Refactoring to a single Conversation element

This commit is contained in:
James Ketr 2025-04-22 13:05:54 -07:00
parent 02915b9a23
commit 4ce616b64b
12 changed files with 592 additions and 437 deletions

View File

@ -4,6 +4,11 @@ div {
word-break: break-word; word-break: break-word;
} }
.gl-container #scene {
top: 0px !important;
left: 0px !important;
}
pre { pre {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;

View File

@ -19,8 +19,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import { ResumeBuilder } from './ResumeBuilder'; import { ResumeBuilder } from './ResumeBuilder';
import { Message } from './Message'; import { Message, ChatQuery, MessageList, MessageData } from './Message';
import { MessageData } from './MessageMeta';
import { SeverityType } from './Snack'; import { SeverityType } from './Snack';
import { VectorVisualizer } from './VectorVisualizer'; import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls'; import { Controls } from './Controls';
@ -33,6 +32,8 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css'; import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css'; import '@fontsource/roboto/700.css';
import MuiMarkdown from 'mui-markdown';
const getConnectionBase = (loc: any): string => { const getConnectionBase = (loc: any): string => {
if (!loc.host.match(/.*battle-linux.*/)) { if (!loc.host.match(/.*battle-linux.*/)) {
@ -130,10 +131,41 @@ const App = () => {
}, [about, setAbout]) }, [about, setAbout])
const handleSubmitChatQuery = () => { const handleSubmitChatQuery = (query: string) => {
chatRef.current?.submitQuery(); console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
}; };
const chatPreamble: MessageList = [
{
role: 'info',
content: `
# Welcome to Backstory
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 chatQuestions = [
<Box sx={{ display: "flex", flexDirection: "row" }}>
<ChatQuery text="What is James Ketrenos' work history?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What programming languages has James used?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are James' professional strengths?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are today's headlines on CNBC.com?" submitQuery={handleSubmitChatQuery} />
</Box>,
<MuiMarkdown>
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**.
</MuiMarkdown>
];
// Extract the sessionId from the URL if present, otherwise // Extract the sessionId from the URL if present, otherwise
// request a sessionId from the server. // request a sessionId from the server.
useEffect(() => { useEffect(() => {
@ -368,10 +400,12 @@ const App = () => {
ref={chatRef} ref={chatRef}
{...{ {...{
type: "chat", type: "chat",
prompt: "Enter your question...", prompt: "What would you like to know about James?",
sessionId, sessionId,
connectionBase, connectionBase,
setSnack setSnack,
preamble: chatPreamble,
defaultPrompts: chatQuestions
}} }}
/> />
</Box> </Box>
@ -392,7 +426,7 @@ const App = () => {
<CustomTabPanel tab={tab} index={3}> <CustomTabPanel tab={tab} index={3}>
<Box className="ChatBox"> <Box className="ChatBox">
<Box className="Conversation"> <Box className="Conversation">
<Message {...{ message: { role: 'assistant', content: about }, submitQuery: handleSubmitChatQuery }} /> <Message {...{ message: { role: 'assistant', content: about }, submitQuery: handleSubmitChatQuery, connectionBase, sessionId, setSnack }} />
</Box> </Box>
</Box> </Box>
</CustomTabPanel> </CustomTabPanel>

View File

@ -2,7 +2,7 @@ import { Box } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from '@mui/material';
import React from 'react'; import React from 'react';
import { MessageRoles } from './MessageMeta'; import { MessageRoles } from './Message';
interface ChatBubbleProps { interface ChatBubbleProps {
role: MessageRoles, role: MessageRoles,
@ -16,64 +16,52 @@ interface ChatBubbleProps {
function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubbleProps) { function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubbleProps) {
const theme = useTheme(); const theme = useTheme();
const styles = { const defaultRadius = '16px';
'user': { const defaultStyle = {
backgroundColor: theme.palette.background.default, // Warm Gray (#D3CDBF) padding: theme.spacing(1, 1),
border: `1px solid ${theme.palette.custom.highlight}`, // Golden Ochre (#D4A017)
borderRadius: '16px 16px 0 16px', // Rounded, flat bottom-right for user
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '100%',
minWidth: '80%',
alignSelf: 'flex-end', // Right-aligned for user
color: theme.palette.primary.main, // Midnight Blue (#1A2536) for text
'& > *': {
color: 'inherit', // Children inherit Midnight Blue unless overridden
},
},
'assistant': {
backgroundColor: theme.palette.primary.main, // Midnight Blue (#1A2536)
border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal (#4A7A7D)
borderRadius: '16px 16px 16px 0', // Rounded, flat bottom-left for assistant
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '100%',
minWidth: '80%',
alignSelf: 'flex-start', // Left-aligned for assistant
color: theme.palette.primary.contrastText, // Warm Gray (#D3CDBF) for text
'& > *': {
color: 'inherit', // Children inherit Warm Gray unless overridden
},
},
'system': {
backgroundColor: '#EDEAE0', // Soft warm gray that plays nice with #D3CDBF
border: `1px dashed ${theme.palette.custom.highlight}`, // Golden Ochre
borderRadius: '12px',
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '90%',
minWidth: '60%',
alignSelf: 'center',
color: theme.palette.text.primary, // Charcoal Black
fontStyle: 'italic',
fontSize: '0.95rem',
'& > *': {
color: 'inherit',
},
},
'info': {
backgroundColor: '#BFD8D8', // Softened Dusty Teal
border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal
borderRadius: '16px',
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '100%',
minWidth: '70%',
alignSelf: 'flex-start',
color: theme.palette.text.primary, // Charcoal Black (#2E2E2E) — much better contrast
opacity: 0.95,
fontSize: '0.875rem', fontSize: '0.875rem',
alignSelf: 'flex-start', // Left-aligned is used by default
maxWidth: '100%',
minWidth: '80%',
'& > *': { '& > *': {
color: 'inherit', color: 'inherit', // Children inherit 'color' from parent
}, },
} }
const styles = {
'user': {
...defaultStyle,
backgroundColor: theme.palette.background.default, // Warm Gray (#D3CDBF)
border: `1px solid ${theme.palette.custom.highlight}`, // Golden Ochre (#D4A017)
borderRadius: `${defaultRadius} ${defaultRadius} 0 ${defaultRadius}`, // Rounded, flat bottom-right for user
alignSelf: 'flex-end', // Right-aligned for user
color: theme.palette.primary.main, // Midnight Blue (#1A2536) for text
},
'assistant': {
...defaultStyle,
backgroundColor: theme.palette.primary.main, // Midnight Blue (#1A2536)
border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal (#4A7A7D)
borderRadius: `${defaultRadius} ${defaultRadius} ${defaultRadius} 0`, // Rounded, flat bottom-left for assistant
color: theme.palette.primary.contrastText, // Warm Gray (#D3CDBF) for text
},
'system': {
...defaultStyle,
backgroundColor: '#EDEAE0', // Soft warm gray that plays nice with #D3CDBF
border: `1px dashed ${theme.palette.custom.highlight}`, // Golden Ochre
borderRadius: defaultRadius,
maxWidth: isFullWidth ? '100%' : '90%',
alignSelf: 'center',
color: theme.palette.text.primary, // Charcoal Black
fontStyle: 'italic',
},
'info': {
...defaultStyle,
backgroundColor: '#BFD8D8', // Softened Dusty Teal
border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal
borderRadius: defaultRadius,
color: theme.palette.text.primary, // Charcoal Black (#2E2E2E) — much better contrast
opacity: 0.95,
}
}; };
return ( return (

View File

@ -8,50 +8,30 @@ import SendIcon from '@mui/icons-material/Send';
import PropagateLoader from "react-spinners/PropagateLoader"; import PropagateLoader from "react-spinners/PropagateLoader";
import { Message, MessageList } from './Message'; import { Message, MessageList, MessageData } from './Message';
import { SeverityType } from './Snack'; import { SeverityType } from './Snack';
import { ContextStatus } from './ContextStatus'; import { ContextStatus } from './ContextStatus';
import { MessageData } from './MessageMeta';
const welcomeMarkdown = ` const loadingMessage: MessageData = { "role": "assistant", "content": "Establishing connection with server..." };
# Welcome to Backstory
Backstory was written by James Ketrenos in order to provide answers to
questions potential employers may have about his work history.
You can ask things like:
<ChatQuery text="What is James Ketrenos' work history?"/>
<ChatQuery text="What programming languages has James used?"/>
<ChatQuery text="What are James' professional strengths?"/>
<ChatQuery text="What are today's headlines on CNBC.com?"/>
You can click the text above to submit that query, or type it in yourself (or whatever questions you may have.)
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).
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**.`;
const welcomeMessage: MessageData = {
"role": "assistant", "content": welcomeMarkdown
};
const loadingMessage: MessageData = { "role": "assistant", "content": "Instancing chat session..." };
type ConversationMode = 'chat' | 'fact-check' | 'system'; type ConversationMode = 'chat' | 'fact-check' | 'system';
interface ConversationHandle { interface ConversationHandle {
submitQuery: () => void; submitQuery: (query: string) => void;
} }
interface ConversationProps { interface ConversationProps {
type: ConversationMode type: ConversationMode
prompt: string, prompt: string,
connectionBase: string, connectionBase: string,
sessionId: string | undefined, sessionId?: string,
setSnack: (message: string, severity: SeverityType) => void, setSnack: (message: string, severity: SeverityType) => void,
defaultPrompts?: React.ReactElement[],
preamble?: MessageList,
hideDefaultPrompts?: boolean,
}; };
const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt, type, sessionId, setSnack, connectionBase} : ConversationProps, ref) => { const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt, type, preamble, hideDefaultPrompts, defaultPrompts, sessionId, setSnack, connectionBase }: ConversationProps, ref) => {
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0); const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
@ -62,6 +42,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
const [lastPromptTPS, setLastPromptTPS] = useState<number>(430); const [lastPromptTPS, setLastPromptTPS] = useState<number>(430);
const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 }); const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 });
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false); const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
// Update the context status // Update the context status
const updateContextStatus = useCallback(() => { const updateContextStatus = useCallback(() => {
@ -93,29 +74,39 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
useEffect(() => { useEffect(() => {
if (sessionId === undefined) { if (sessionId === undefined) {
setConversation([loadingMessage]); setConversation([loadingMessage]);
} else { return;
fetch(connectionBase + `/api/history/${sessionId}`, { }
const fetchHistory = async () => {
try {
const response = await fetch(connectionBase + `/api/history/${sessionId}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) });
.then(response => response.json()) if (!response.ok) {
.then(data => { throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`Session id: ${sessionId} -- history returned from server with ${data.length} entries`) console.log(`Session id: ${sessionId} -- history returned from server with ${data.length} entries`)
setConversation([ if (data.length === 0) {
welcomeMessage, setConversation(preamble || []);
...data setNoInteractions(true);
]); } else {
}) setConversation(data);
.catch(error => { setNoInteractions(false);
}
updateContextStatus();
} catch (error) {
console.error('Error generating session ID:', error); console.error('Error generating session ID:', error);
setSnack("Unable to obtain chat history.", "error"); setSnack("Unable to obtain chat history.", "error");
});
updateContextStatus();
} }
}, [sessionId, setConversation, updateContextStatus, connectionBase, setSnack]); };
if (sessionId !== undefined) {
fetchHistory();
}
}, [sessionId, setConversation, updateContextStatus, connectionBase, setSnack, preamble]);
const isScrolledToBottom = useCallback(()=> { const isScrolledToBottom = useCallback(()=> {
// Current vertical scroll position // Current vertical scroll position
@ -138,7 +129,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}); });
}, []); }, []);
const startCountdown = (seconds: number) => { const startCountdown = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
setCountdown(seconds); setCountdown(seconds);
@ -159,10 +149,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}, 1000); }, 1000);
}; };
const submitQuery = (text: string) => {
sendQuery(text);
}
const stopCountdown = () => { const stopCountdown = () => {
if (timerRef.current) { if (timerRef.current) {
clearInterval(timerRef.current); clearInterval(timerRef.current);
@ -182,11 +168,15 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}; };
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
submitQuery: () => { submitQuery: (query: string) => {
sendQuery(query); sendQuery(query);
} }
})); }));
const submitQuery = (query: string) => {
sendQuery(query);
}
// If context status changes, show a warning if necessary. If it drops // If context status changes, show a warning if necessary. If it drops
// back below the threshold, clear the warning trigger // back below the threshold, clear the warning trigger
useEffect(() => { useEffect(() => {
@ -202,6 +192,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]); }, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]);
const sendQuery = async (query: string) => { const sendQuery = async (query: string) => {
setNoInteractions(false);
if (!query.trim()) return; if (!query.trim()) return;
//setTab(0); //setTab(0);
@ -381,9 +373,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}; };
return ( return (
<Box className="ConversationContainer" sx={{ display: "flex", flexDirection: "column", height: "100%", overflowY: "auto" }}> <Box className="Conversation" sx={{ display: "flex", flexDirection: "column", overflowY: "auto" }}>
<Box className="Conversation" sx={{ flexGrow: 2, p: 1 }}> {conversation.map((message, index) => <Message key={index} {...{ submitQuery, message, connectionBase, sessionId, setSnack }} />)}
{conversation.map((message, index) => <Message key={index} submitQuery={submitQuery} message={message} />)}
<Box sx={{ <Box sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -407,15 +398,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
>Estimated response time: {countdown}s</Box> >Estimated response time: {countdown}s</Box>
)} )}
</Box> </Box>
<Box sx={{ ml: "0.25rem", fontSize: "0.6rem", color: "darkgrey", display: "flex", flexDirection: "row", gap: 1, mt: "auto" }}>
Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context}
{
contextUsedPercentage >= 90 ? <Typography sx={{ fontSize: "0.6rem", color: "red" }}>WARNING: Context almost exhausted. You should start a new chat.</Typography>
: (contextUsedPercentage >= 50 ? <Typography sx={{ fontSize: "0.6rem", color: "orange" }}>NOTE: Context is getting long. Queries will be slower, and the LLM may stop issuing tool calls.</Typography>
: <></>)
}
</Box>
</Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}> <Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField <TextField
variant="outlined" variant="outlined"
@ -425,13 +407,31 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
placeholder="Enter your question..." placeholder={prompt}
id="QueryInput" id="QueryInput"
/> />
<Tooltip title="Send"> <Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(query); }}><SendIcon /></Button> <Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(query); }}><SendIcon /></Button>
</Tooltip> </Tooltip>
</Box> </Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
{
defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>);
})
}
</Box>
}
<Box sx={{ ml: "0.25rem", fontSize: "0.6rem", color: "darkgrey", display: "flex", flexShrink: 1, flexDirection: "row", gap: 1, mb: "auto", mt: 1 }}>
Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context}
{
contextUsedPercentage >= 90 ? <Typography sx={{ fontSize: "0.6rem", color: "red" }}>WARNING: Context almost exhausted. You should start a new chat.</Typography>
: (contextUsedPercentage >= 50 ? <Typography sx={{ fontSize: "0.6rem", color: "orange" }}>NOTE: Context is getting long. Queries will be slower, and the LLM may stop issuing tool calls.</Typography>
: <></>)
}
</Box>
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box> </Box>
); );
}); });

