From b6dd4878c891b679781f651440a545bd4d142e52 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 10 May 2025 15:54:43 -0700 Subject: [PATCH] All UI seems to work --- frontend/src/AboutPage.tsx | 86 ++++++ frontend/src/ControlsPage.tsx | 434 +++++++++++++++++++++++++++++ frontend/src/Global.tsx | 14 + frontend/src/HomePage.tsx | 69 +++++ frontend/src/ResumeBuilderPage.css | 6 + frontend/src/ResumeBuilderPage.tsx | 367 ++++++++++++++++++++++++ 6 files changed, 976 insertions(+) create mode 100644 frontend/src/AboutPage.tsx create mode 100644 frontend/src/ControlsPage.tsx create mode 100644 frontend/src/Global.tsx create mode 100644 frontend/src/HomePage.tsx create mode 100644 frontend/src/ResumeBuilderPage.css create mode 100644 frontend/src/ResumeBuilderPage.tsx diff --git a/frontend/src/AboutPage.tsx b/frontend/src/AboutPage.tsx new file mode 100644 index 0000000..9b33c68 --- /dev/null +++ b/frontend/src/AboutPage.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from 'react'; + +import { Scrollable } from './Scrollable'; +import { BackstoryPageProps } from './BackstoryTab'; +import { Document } from './Document'; + +const AboutPage = (props: BackstoryPageProps) => { + const { sessionId, submitQuery, setSnack, route, setRoute } = props; + const [ page, setPage ] = useState(""); + const [ subRoute, setSubRoute] = useState(""); + + useEffect(() => { + console.log(`AboutPage: ${page}`); + }, [page]); + useEffect(() => { + console.log(`AboutPage: ${page} - subRoute: ${subRoute}`); + }, [subRoute]); + + useEffect(() => { + if (route === undefined) { return; } + const parts = route.split("/"); + if (parts.length === 0) { return; } + setPage(parts[0]); + if (parts.length > 1) { + parts.shift(); + setSubRoute(parts.join("/")); + } + }, [route, setPage, setSubRoute]); + + const onDocumentExpand = (document: string, open: boolean) => { + console.log("Document expanded:", document, open); + if (open) { + setPage(document); + } else { + setPage(""); + } + /* This is just to quiet warnings for now...*/ + if (route === "never" && subRoute && setRoute) { + setRoute(document); + setSubRoute(document); + } + } + + return + { onDocumentExpand('about', open); }, + expanded: page === 'about', + sessionId, + submitQuery: submitQuery, + setSnack, + }} /> + { onDocumentExpand('resume-generation', open); }, + expanded: page === 'resume-generation', + sessionId, + submitQuery: submitQuery, + setSnack, + }} /> + { onDocumentExpand('about-app', open); }, + expanded: page === 'about-app', + sessionId, + submitQuery: submitQuery, + setSnack, + }} /> + ; +}; + +export { + AboutPage +}; \ No newline at end of file diff --git a/frontend/src/ControlsPage.tsx b/frontend/src/ControlsPage.tsx new file mode 100644 index 0000000..f05df20 --- /dev/null +++ b/frontend/src/ControlsPage.tsx @@ -0,0 +1,434 @@ +import React, { useState, useEffect, ReactElement } from 'react'; +// import FormGroup from '@mui/material/FormGroup'; +// import FormControlLabel from '@mui/material/FormControlLabel'; +// import Switch from '@mui/material/Switch'; +// import Divider from '@mui/material/Divider'; +// import TextField from '@mui/material/TextField'; +import Accordion from '@mui/material/Accordion'; +import AccordionActions from '@mui/material/AccordionActions'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import Typography from '@mui/material/Typography'; +// import Button from '@mui/material/Button'; +// import Box from '@mui/material/Box'; +// import ResetIcon from '@mui/icons-material/History'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +import { connectionBase } from './Global'; +import { BackstoryPageProps } from './BackstoryTab'; +import { restyle } from 'plotly.js'; + +interface ServerTunables { + system_prompt: string, + message_history_length: number, + tools: Tool[], + rags: Tool[] +}; + +type Tool = { + type: string, + enabled: boolean + name: string, + description: string, + parameters?: any, + returns?: any +}; + +type GPUInfo = { + name: string, + memory: number, + discrete: boolean +} + +type SystemInfo = { + "Installed RAM": string, + "Graphics Card": GPUInfo[], + "CPU": string +}; + +const SystemInfoComponent: React.FC<{ systemInfo: SystemInfo | undefined }> = ({ systemInfo }) => { + const [systemElements, setSystemElements] = useState([]); + + const convertToSymbols = (text: string) => { + return text + .replace(/\(R\)/g, '®') // Replace (R) with the ® symbol + .replace(/\(C\)/g, '©') // Replace (C) with the © symbol + .replace(/\(TM\)/g, '™'); // Replace (TM) with the ™ symbol + }; + + useEffect(() => { + if (systemInfo === undefined) { + return; + } + const elements = Object.entries(systemInfo).flatMap(([k, v]) => { + // If v is an array, repeat for each card + if (Array.isArray(v)) { + return v.map((card, index) => ( +
+
{convertToSymbols(k)} {index}
+
{convertToSymbols(card.name)} {card.discrete ? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM` : "(integrated)"}
+
+ )); + } + + // If it's not an array, handle normally + return ( +
+
{convertToSymbols(k)}
+
{convertToSymbols(String(v))}
+
+ ); + }); + + setSystemElements(elements); + }, [systemInfo]); + + return
{systemElements}
; +}; + +const ControlsPage = ({ sessionId, setSnack }: BackstoryPageProps) => { + const [editSystemPrompt, setEditSystemPrompt] = useState(""); + const [systemInfo, setSystemInfo] = useState(undefined); + const [tools, setTools] = useState([]); + const [rags, setRags] = useState([]); + const [systemPrompt, setSystemPrompt] = useState(""); + const [messageHistoryLength, setMessageHistoryLength] = useState(5); + const [serverTunables, setServerTunables] = useState(undefined); + + useEffect(() => { + if (serverTunables === undefined || systemPrompt === serverTunables.system_prompt || !systemPrompt.trim() || sessionId === undefined) { + return; + } + const sendSystemPrompt = async (prompt: string) => { + try { + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "system_prompt": prompt }), + }); + + const tunables = await response.json(); + serverTunables.system_prompt = tunables.system_prompt; + setSystemPrompt(tunables.system_prompt) + setSnack("System prompt updated", "success"); + } catch (error) { + console.error('Fetch error:', error); + setSnack("System prompt update failed", "error"); + } + }; + + sendSystemPrompt(systemPrompt); + + }, [systemPrompt, sessionId, setSnack, serverTunables]); + + useEffect(() => { + if (serverTunables === undefined || messageHistoryLength === serverTunables.message_history_length || !messageHistoryLength || sessionId === undefined) { + return; + } + const sendMessageHistoryLength = async (length: number) => { + try { + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "message_history_length": length }), + }); + + const data = await response.json(); + const newLength = data["message_history_length"]; + if (newLength !== messageHistoryLength) { + setMessageHistoryLength(newLength); + setSnack("Message history length updated", "success"); + } + } catch (error) { + console.error('Fetch error:', error); + setSnack("Message history length update failed", "error"); + } + }; + + sendMessageHistoryLength(messageHistoryLength); + + }, [messageHistoryLength, setMessageHistoryLength, sessionId, setSnack, serverTunables]); + + const reset = async (types: ("rags" | "tools" | "history" | "system_prompt" | "message_history_length")[], message: string = "Update successful.") => { + try { + const response = await fetch(connectionBase + `/api/reset/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "reset": types }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.error) { + throw Error() + } + for (const [key, value] of Object.entries(data)) { + switch (key) { + case "rags": + setRags(value as Tool[]); + break; + case "tools": + setTools(value as Tool[]); + break; + case "system_prompt": + setSystemPrompt((value as ServerTunables)["system_prompt"].trim()); + break; + case "history": + console.log('TODO: handle history reset'); + break; + } + } + setSnack(message, "success"); + } else { + throw Error(`${{ status: response.status, message: response.statusText }}`); + } + } catch (error) { + console.error('Fetch error:', error); + setSnack("Unable to restore defaults", "error"); + } + }; + + // Get the system information + useEffect(() => { + if (systemInfo !== undefined || sessionId === undefined) { + return; + } + fetch(connectionBase + `/api/system-info/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(response => response.json()) + .then(data => { + setSystemInfo(data); + }) + .catch(error => { + console.error('Error obtaining system information:', error); + setSnack("Unable to obtain system information.", "error"); + }); + }, [systemInfo, setSystemInfo, setSnack, sessionId]) + + useEffect(() => { + setEditSystemPrompt(systemPrompt.trim()); + }, [systemPrompt, setEditSystemPrompt]); + + const toggleRag = async (tool: Tool) => { + tool.enabled = !tool.enabled + try { + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "rags": [{ "name": tool?.name, "enabled": tool.enabled }] }), + }); + + const tunables: ServerTunables = await response.json(); + setRags(tunables.rags) + setSnack(`${tool?.name} ${tool.enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error('Fetch error:', error); + setSnack(`${tool?.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error"); + tool.enabled = !tool.enabled + } + }; + + const toggleTool = async (tool: Tool) => { + tool.enabled = !tool.enabled + try { + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "tools": [{ "name": tool.name, "enabled": tool.enabled }] }), + }); + + const tunables: ServerTunables = await response.json(); + setTools(tunables.tools) + setSnack(`${tool.name} ${tool.enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error('Fetch error:', error); + setSnack(`${tool.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error"); + tool.enabled = !tool.enabled + } + }; + + // If the systemPrompt has not been set, fetch it from the server + useEffect(() => { + if (serverTunables !== undefined || sessionId === undefined) { + return; + } + const fetchTunables = async () => { + // Make the fetch request with proper headers + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + const data = await response.json(); + console.log("Server tunables: ", data); + setServerTunables(data); + setSystemPrompt(data["system_prompt"]); + setMessageHistoryLength(data["message_history_length"]); + setTools(data["tools"]); + setRags(data["rags"]); + } + + fetchTunables(); + }, [sessionId, setServerTunables, setSystemPrompt, setMessageHistoryLength, serverTunables, setTools, setRags]); + + const toggle = async (type: string, index: number) => { + switch (type) { + case "rag": + if (rags === undefined) { + return; + } + toggleRag(rags[index]) + break; + case "tool": + if (tools === undefined) { + return; + } + toggleTool(tools[index]); + } + }; + + const handleKeyPress = (event: any) => { + if (event.key === 'Enter' && event.ctrlKey) { + setSystemPrompt(editSystemPrompt); + } + }; + + return (
+ {/* + You can change the information available to the LLM by adjusting the following settings: + + + }> + System Prompt + + + setEditSystemPrompt(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Enter the new system prompt.." + /> + + + + + + + + + }> + Tunables + + + setMessageHistoryLength(e.target.value)} + slotProps={{ + htmlInput: { + min: 0 + }, + inputLabel: { + shrink: true, + }, + }} + /> + + + + + }> + Tools + + + These tools can be made available to the LLM for obtaining real-time information from the Internet. The description provided to the LLM is provided for reference. + + + + { + (tools || []).map((tool, index) => + + + } onChange={() => toggle("tool", index)} label={tool.name} /> + {tool.description} + + ) + } + + + + + }> + RAG + + + These RAG databases can be enabled / disabled for adding additional context based on the chat request. + + + + { + (rags || []).map((rag, index) => + + + } + onChange={() => toggle("rag", index)} label={rag.name} + /> + {rag.description} + + ) + } + + */} + + + }> + System Information + + + The server is running on the following hardware: + + + + + + + {/* + */} +
); +} + +export { + ControlsPage +}; \ No newline at end of file diff --git a/frontend/src/Global.tsx b/frontend/src/Global.tsx new file mode 100644 index 0000000..338e94d --- /dev/null +++ b/frontend/src/Global.tsx @@ -0,0 +1,14 @@ +const getConnectionBase = (loc: any): string => { + console.log(`getConnectionBase(${loc})`) + if (!loc.host.match(/.*battle-linux.*/)) { + return loc.protocol + "//" + loc.host; + } else { + return loc.protocol + "//battle-linux.ketrenos.com:8912"; + } +} + +const connectionBase = getConnectionBase(window.location); + +export { + connectionBase +}; \ No newline at end of file diff --git a/frontend/src/HomePage.tsx b/frontend/src/HomePage.tsx new file mode 100644 index 0000000..7f3ef4d --- /dev/null +++ b/frontend/src/HomePage.tsx @@ -0,0 +1,69 @@ +import React, { forwardRef } from 'react'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import MuiMarkdown from 'mui-markdown'; + +import { BackstoryPageProps } from './BackstoryTab'; +import { Conversation, ConversationHandle } from './Conversation'; +import { ChatQuery } from './ChatQuery'; +import { MessageList } from './Message'; + +const HomePage = forwardRef((props: BackstoryPageProps, ref) => { + const { sessionId, setSnack, submitQuery } = props; + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const backstoryPreamble: MessageList = [ + { + role: 'content', + title: 'Welcome to Backstory', + disableCopy: true, + content: ` +Backstory is a RAG enabled expert system with access to real-time data running self-hosted +(no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs). +It was written by James Ketrenos in order to provide answers to +questions potential employers may have about his work history. + +What would you like to know about James? +` + } + ]; + + const backstoryQuestions = [ + + + + + + , + + + As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career, + I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**. + + + ]; + + return ; +}); + +export { + HomePage +}; \ No newline at end of file diff --git a/frontend/src/ResumeBuilderPage.css b/frontend/src/ResumeBuilderPage.css new file mode 100644 index 0000000..0f71ee9 --- /dev/null +++ b/frontend/src/ResumeBuilderPage.css @@ -0,0 +1,6 @@ +.ResumeBuilder .JsonViewScrollable { + min-height: unset !important; + max-height: 30rem !important; + border: 1px solid orange; + overflow-x: auto !important; +} \ No newline at end of file diff --git a/frontend/src/ResumeBuilderPage.tsx b/frontend/src/ResumeBuilderPage.tsx new file mode 100644 index 0000000..6a7c6f7 --- /dev/null +++ b/frontend/src/ResumeBuilderPage.tsx @@ -0,0 +1,367 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + Tabs, + Tab, + Box, +} from '@mui/material'; +import { SxProps } from '@mui/material'; + +import { ChatQuery } from './ChatQuery'; +import { MessageList, MessageData } from './Message'; +import { Conversation } from './Conversation'; +import { BackstoryPageProps } from './BackstoryTab'; + +import './ResumeBuilderPage.css'; + +/** + * ResumeBuilder component + * + * A responsive component that displays job descriptions, generated resumes and fact checks + * with different layouts for mobile and desktop views. + */ +const ResumeBuilderPage: React.FC = ({ + sx, + sessionId, + setSnack, + submitQuery, +}) => { + // State for editing job description + const [hasJobDescription, setHasJobDescription] = useState(false); + const [hasResume, setHasResume] = useState(false); + const [hasFacts, setHasFacts] = useState(false); + const jobConversationRef = useRef(null); + const resumeConversationRef = useRef(null); + const factsConversationRef = useRef(null); + + const [activeTab, setActiveTab] = useState(0); + + /** + * Handle tab change for mobile view + */ + const handleTabChange = (_event: React.SyntheticEvent, newValue: number): void => { + setActiveTab(newValue); + }; + + const handleJobQuery = (query: string) => { + console.log(`handleJobQuery: ${query} -- `, jobConversationRef.current ? ' sending' : 'no handler'); + jobConversationRef.current?.submitQuery(query); + }; + + const handleResumeQuery = (query: string) => { + console.log(`handleResumeQuery: ${query} -- `, resumeConversationRef.current ? ' sending' : 'no handler'); + resumeConversationRef.current?.submitQuery(query); + }; + + const handleFactsQuery = (query: string) => { + console.log(`handleFactsQuery: ${query} -- `, factsConversationRef.current ? ' sending' : 'no handler'); + factsConversationRef.current?.submitQuery(query); + }; + + const filterJobDescriptionMessages = useCallback((messages: MessageList): MessageList => { + if (messages === undefined || messages.length === 0) { + return []; + } + + if (messages.length > 2) { + setHasResume(true); + setHasFacts(true); + } + + if (messages.length > 0) { + messages[0].role = 'content'; + messages[0].title = 'Job Description'; + messages[0].disableCopy = false; + messages[0].expandable = true; + } + + if (messages.length > 3) { + // messages[2] is Show job requirements + messages[3].role = 'job-requirements'; + messages[3].title = 'Job Requirements'; + messages[3].disableCopy = false; + messages[3].expanded = false; + messages[3].expandable = true; + } + + /* Filter out the 2nd and 3rd (0-based) */ + const filtered = messages.filter((m, i) => i !== 1 && i !== 2); + + return filtered; + }, [setHasResume, setHasFacts]); + + const filterResumeMessages = useCallback((messages: MessageList): MessageList => { + if (messages === undefined || messages.length === 0) { + return []; + } + + if (messages.length > 1) { + // messages[0] is Show Qualifications + messages[1].role = 'qualifications'; + messages[1].title = 'Candidate qualifications'; + messages[1].disableCopy = false; + messages[1].expanded = false; + messages[1].expandable = true; + } + + if (messages.length > 3) { + // messages[2] is Show Resume + messages[3].role = 'resume'; + messages[3].title = 'Generated Resume'; + messages[3].disableCopy = false; + messages[3].expanded = true; + messages[3].expandable = true; + } + + /* Filter out the 1st and 3rd messages (0-based) */ + const filtered = messages.filter((m, i) => i !== 0 && i !== 2); + + return filtered; + }, []); + + const filterFactsMessages = useCallback((messages: MessageList): MessageList => { + if (messages === undefined || messages.length === 0) { + return []; + } + + if (messages.length > 1) { + // messages[0] is Show verification + messages[1].role = 'fact-check'; + messages[1].title = 'Fact Check'; + messages[1].disableCopy = false; + messages[1].expanded = true; + messages[1].expandable = true; + } + + /* Filter out the 1st (0-based) */ + const filtered = messages.filter((m, i) => i !== 0); + + return filtered; + }, []); + + const jobResponse = useCallback(async (message: MessageData) => { + console.log('onJobResponse', message); + if (message.actions && message.actions.includes("job_description")) { + await jobConversationRef.current.fetchHistory(); + } + if (message.actions && message.actions.includes("resume_generated")) { + await resumeConversationRef.current.fetchHistory(); + setHasResume(true); + setActiveTab(1); // Switch to Resume tab + } + if (message.actions && message.actions.includes("facts_checked")) { + await factsConversationRef.current.fetchHistory(); + setHasFacts(true); + } + }, [setHasFacts, setHasResume, setActiveTab]); + + const resumeResponse = useCallback((message: MessageData): void => { + console.log('onResumeResponse', message); + setHasFacts(true); + }, [setHasFacts]); + + const factsResponse = useCallback((message: MessageData): void => { + console.log('onFactsResponse', message); + }, []); + + const resetJobDescription = useCallback(() => { + setHasJobDescription(false); + setHasResume(false); + setHasFacts(false); + }, [setHasJobDescription, setHasResume, setHasFacts]); + + const resetResume = useCallback(() => { + setHasResume(false); + setHasFacts(false); + }, [setHasResume, setHasFacts]); + + const resetFacts = useCallback(() => { + setHasFacts(false); + }, [setHasFacts]); + + const renderJobDescriptionView = useCallback((sx: SxProps) => { + console.log('renderJobDescriptionView'); + const jobDescriptionQuestions = [ + + + + , + ]; + + const jobDescriptionPreamble: MessageList = [{ + role: 'info', + content: `Once you paste a job description and press **Generate Resume**, the system will perform the following actions: + +1. **RAG**: Collects information from the RAG database relavent to the job description +2. **Isolated Analysis**: Three sub-stages + 1. **Job Analysis**: Extracts requirements from job description only + 2. **Candidate Analysis**: Catalogs qualifications from resume/context only + 3. **Mapping Analysis**: Identifies legitimate matches between requirements and qualifications +3. **Resume Generation**: Uses mapping output to create a tailored resume with evidence-based content +4. **Verification**: Performs fact-checking to catch any remaining fabrications + 1. **Re-generation**: If verification does not pass, a second attempt is made to correct any issues` + }]; + + + if (!hasJobDescription) { + return + + } else { + return + } + }, [filterJobDescriptionMessages, hasJobDescription, sessionId, setSnack, jobResponse, resetJobDescription, hasFacts, hasResume, submitQuery]); + + /** + * Renders the resume view with loading indicator + */ + const renderResumeView = useCallback((sx: SxProps) => { + const resumeQuestions = [ + + + + , + ]; + + if (!hasFacts) { + return + } else { + return + } + }, [filterResumeMessages, hasFacts, sessionId, setSnack, resumeResponse, resetResume, hasResume, submitQuery]); + + /** + * Renders the fact check view + */ + const renderFactCheckView = useCallback((sx: SxProps) => { + const factsQuestions = [ + + + , + ]; + + return + }, [ sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts, submitQuery]); + + return ( + + {/* Tabs */} + + + {hasResume && } + {hasFacts && } + + + {/* Document display area */} + + {renderJobDescriptionView({ height: "calc(100vh - 72px - 48px)" })} + {renderResumeView({ height: "calc(100vh - 72px - 48px)" })} + {renderFactCheckView({ height: "calc(100vh - 72px - 48px)" })} + + + ); +}; + +export { + ResumeBuilderPage +}; +