View File

@ -1,27 +0,0 @@
import { SxProps, Theme } from '@mui/material';
import { MessageData } from './MessageMeta';
/**
* Props for the DocumentViewer component
* @interface DocumentViewerProps
* @property {function} generateResume - Function to generate a resume based on job description
* @property {MessageData | undefined} resume - The generated resume data
* @property {function} setResume - Function to set the generated resume
* @property {function} factCheck - Function to fact check the generated resume
* @property {MessageData | undefined} facts - The fact check results
* @property {function} setFacts - Function to set the fact check results
* @property {string} jobDescription - The initial job description
* @property {function} setJobDescription - Function to set the job description
* @property {SxProps<Theme>} [sx] - Optional styling properties
*/
export interface DocumentViewerProps {
generateResume: (jobDescription: string) => void;
resume: MessageData | undefined;
setResume: (resume: MessageData | undefined) => void;
factCheck: (resume: string) => void;
facts: MessageData | undefined;
setFacts: (facts: MessageData | undefined) => void;
jobDescription: string | undefined;
setJobDescription: (jobDescription: string | undefined) => void;
sx?: SxProps<Theme>;
}

View File

@ -24,12 +24,42 @@ import {
RestartAlt as ResetIcon, RestartAlt as ResetIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import PropagateLoader from "react-spinners/PropagateLoader"; import PropagateLoader from "react-spinners/PropagateLoader";
import { SxProps, Theme } from '@mui/material';
import MuiMarkdown from 'mui-markdown';
import { Message } from './Message'; import { Message } from './Message';
import { Document } from './Document'; import { Document } from './Document';
import { DocumentViewerProps } from './DocumentTypes'; import { MessageData } from './Message';
import MuiMarkdown from 'mui-markdown'; import { SeverityType } from './Snack';
/**
* Props for the DocumentViewer component
* @interface DocumentViewerProps
* @property {function} generateResume - Function to generate a resume based on job description
* @property {MessageData | undefined} resume - The generated resume data
* @property {function} setResume - Function to set the generated resume
* @property {function} factCheck - Function to fact check the generated resume
* @property {MessageData | undefined} facts - The fact check results
* @property {function} setFacts - Function to set the fact check results
* @property {string} jobDescription - The initial job description
* @property {function} setJobDescription - Function to set the job description
* @property {SxProps<Theme>} [sx] - Optional styling properties
*/
export interface DocumentViewerProps {
generateResume: (jobDescription: string) => void;
resume: MessageData | undefined;
setResume: (resume: MessageData | undefined) => void;
factCheck: (resume: string) => void;
facts: MessageData | undefined;
setFacts: (facts: MessageData | undefined) => void;
jobDescription: string | undefined;
setJobDescription: (jobDescription: string | undefined) => void;
sx?: SxProps<Theme>;
connectionBase: string;
sessionId: string;
setSnack: (message: string, severity: SeverityType) => void,
}
/** /**
* DocumentViewer component * DocumentViewer component
* *
@ -44,7 +74,10 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
setResume, setResume,
facts, facts,
setFacts, setFacts,
sx sx,
connectionBase,
sessionId,
setSnack
}) => { }) => {
// State for editing job description // State for editing job description
const [editJobDescription, setEditJobDescription] = useState<string | undefined>(jobDescription); const [editJobDescription, setEditJobDescription] = useState<string | undefined>(jobDescription);
@ -223,7 +256,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
const renderResumeView = () => ( const renderResumeView = () => (
<Box key="ResumeView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0 }}> <Box key="ResumeView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0 }}>
<Document sx={{ display: "flex", flexGrow: 1 }} title=""> <Document sx={{ display: "flex", flexGrow: 1 }} title="">
{resume !== undefined && <Message message={resume} />} {resume !== undefined && <Message {...{ message: resume, connectionBase, sessionId, setSnack }} />}
</Document> </Document>
{processing === "resume" && ( {processing === "resume" && (
<Box sx={{ <Box sx={{
@ -257,13 +290,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
const renderFactCheckView = () => ( const renderFactCheckView = () => (
<Box key="FactView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0, p: 0 }}> <Box key="FactView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0, p: 0 }}>
<Document sx={{ display: "flex", flexGrow: 1 }} title=""> <Document sx={{ display: "flex", flexGrow: 1 }} title="">
{facts !== undefined && <Message message={facts} />} {facts !== undefined && <Message {...{ message: facts, connectionBase, sessionId, setSnack }} />}
{/* <pre>
With over 20 years of experience as a software architect, team lead, and developer, James Ketrenos brings a unique blend of technical expertise and leadership to the table. Focused on advancing energy-efficient AI solutions, he excels in designing, building, and deploying scalable systems that enable rapid product development. His extensive background in Linux software architecture, DevOps, and open-source technologies makes him an ideal candidate for leading roles at technology-driven companies.
---
</pre> */}
</Document> </Document>
{processing === "facts" && ( {processing === "facts" && (
<Box sx={{ <Box sx={{

View File

@ -1,4 +1,15 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import Divider from '@mui/material/Divider';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Card from '@mui/material/Card';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@ -11,17 +22,50 @@ import { ExpandMore } from './ExpandMore';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from '@mui/icons-material/Check';
import { MessageData, MessageMeta } from './MessageMeta';
import { ChatBubble } from './ChatBubble'; import { ChatBubble } from './ChatBubble';
import { StyledMarkdown } from './StyledMarkdown'; import { StyledMarkdown } from './StyledMarkdown';
import { Tooltip } from '@mui/material'; import { Tooltip } from '@mui/material';
import { VectorVisualizer } from './VectorVisualizer';
import { SeverityType } from './Snack';
type MessageRoles = 'info' | 'user' | 'assistant' | 'system';
type MessageData = {
role: MessageRoles,
content: string,
user?: string,
type?: string,
id?: string,
isProcessing?: boolean,
metadata?: MessageMetaProps
};
interface MessageMetaProps {
query?: {
query_embedding: number[];
vector_embedding: number[];
},
rag: any,
tools: any[],
eval_count: number,
eval_duration: number,
prompt_eval_count: number,
prompt_eval_duration: number,
sessionId?: string,
connectionBase: string,
setSnack: (message: string, severity: SeverityType) => void,
}
type MessageList = MessageData[]; type MessageList = MessageData[];
interface MessageInterface { interface MessageProps {
message?: MessageData, message?: MessageData,
isFullWidth?: boolean, isFullWidth?: boolean,
submitQuery?: (text: string) => void submitQuery?: (text: string) => void,
sessionId?: string,
connectionBase: string,
setSnack: (message: string, severity: SeverityType) => void,
}; };
interface ChatQueryInterface { interface ChatQueryInterface {
@ -29,18 +73,135 @@ interface ChatQueryInterface {
submitQuery?: (text: string) => void submitQuery?: (text: string) => void
} }
const MessageMeta = ({ ...props }: MessageMetaProps) => {
return (<>
<Box sx={{ fontSize: "0.8rem", mb: 1 }}>
Below is the LLM performance of this query. Note that if tools are called, the
entire context is processed for each separate tool request by the LLM. This
can dramatically increase the total time for a response.
</Box>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right" >Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Prompt</TableCell>
<TableCell align="right">{props.prompt_eval_count}</TableCell>
<TableCell align="right">{Math.round(props.prompt_eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(props.prompt_eval_count * 10 ** 9 / props.prompt_eval_duration)}</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Response</TableCell>
<TableCell align="right">{props.eval_count}</TableCell>
<TableCell align="right">{Math.round(props.eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(props.eval_count * 10 ** 9 / props.eval_duration)}</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Total</TableCell>
<TableCell align="right">{props.prompt_eval_count + props.eval_count}</TableCell>
<TableCell align="right">{Math.round((props.prompt_eval_duration + props.eval_duration) / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round((props.prompt_eval_count + props.eval_count) * 10 ** 9 / (props.prompt_eval_duration + props.eval_duration))}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
{
props.tools !== undefined && props.tools.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Tools queried
</Box>
</AccordionSummary>
<AccordionDetails>
{props.tools.map((tool: any, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 0.5 }}>
<div style={{ display: "flex", paddingRight: "1rem", whiteSpace: "nowrap" }}>
{tool.tool}
</div>
<div style={{
display: "flex",
padding: "3px",
whiteSpace: "pre-wrap",
flexGrow: 1,
border: "1px solid #E0E0E0",
wordBreak: "break-all",
maxHeight: "5rem",
overflow: "auto"
}}>
{JSON.stringify(tool.result, null, 2)}
</div>
</Box>
</Box>)}
</AccordionDetails>
</Accordion>
}
{
props?.rag?.name !== undefined && <>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Top RAG {props.rag.ids.length} matches from '{props.rag.name}' collection against embedding vector of {props.rag.query_embedding.length} dimensions
</Box>
</AccordionSummary>
<AccordionDetails>
{props.rag.ids.map((id: number, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "row", mb: 0.5, mt: 0.5 }}>
<div style={{ display: "flex", flexDirection: "column", paddingRight: "1rem", minWidth: "10rem" }}>
<div style={{ whiteSpace: "nowrap" }}>Doc ID: {props.rag.ids[index].slice(-10)}</div>
<div style={{ whiteSpace: "nowrap" }}>Similarity: {Math.round(props.rag.distances[index] * 100) / 100}</div>
<div style={{ whiteSpace: "nowrap" }}>Type: {props.rag.metadatas[index].doc_type}</div>
<div style={{ whiteSpace: "nowrap" }}>Chunk Len: {props.rag.documents[index].length}</div>
</div>
<div style={{ display: "flex", padding: "3px", flexGrow: 1, border: "1px solid #E0E0E0", maxHeight: "5rem", overflow: "auto" }}>{props.rag.documents[index]}</div>
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
UMAP Vector Visualization of RAG
</Box>
</AccordionSummary>
<AccordionDetails>
<VectorVisualizer inline {...props} rag={props?.rag} />
</AccordionDetails>
</Accordion>
</>
}
</>
);
};
const ChatQuery = ({ text, submitQuery }: ChatQueryInterface) => { const ChatQuery = ({ text, submitQuery }: ChatQueryInterface) => {
return (submitQuery if (submitQuery === undefined) {
? <Button variant="outlined" sx={{ return (<Box>{text}</Box>);
}
return (
<Button variant="outlined" sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017) color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight, borderColor: theme => theme.palette.custom.highlight,
m: 1 m: 1
}} }}
size="small" onClick={(e: any) => { console.log(text); submitQuery(text); }}>{text}</Button> size="small" onClick={(e: any) => { console.log(text); submitQuery(text); }}>
: <Box>{text}</Box>); {text}
</Button>
);
} }
const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => { const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, connectionBase }: MessageProps) => {
const [expanded, setExpanded] = useState<boolean>(false); const [expanded, setExpanded] = useState<boolean>(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const textFieldRef = useRef(null); const textFieldRef = useRef(null);
@ -72,15 +233,15 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
const formattedContent = message.content.trim(); const formattedContent = message.content.trim();
return ( return (
<ChatBubble className="Message" isFullWidth={isFullWidth} role={message.role} sx={{ flexGrow: 1, pb: message.metadata ? 0 : "8px", m: 0, mb: 1, mt: 1, overflowX: "auto" }}> <ChatBubble className="Message" isFullWidth={isFullWidth} role={message.role} sx={{ pb: message.metadata ? 0 : "8px", m: 0, mb: 1, mt: 1, overflowX: "auto" }}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto" }}> <CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto" }}>
<Tooltip title="Copy to clipboard" placement="top" arrow> <Tooltip title="Copy to clipboard" placement="top" arrow>
<IconButton <IconButton
onClick={handleCopy} onClick={handleCopy}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 8, top: 0,
right: 8, right: 0,
width: 24, width: 24,
height: 24, height: 24,
bgcolor: 'background.paper', bgcolor: 'background.paper',
@ -97,7 +258,6 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
<StyledMarkdown <StyledMarkdown
className="MessageContent" className="MessageContent"
sx={{ display: "flex", color: 'text.secondary' }} sx={{ display: "flex", color: 'text.secondary' }}
{...{ content: formattedContent, submitQuery }} /> {...{ content: formattedContent, submitQuery }} />
: :
<Typography <Typography
@ -110,8 +270,8 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
} }
</CardContent> </CardContent>
{message.metadata && <> {message.metadata && <>
<CardActions disableSpacing> <CardActions disableSpacing sx={{ justifySelf: "flex-end" }}>
<Typography sx={{ color: "darkgrey", p: 1, textAlign: "end", flexGrow: 1 }}>LLM information for this query</Typography> <Button variant="text" onClick={handleExpandClick} sx={{ color: "darkgrey", p: 1, flexGrow: 0 }}>LLM information for this query</Button>
<ExpandMore <ExpandMore
expand={expanded} expand={expanded}
onClick={handleExpandClick} onClick={handleExpandClick}
@ -123,7 +283,7 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
</CardActions> </CardActions>
<Collapse in={expanded} timeout="auto" unmountOnExit> <Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent> <CardContent>
<MessageMeta metadata={message.metadata} /> <MessageMeta {...{ ...message.metadata, sessionId, connectionBase, setSnack }} />
</CardContent> </CardContent>
</Collapse> </Collapse>
</>} </>}
@ -132,12 +292,17 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
}; };
export type { export type {
MessageInterface, MessageProps,
MessageList, MessageList,
ChatQueryInterface,
MessageMetaProps,
MessageData,
MessageRoles
}; };
export { export {
Message, Message,
ChatQuery, ChatQuery,
MessageMeta
}; };

View File

@ -1,138 +0,0 @@
//import React, { useState, useEffect, useRef, useCallback, ReactElement } from 'react';
import Divider from '@mui/material/Divider';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Box from '@mui/material/Box';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import Card from '@mui/material/Card';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
type MessageMetadata = {
rag: any,
tools: any[],
eval_count: number,
eval_duration: number,
prompt_eval_count: number,
prompt_eval_duration: number
};
type MessageRoles = 'info' | 'user' | 'assistant' | 'system';
type MessageData = {
role: MessageRoles,
content: string,
user?: string,
type?: string,
id?: string,
isProcessing?: boolean,
metadata?: MessageMetadata
};
interface MessageMetaInterface {
metadata: MessageMetadata
}
const MessageMeta = ({ metadata }: MessageMetaInterface) => {
if (metadata === undefined) {
return <></>
}
return (<>
<Box sx={{ fontSize: "0.8rem", mb: 1 }}>
Below is the LLM performance of this query. Note that if tools are called, the entire context is processed for each separate tool request by the LLM. This can dramatically increase the total time for a response.
</Box>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right" >Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Prompt</TableCell>
<TableCell align="right">{metadata.prompt_eval_count}</TableCell>
<TableCell align="right">{Math.round(metadata.prompt_eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(metadata.prompt_eval_count * 10 ** 9 / metadata.prompt_eval_duration)}</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Response</TableCell>
<TableCell align="right">{metadata.eval_count}</TableCell>
<TableCell align="right">{Math.round(metadata.eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(metadata.eval_count * 10 ** 9 / metadata.eval_duration)}</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Total</TableCell>
<TableCell align="right">{metadata.prompt_eval_count + metadata.eval_count}</TableCell>
<TableCell align="right">{Math.round((metadata.prompt_eval_duration + metadata.eval_duration) / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round((metadata.prompt_eval_count + metadata.eval_count) * 10 ** 9 / (metadata.prompt_eval_duration + metadata.eval_duration))}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
{
metadata.tools !== undefined && metadata.tools.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Tools queried
</Box>
</AccordionSummary>
<AccordionDetails>
{metadata.tools.map((tool: any, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 0.5 }}>
<div style={{ display: "flex", paddingRight: "1rem", whiteSpace: "nowrap" }}>
{tool.tool}
</div>
<div style={{ display: "flex", padding: "3px", whiteSpace: "pre-wrap", flexGrow: 1, border: "1px solid #E0E0E0", wordBreak: "break-all", maxHeight: "5rem", overflow: "auto" }}>{JSON.stringify(tool.result, null, 2)}</div>
</Box>
</Box>)}
</AccordionDetails>
</Accordion>
}
{
metadata?.rag?.name !== undefined &&
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Top RAG {metadata.rag.ids.length} matches from '{metadata.rag.name}' collection against embedding vector of {metadata.rag.query_embedding.length} dimensions
</Box>
</AccordionSummary>
<AccordionDetails>
{metadata.rag.ids.map((id: number, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "row", mb: 0.5, mt: 0.5 }}>
<div style={{ display: "flex", flexDirection: "column", paddingRight: "1rem", minWidth: "10rem" }}>
<div style={{ whiteSpace: "nowrap" }}>Doc ID: {metadata.rag.ids[index].slice(-10)}</div>
<div style={{ whiteSpace: "nowrap" }}>Similarity: {Math.round(metadata.rag.distances[index] * 100) / 100}</div>
<div style={{ whiteSpace: "nowrap" }}>Type: {metadata.rag.metadatas[index].doc_type}</div>
<div style={{ whiteSpace: "nowrap" }}>Chunk Len: {metadata.rag.documents[index].length}</div>
</div>
<div style={{ display: "flex", padding: "3px", flexGrow: 1, border: "1px solid #E0E0E0", maxHeight: "5rem", overflow: "auto" }}>{metadata.rag.documents[index]}</div>
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
}
</>
);
};
export type {
MessageMetadata,
MessageMetaInterface,
MessageData,
MessageRoles,
};
export { MessageMeta };

View File

@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { SeverityType } from './Snack'; import { SeverityType } from './Snack';
import { ContextStatus } from './ContextStatus'; import { ContextStatus } from './ContextStatus';
import { MessageData, MessageMetadata } from './MessageMeta'; import { MessageData, MessageMetaProps } from './Message';
import { DocumentViewer } from './DocumentViewer'; import { DocumentViewer } from './DocumentViewer';
interface ResumeBuilderProps { interface ResumeBuilderProps {
@ -21,7 +21,7 @@ type Resume = {
resume: MessageData | undefined, resume: MessageData | undefined,
fact_check: MessageData | undefined, fact_check: MessageData | undefined,
job_description: string, job_description: string,
metadata: MessageMetadata metadata: MessageMetaProps
}; };
const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => { const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
@ -350,7 +350,7 @@ const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, proc
overflowY: "auto", overflowY: "auto",
flexDirection: "column", flexDirection: "column",
height: "calc(0vh - 0px)", // Hack to make the height work height: "calc(0vh - 0px)", // Hack to make the height work
}} {...{ factCheck, facts, jobDescription, generateResume, resume, setFacts, setResume, setJobDescription }} /> }} {...{ factCheck, facts, jobDescription, generateResume, resume, setFacts, setResume, setSnack, setJobDescription, connectionBase, sessionId }} />
</Box> </Box>
</Box> </Box>
); );

View File

@ -7,6 +7,8 @@ import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { SeverityType } from './Snack'; import { SeverityType } from './Snack';
@ -19,6 +21,8 @@ interface ResultData {
embeddings: number[][] | number[][][]; embeddings: number[][] | number[][][];
documents: string[]; documents: string[];
metadatas: Metadata[]; metadatas: Metadata[];
ids: string[];
dimensions: number;
} }
interface PlotData { interface PlotData {
@ -39,6 +43,8 @@ interface VectorVisualizerProps {
connectionBase: string; connectionBase: string;
sessionId?: string; sessionId?: string;
setSnack: (message: string, severity: SeverityType) => void; setSnack: (message: string, severity: SeverityType) => void;
inline?: boolean;
rag?: any;
} }
interface ChromaResult { interface ChromaResult {
@ -48,7 +54,8 @@ interface ChromaResult {
metadatas: Metadata[]; metadatas: Metadata[];
query_embedding: number[]; query_embedding: number[];
query?: string; query?: string;
vector_embedding?: number[]; umap_embedding_2d?: number[];
umap_embedding_3d?: number[];
} }
const normalizeDimension = (arr: number[]): number[] => { const normalizeDimension = (arr: number[]): number[] => {
@ -89,11 +96,12 @@ const symbolMap: Record<string, string> = {
'query': 'circle', 'query': 'circle',
}; };
const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectionBase, sessionId }) => { const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inline, connectionBase, sessionId }) => {
const [plotData, setPlotData] = useState<PlotData | null>(null); const [plotData, setPlotData] = useState<PlotData | null>(null);
const [query, setQuery] = useState<string>(''); const [newQuery, setNewQuery] = useState<string>('');
const [queryEmbedding, setQueryEmbedding] = useState<ChromaResult | undefined>(undefined); const [newQueryEmbedding, setNewQueryEmbedding] = useState<ChromaResult | undefined>(undefined);
const [result, setResult] = useState<ResultData | undefined>(undefined); const [result, setResult] = useState<ResultData | undefined>(undefined);
const [view2D, setView2D] = useState<boolean>(true);
const [tooltip, setTooltip] = useState<{ const [tooltip, setTooltip] = useState<{
visible: boolean, visible: boolean,
// x: number, // x: number,
@ -105,7 +113,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
// Get the collection to visualize // Get the collection to visualize
useEffect(() => { useEffect(() => {
if (result !== undefined || sessionId === undefined) { if ((result !== undefined && result.dimensions !== (view2D ? 3 : 2)) || sessionId === undefined) {
return; return;
} }
const fetchCollection = async () => { const fetchCollection = async () => {
@ -115,9 +123,10 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ dimensions: 3 }), body: JSON.stringify({ dimensions: view2D ? 2 : 3 }),
}); });
const data = await response.json(); const data: ResultData = await response.json();
data.dimensions = view2D ? 2 : 3;
setResult(data); setResult(data);
} catch (error) { } catch (error) {
console.error('Error obtaining collection information:', error); console.error('Error obtaining collection information:', error);
@ -126,7 +135,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
}; };
fetchCollection(); fetchCollection();
}, [result, setResult, connectionBase, setSnack, sessionId]) }, [result, setResult, connectionBase, setSnack, sessionId, view2D])
useEffect(() => { useEffect(() => {
if (!result || !result.embeddings) return; if (!result || !result.embeddings) return;
@ -135,12 +144,31 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
const vectors: number[][] = [...result.embeddings as number[][]]; const vectors: number[][] = [...result.embeddings as number[][]];
const documents = [...result.documents || []]; const documents = [...result.documents || []];
const metadatas = [...result.metadatas || []]; const metadatas = [...result.metadatas || []];
const ids = [...result.ids || []];
if (queryEmbedding !== undefined && queryEmbedding.vector_embedding !== undefined) { if (view2D && rag && rag.umap_embedding_2d) {
metadatas.unshift({ doc_type: 'query' }); metadatas.unshift({ doc_type: 'query' });
documents.unshift(queryEmbedding.query || ''); documents.unshift('Query');
vectors.unshift(queryEmbedding.vector_embedding); vectors.unshift(rag.umap_embedding_2d);
} }
if (!view2D && rag && rag.umap_embedding_3d) {
metadatas.unshift({ doc_type: 'query' });
documents.unshift('Query');
vectors.unshift(rag.umap_embedding_3d);
}
if (newQueryEmbedding !== undefined) {
metadatas.unshift({ doc_type: 'query' });
documents.unshift(newQueryEmbedding.query || '');
if (view2D && newQueryEmbedding.umap_embedding_2d) {
vectors.unshift(newQueryEmbedding.umap_embedding_2d);
}
if (!view2D && newQueryEmbedding.umap_embedding_3d) {
vectors.unshift(newQueryEmbedding.umap_embedding_3d);
}
}
const is2D = vectors.every((v: number[]) => v.length === 2); const is2D = vectors.every((v: number[]) => v.length === 2);
const is3D = vectors.every((v: number[]) => v.length === 3); const is3D = vectors.every((v: number[]) => v.length === 3);
if (!is2D && !is3D) { if (!is2D && !is3D) {
@ -148,11 +176,19 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
return; return;
} }
const doc_types = metadatas.map(m => m.doc_type || 'unknown'); const doc_types = metadatas.map(m => m.doc_type || 'unknown')
const sizes = doc_types.map(type => {
const sizes = doc_types.map((type, index) => {
if (!sizeMap[type]) { if (!sizeMap[type]) {
sizeMap[type] = 5; sizeMap[type] = 5;
} }
/* If this is a match, increase the size */
if (rag && rag.ids.includes(ids[index])) {
return sizeMap[type] + 5;
}
if (newQueryEmbedding && newQueryEmbedding.ids && newQueryEmbedding.ids.includes(ids[index])) {
return sizeMap[type] + 5;
}
return sizeMap[type]; return sizeMap[type];
}); });
const symbols = doc_types.map(type => { const symbols = doc_types.map(type => {
@ -189,7 +225,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
}, },
xaxis: { title: 'X', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' }, xaxis: { title: 'X', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
yaxis: { title: 'Y', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' }, yaxis: { title: 'Y', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
margin: { r: 20, b: 10, l: 10, t: 40 }, margin: { r: 0, b: 0, l: 0, t: 0 },
}; };
const data: any = { const data: any = {
@ -212,17 +248,23 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
setPlotData({ data, layout }); setPlotData({ data, layout });
}, [result, queryEmbedding]); }, [result, newQueryEmbedding, rag, view2D, setPlotData, setSnack]);
if (setSnack === undefined) {
console.error('setSnack function is undefined');
return null;
}
const handleKeyPress = (event: any) => { const handleKeyPress = (event: any) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
sendQuery(query); sendQuery(newQuery);
} }
}; };
const sendQuery = async (query: string) => { const sendQuery = async (query: string) => {
if (!query.trim()) return; if (!query.trim()) return;
setQuery(''); setNewQuery('');
try { try {
const response = await fetch(`${connectionBase}/api/similarity/${sessionId}`, { const response = await fetch(`${connectionBase}/api/similarity/${sessionId}`, {
method: 'PUT', method: 'PUT',
@ -231,11 +273,11 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
}, },
body: JSON.stringify({ body: JSON.stringify({
query: query, query: query,
dimensions: view2D ? 2 : 3,
}) })
}); });
const chroma: ChromaResult = await response.json(); const chroma: ChromaResult = await response.json();
console.log('Chroma:', chroma); setNewQueryEmbedding(chroma);
setQueryEmbedding(chroma);
} catch (error) { } catch (error) {
console.error('Error obtaining query similarity information:', error); console.error('Error obtaining query similarity information:', error);
setSnack("Unable to obtain query similarity information.", "error"); setSnack("Unable to obtain query similarity information.", "error");
@ -249,13 +291,16 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
); );
return ( return (
<> <Box className="VectorVisualizer" sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
{
!inline &&
<Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mb: 1, pt: 0 }}> <Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mb: 1, pt: 0 }}>
<Typography variant="h6" sx={{ p: 1, pt: 0 }}> <Typography variant="h6" sx={{ p: 1, pt: 0 }}>
Similarity Visualization via Uniform Manifold Approximation and Projection (UMAP) Similarity Visualization via Uniform Manifold Approximation and Projection (UMAP)
</Typography> </Typography>
</Card> </Card>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}> }
<FormControlLabel sx={{ display: "inline-flex", width: "fit-content", mb: '-2.5rem', zIndex: 100, ml: 1, flexBasis: 0, flexGrow: 0 }} control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
<Plot <Plot
onClick={(event: any) => { onClick={(event: any) => {
const point = event.points[0]; const point = event.points[0];
@ -275,15 +320,15 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
useResizeHandler={true} useResizeHandler={true}
config={{ config={{
responsive: true, responsive: true,
displayModeBar: false, // displayModeBar: false,
displaylogo: false, displaylogo: false,
showSendToCloud: false, showSendToCloud: false,
staticPlot: false, staticPlot: false,
}} }}
style={{ width: '100%', height: '100%' }} style={{ display: "flex", flexGrow: 1, justifyContent: 'center', alignItems: 'center', minHeight: '30vh', height: '30vh', padding: 0, margin: 0 }}
layout={plotData.layout} layout={plotData.layout}
/> />
</Box> {!inline &&
<Card sx={{ <Card sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -305,30 +350,34 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
{tooltip?.content} {tooltip?.content}
</Typography> </Typography>
</Card> </Card>
{ queryEmbedding !== undefined && }
{!inline && newQueryEmbedding !== undefined &&
<Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mt: 1, pb: 0 }}> <Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mt: 1, pb: 0 }}>
<Typography variant="h6" sx={{ p: 1, pt: 0, maxHeight: '5rem', overflow: 'auto' }}> <Typography variant="h6" sx={{ p: 1, pt: 0, maxHeight: '5rem', overflow: 'auto' }}>
Query: {queryEmbedding.query} Query: {newQueryEmbedding.query}
</Typography> </Typography>
</Card> </Card>
} }
{
!inline &&
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}> <Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField <TextField
variant="outlined" variant="outlined"
fullWidth fullWidth
type="text" type="text"
value={query} value={newQuery}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setNewQuery(e.target.value)}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..." placeholder="Enter query to find related documents..."
id="QueryInput" id="QueryInput"
/> />
<Tooltip title="Send"> <Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(query); }}><SendIcon /></Button> <Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(newQuery); }}><SendIcon /></Button>
</Tooltip> </Tooltip>
</Box> </Box>
</> }
</Box>
); );
}; };

View File

@ -515,12 +515,16 @@ class WebServer:
dimensions = 2 dimensions = 2
try: try:
result = self.file_watcher.collection.get(include=["embeddings", "documents", "metadatas"]) result = self.file_watcher.umap_collection
vectors = np.array(result["embeddings"]) if dimensions == 2:
umap_model = umap.UMAP(n_components=dimensions, random_state=42) #, n_neighbors=15, min_dist=0.1) logging.info("Returning 2D UMAP")
embedding = umap_model.fit_transform(vectors) umap_embedding = self.file_watcher.umap_embedding_2d
context["umap_model"] = umap_model else:
result["embeddings"] = embedding.tolist() logging.info("Returning 3D UMAP")
umap_embedding = self.file_watcher.umap_embedding_3d
result["embeddings"] = umap_embedding.tolist()
return JSONResponse(result) return JSONResponse(result)
except Exception as e: except Exception as e:
@ -536,10 +540,6 @@ class WebServer:
logging.warning(f"Invalid context_id: {context_id}") logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400) return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
if not context.get("umap_model"):
return JSONResponse({"error": "No umap_model found in context"}, status_code=404)
try: try:
data = await request.json() data = await request.json()
query = data.get("query", "") query = data.get("query", "")
@ -552,9 +552,15 @@ class WebServer:
chroma_results = self.file_watcher.find_similar(query=query, top_k=10) chroma_results = self.file_watcher.find_similar(query=query, top_k=10)
if not chroma_results: if not chroma_results:
return JSONResponse({"error": "No results found"}, status_code=404) return JSONResponse({"error": "No results found"}, status_code=404)
chroma_embedding = chroma_results["query_embedding"] chroma_embedding = chroma_results["query_embedding"]
umap_embedding = context["umap_model"].transform([chroma_embedding])[0].tolist()
return JSONResponse({ **chroma_results, "query": query, "vector_embedding": umap_embedding }) return JSONResponse({
**chroma_results,
"query": query,
"umap_embedding_2d": self.file_watcher.umap_model_2d.transform([chroma_embedding])[0].tolist(),
"umap_embedding_3d": self.file_watcher.umap_model_3d.transform([chroma_embedding])[0].tolist()
})
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
@ -839,8 +845,7 @@ class WebServer:
# Serialize the data to JSON and write to file # Serialize the data to JSON and write to file
with open(file_path, "w") as f: with open(file_path, "w") as f:
json.dump(context, f) json.dump(context, f)
if umap_model:
context["umap_model"] = umap_model
return session_id return session_id
def load_context(self, session_id): def load_context(self, session_id):
@ -894,7 +899,6 @@ class WebServer:
logging.warning("No context ID provided. Creating a new context.") logging.warning("No context ID provided. Creating a new context.")
return self.create_context() return self.create_context()
if context_id in self.contexts: if context_id in self.contexts:
logging.info(f"Context {context_id} found.")
return self.contexts[context_id] return self.contexts[context_id]
logging.info(f"Context {context_id} not found. Creating new context.") logging.info(f"Context {context_id} not found. Creating new context.")
return self.load_context(context_id) return self.load_context(context_id)
@ -931,7 +935,13 @@ class WebServer:
chroma_results = self.file_watcher.find_similar(query=content, top_k=10) chroma_results = self.file_watcher.find_similar(query=content, top_k=10)
if chroma_results: if chroma_results:
rag_docs.extend(chroma_results["documents"]) rag_docs.extend(chroma_results["documents"])
metadata["rag"] = { "name": rag["name"], **chroma_results } chroma_embedding = chroma_results["query_embedding"]
metadata["rag"] = {
**chroma_results,
"name": rag["name"],
"umap_embedding_2d": self.file_watcher.umap_model_2d.transform([chroma_embedding])[0].tolist(),
"umap_embedding_3d": self.file_watcher.umap_model_3d.transform([chroma_embedding])[0].tolist()
}
preamble = "" preamble = ""
if len(rag_docs): if len(rag_docs):
preamble = f""" preamble = f"""

View File

@ -19,6 +19,7 @@ from langchain.text_splitter import CharacterTextSplitter
from langchain.schema import Document from langchain.schema import Document
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
import umap
# Import your existing modules # Import your existing modules
if __name__ == "__main__": if __name__ == "__main__":
@ -52,6 +53,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
# Initialize ChromaDB collection # Initialize ChromaDB collection
self._collection = self._get_vector_collection(recreate=recreate) self._collection = self._get_vector_collection(recreate=recreate)
self._update_umaps()
# Setup text splitter # Setup text splitter
self.text_splitter = CharacterTextSplitter( self.text_splitter = CharacterTextSplitter(
@ -68,6 +70,26 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
def collection(self): def collection(self):
return self._collection return self._collection
@property
def umap_collection(self):
return self._umap_collection
@property
def umap_embedding_2d(self):
return self._umap_embedding_2d
@property
def umap_embedding_3d(self):
return self._umap_embedding_3d
@property
def umap_model_2d(self):
return self._umap_model_2d
@property
def umap_model_3d(self):
return self._umap_model_3d
def _save_hash_state(self): def _save_hash_state(self):
"""Save the current file hash state to disk.""" """Save the current file hash state to disk."""
try: try:
@ -185,6 +207,9 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
# Save the hash state after successful update # Save the hash state after successful update
self._save_hash_state() self._save_hash_state()
# Re-fit the UMAP for the new content
self._update_umaps()
except Exception as e: except Exception as e:
logging.error(f"Error processing update for {file_path}: {e}") logging.error(f"Error processing update for {file_path}: {e}")
finally: finally:
@ -212,6 +237,23 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
except Exception as e: except Exception as e:
logging.error(f"Error removing file from collection: {e}") logging.error(f"Error removing file from collection: {e}")
def _update_umaps(self):
# Update the UMAP embeddings
self._umap_collection = self._collection.get(include=["embeddings", "documents", "metadatas"])
if not self._umap_collection or not len(self._umap_collection["embeddings"]):
logging.warning("No embeddings found in the collection.")
return
logging.info(f"Updating 2D UMAP for {len(self._umap_collection['embeddings'])} vectors")
vectors = np.array(self._umap_collection["embeddings"])
self._umap_model_2d = umap.UMAP(n_components=2, random_state=8911, metric="cosine") #, n_neighbors=15, min_dist=0.1)
self._umap_embedding_2d = self._umap_model_2d.fit_transform(vectors)
logging.info(f"Updating 3D UMAP for {len(self._umap_collection['embeddings'])} vectors")
vectors = np.array(self._umap_collection["embeddings"])
self._umap_model_3d = umap.UMAP(n_components=3, random_state=8911, metric="cosine") #, n_neighbors=15, min_dist=0.1)
self._umap_embedding_3d = self._umap_model_3d.fit_transform(vectors)
def _get_vector_collection(self, recreate=False): def _get_vector_collection(self, recreate=False):
"""Get or create a ChromaDB collection.""" """Get or create a ChromaDB collection."""
# Initialize ChromaDB client # Initialize ChromaDB client