From 11447b68aa96a3f3a6a7cb6892a50ef55cd5d838 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Thu, 29 May 2025 14:15:21 -0700 Subject: [PATCH] Chat is working again, just not saving --- frontend/craco.config.js | 1 + frontend/src/components/Conversation.tsx | 79 ++--- frontend/src/components/Message.tsx | 348 ++++++++--------------- frontend/src/pages/ChatPage.tsx | 2 +- frontend/src/pages/GenerateCandidate.tsx | 27 +- frontend/src/pages/LoadingPage.tsx | 1 + frontend/src/pages/ResumeBuilderPage.tsx | 104 ++++--- frontend/src/types/api-client.ts | 156 ++++------ frontend/src/types/types.ts | 40 ++- frontend/tsconfig.json | 7 +- src/backend/agents/base.py | 67 +++-- src/backend/main.py | 67 +++-- src/backend/model_cast.py | 14 + src/backend/models.py | 43 ++- src/kill-server.sh | 41 ++- 15 files changed, 475 insertions(+), 522 deletions(-) create mode 100644 src/backend/model_cast.py diff --git a/frontend/craco.config.js b/frontend/craco.config.js index 5f99851..5fc3f43 100644 --- a/frontend/craco.config.js +++ b/frontend/craco.config.js @@ -44,6 +44,7 @@ module.exports = { }, webpack: { configure: (webpackConfig) => { + webpackConfig.devtool = 'source-map'; // Add .ts and .tsx to resolve.extensions webpackConfig.resolve.extensions = [ ...webpackConfig.resolve.extensions, diff --git a/frontend/src/components/Conversation.tsx b/frontend/src/components/Conversation.tsx index fc9e175..5515705 100644 --- a/frontend/src/components/Conversation.tsx +++ b/frontend/src/components/Conversation.tsx @@ -8,20 +8,20 @@ import CancelIcon from '@mui/icons-material/Cancel'; import { SxProps, Theme } from '@mui/material'; import PropagateLoader from "react-spinners/PropagateLoader"; -import { Message, MessageRoles } from './Message'; +import { Message } from './Message'; import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { BackstoryElementProps } from './BackstoryTab'; import { connectionBase } from 'utils/Global'; import { useUser } from "hooks/useUser"; import { StreamingResponse } from 'types/api-client'; -import { ChatMessage, ChatContext, ChatSession, ChatQuery } from 'types/types'; +import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types'; import { PaginatedResponse } from 'types/conversion'; import './Conversation.css'; const defaultMessage: ChatMessage = { - status: "thinking", sender: "system", sessionId: "", timestamp: new Date(), content: "" + type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: "" }; const loadingMessage: ChatMessage = { ...defaultMessage, content: "Establishing connection with server..." }; @@ -249,6 +249,7 @@ const Conversation = forwardRef((props: C ...conversationRef.current, { ...defaultMessage, + type: 'user', sender: 'user', content: query.prompt, } @@ -259,44 +260,44 @@ const Conversation = forwardRef((props: C ); controllerRef.current = apiClient.sendMessageStream(sessionId, query, { - onComplete: (msg) => { - console.log(msg); - switch (msg.status) { - case "done": - case "partial": - setConversation([ - ...conversationRef.current, { - ...msg, - role: 'assistant', - origin: type, - }] as ChatMessage[]); - if (msg.status === "done") { - setStreamingMessage(undefined); - setProcessingMessage(undefined); - setProcessing(false); - controllerRef.current = null; - } - if (onResponse) { - onResponse(msg); - } - break; - case "error": - // Show error - setConversation([ - ...conversationRef.current, - msg - ]); - setProcessingMessage(msg); - setProcessing(false); - controllerRef.current = null; - break; - default: - setProcessingMessage(msg); - break; + onMessage: (msg) => { + console.log("onMessage:", msg); + if (msg.type === "response") { + setConversation([ + ...conversationRef.current, + msg + ]); + setStreamingMessage(undefined); + setProcessingMessage(undefined); + setProcessing(false); + } else { + setProcessingMessage(msg); + } + if (onResponse) { + onResponse(msg); } }, - onPartialMessage: (chunk) => { - setStreamingMessage({ ...defaultMessage, status: "streaming", content: chunk }); + onError: (error: string | ChatMessageBase) => { + console.log("onError:", error); + // Type-guard to determine if this is a ChatMessageBase or a string + if (typeof error === "object" && error !== null && "content" in error) { + setProcessingMessage(error as ChatMessage); + setProcessing(false); + controllerRef.current = null; + } else { + setProcessingMessage({ ...defaultMessage, content: error as string }); + } + }, + onStreaming: (chunk) => { + console.log("onStreaming:", chunk); + setStreamingMessage({ ...defaultMessage, ...chunk }); + }, + onStatusChange: (status) => { + console.log("onStatusChange:", status); + }, + onComplete: () => { + console.log("onComplete"); + controllerRef.current = null; } }); }; diff --git a/frontend/src/components/Message.tsx b/frontend/src/components/Message.tsx index eac508f..9d34a9f 100644 --- a/frontend/src/components/Message.tsx +++ b/frontend/src/components/Message.tsx @@ -32,68 +32,9 @@ import { SetSnackType } from './Snack'; import { CopyBubble } from './CopyBubble'; import { Scrollable } from './Scrollable'; import { BackstoryElementProps } from './BackstoryTab'; -import { ChatMessage, ChatSession } from 'types/types'; - -type MessageRoles = - 'assistant' | - 'content' | - 'error' | - 'fact-check' | - 'info' | - 'job-description' | - 'job-requirements' | - 'processing' | - 'qualifications' | - 'resume' | - 'status' | - 'streaming' | - 'system' | - 'thinking' | - 'user'; - -type BackstoryMessage = { - // Only two required fields - role: MessageRoles, - content: string, - // Rest are optional - prompt?: string; - preamble?: {}; - status?: string; - remaining_time?: number; - full_content?: string; - response?: string; // Set when status === 'done', 'partial', or 'error' - chunk?: string; // Used when status === 'streaming' - timestamp?: number; - disableCopy?: boolean, - user?: string, - title?: string, - origin?: string, - display?: string, /* Messages generated on the server for filler should not be shown */ - id?: string, - isProcessing?: boolean, - actions?: string[], - metadata?: MessageMetaData, - expanded?: boolean, - expandable?: boolean, -}; - -interface ChatBubbleProps { - role: MessageRoles, - isInfo?: boolean; - children: React.ReactNode; - sx?: SxProps; - className?: string; - title?: string; - expanded?: boolean; - expandable?: boolean; - onExpand?: (open: boolean) => void; -} - -function ChatBubble(props: ChatBubbleProps) { - const { role, children, sx, className, title, onExpand, expandable, expanded } = props; - - const theme = useTheme(); +import { ChatMessage, ChatSession, ChatMessageType } from 'types/types'; +const getStyle = (theme: Theme, type: ChatMessageType): any => { const defaultRadius = '16px'; const defaultStyle = { padding: theme.spacing(1, 2), @@ -115,7 +56,7 @@ function ChatBubble(props: ChatBubbleProps) { }; const styles: any = { - assistant: { + response: { ...defaultStyle, backgroundColor: theme.palette.primary.main, border: `1px solid ${theme.palette.secondary.main}`, @@ -184,7 +125,7 @@ function ChatBubble(props: ChatBubbleProps) { opacity: 0.9, transition: 'opacity 0.3s ease-in-out', }, - streaming: 'assistant', + streaming: 'response', system: { ...defaultStyle, backgroundColor: '#EDEAE0', @@ -214,102 +155,32 @@ function ChatBubble(props: ChatBubbleProps) { } } + return styles[type]; +} + +const getIcon = (messageType: string): React.ReactNode | null => { const icons: any = { error: , + generating: , info: , + preparing: , processing: , - searching: , + system: , thinking: , tooling: , }; - - // Render Accordion for expandable content - if (expandable || title) { - // Determine if Accordion is controlled - const isControlled = typeof expanded === 'boolean' && typeof onExpand === 'function'; - return ( - { - if (isControlled && onExpand) { - onExpand(newExpanded); // Call onExpand with new state - } - }} - sx={{ ...styles[role], ...sx }} - > - } - slotProps={{ - content: { - sx: { - fontWeight: 'bold', - fontSize: '1.1rem', - m: 0, - p: 0, - display: 'flex', - justifyItems: 'center', - }, - }, - }} - > - {title || ''} - - - {children} - - - ); - } - - // Render non-expandable content - return ( - - {icons[role] !== undefined && icons[role]} - - {children} - - - ); + return icons[messageType] || null; } -interface MessageMetaData { - query?: { - query_embedding: number[]; - vector_embedding: number[]; - }, - origin: string, - rag: any[], - tools?: { - tool_calls: any[], - }, - eval_count: number, - eval_duration: number, - prompt_eval_count: number, - prompt_eval_duration: number, - connectionBase: string, - setSnack: SetSnackType, -} - -type MessageList = BackstoryMessage[]; - interface MessageProps extends BackstoryElementProps { - sx?: SxProps, message: ChatMessage, + title?: string, + chatSession?: ChatSession, + className?: string, + sx?: SxProps, + expandable?: boolean, expanded?: boolean, onExpand?: (open: boolean) => void, - className?: string, - chatSession?: ChatSession, }; interface MessageMetaProps { @@ -317,7 +188,6 @@ interface MessageMetaProps { messageProps: MessageProps }; - const MessageMeta = (props: MessageMetaProps) => { const { /* MessageData */ @@ -447,102 +317,124 @@ const MessageMeta = (props: MessageMetaProps) => { ); }; +interface MessageContainerProps { + type: ChatMessageType, + metadataView?: React.ReactNode | null, + messageView?: React.ReactNode | null, + sx?: SxProps, + copyContent?: string, +}; + +const MessageContainer = (props: MessageContainerProps) => { + const { type, sx, messageView, metadataView, copyContent } = props; + const icon = getIcon(type); + + return + + {icon !== null && icon} + {messageView} + + {metadataView} + {copyContent && } + ; +}; + const Message = (props: MessageProps) => { - const { message, submitQuery, sx, className, chatSession, onExpand, setSnack, expanded } = props; + const { message, title, submitQuery, sx, className, chatSession, onExpand, setSnack, expanded, expandable } = props; const [metaExpanded, setMetaExpanded] = useState(false); - const textFieldRef = useRef(null); const backstoryProps = { submitQuery, setSnack }; + const theme = useTheme(); + const style: any = getStyle(theme, message.type); const handleMetaExpandClick = () => { setMetaExpanded(!metaExpanded); }; - if (message === undefined) { - return (<>); - } + const content = message.content?.trim(); + if (!content) { + return (<>) + }; - if (message.content === undefined) { - console.info("Message content is undefined"); - return (<>); - } - - const formattedContent = message.content.trim(); - if (formattedContent === "") { - return (<>); - } - - return ( - - - - - - - - {/*(message.disableCopy === undefined || message.disableCopy === false) &&*/ } - {message.metadata && ( - - - - - - - )} - - {message.metadata && <> - - - - - - } - + const messageView = ( + ); -}; + + let metadataView = (<>); + if (message.metadata) { + metadataView = (<> + + + + + + + + + + + + ); + } + + if (!expandable) { + /* When not expandable, the styles are applied directly to MessageContainer */ + return (<> + {messageView && } + ); + } + + // Determine if Accordion is controlled + const isControlled = typeof expanded === 'boolean' && typeof onExpand === 'function'; + return ( + { isControlled && onExpand && onExpand(newExpanded) }} + sx={{ ...sx, ...style }}> + } + slotProps={{ + content: { + sx: { + display: 'flex', + justifyItems: 'center', + m: 0, p: 0, + fontWeight: 'bold', + fontSize: '1.1rem', + }, + }, + }}> + {title || ''} + + + + + + ); +} export type { - MessageProps, - MessageList, - BackstoryMessage, - MessageMetaData, - MessageRoles, + MessageProps, }; export { diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index ba0c8e3..fb037df 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -17,7 +17,7 @@ const ChatPage = forwardRef((props: Back const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [questions, setQuestions] = useState([]); - console.log("ChatPage candidate =>", candidate); + // console.log("ChatPage candidate =>", candidate); useEffect(() => { if (!candidate) { return; diff --git a/frontend/src/pages/GenerateCandidate.tsx b/frontend/src/pages/GenerateCandidate.tsx index 4718e73..d5f7029 100644 --- a/frontend/src/pages/GenerateCandidate.tsx +++ b/frontend/src/pages/GenerateCandidate.tsx @@ -19,7 +19,7 @@ import { StyledMarkdown } from 'components/StyledMarkdown'; import { Scrollable } from '../components/Scrollable'; import { Pulse } from 'components/Pulse'; import { StreamingResponse } from 'types/api-client'; -import { ChatContext, ChatSession, ChatQuery } from 'types/types'; +import { ChatContext, ChatMessage, ChatMessageBase, ChatSession, ChatQuery } from 'types/types'; import { useUser } from 'hooks/useUser'; const emptyUser: Candidate = { @@ -102,21 +102,30 @@ const GenerateCandidate = (props: BackstoryElementProps) => { setShouldGenerateProfile(false); // Reset the flag const streamResponse = apiClient.sendMessageStream(sessionId, query, { - onPartialMessage: (content, messageId) => { - console.log('Partial content:', content); + onMessage: (chatMessage: ChatMessage) => { + console.log('Message:', chatMessage); // Update UI with partial content }, onStatusChange: (status) => { console.log('Status changed:', status); // Update UI status indicator }, - onComplete: (finalMessage) => { - console.log('Final message:', finalMessage.content); - // Handle completed message + onComplete: () => { + console.log('Content complete'); }, - onError: (error) => { - console.error('Streaming error:', error); - // Handle error + onWarn: (warning) => { + console.log("Warning:", warning); + }, + onError: (error: string | ChatMessageBase) => { + // Type-guard to determine if this is a ChatMessageBase or a string + if (typeof error === "object" && error !== null && "content" in error) { + console.log("Error message:", error); + } else { + console.log("Error string:", error); + } + }, + onStreaming: (chunk) => { + console.log("Streaming: ", chunk); } }); // controllerRef.current = streamQueryResponse({ diff --git a/frontend/src/pages/LoadingPage.tsx b/frontend/src/pages/LoadingPage.tsx index 1658ca2..a6cb580 100644 --- a/frontend/src/pages/LoadingPage.tsx +++ b/frontend/src/pages/LoadingPage.tsx @@ -6,6 +6,7 @@ import { ChatMessage } from 'types/types'; const LoadingPage = (props: BackstoryPageProps) => { const preamble: ChatMessage = { sender: 'system', + type: 'preparing', status: 'done', sessionId: '', content: 'Please wait while connecting to Backstory...', diff --git a/frontend/src/pages/ResumeBuilderPage.tsx b/frontend/src/pages/ResumeBuilderPage.tsx index b9e250c..fee172e 100644 --- a/frontend/src/pages/ResumeBuilderPage.tsx +++ b/frontend/src/pages/ResumeBuilderPage.tsx @@ -7,11 +7,9 @@ import { import { SxProps } from '@mui/material'; import { BackstoryQuery } from 'components/BackstoryQuery'; -import { MessageList, BackstoryMessage } from 'components/Message'; import { Conversation } from 'components/Conversation'; import { BackstoryPageProps } from 'components/BackstoryTab'; -import { ChatQuery } from "types/types"; - +import { ChatQuery, ChatMessage } from "types/types"; import './ResumeBuilderPage.css'; /** @@ -58,19 +56,19 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro factsConversationRef.current?.submitQuery(query); }; - const filterJobDescriptionMessages = useCallback((messages: MessageList): MessageList => { + const filterJobDescriptionMessages = useCallback((messages: ChatMessage[]): ChatMessage[] => { if (messages === undefined || messages.length === 0) { return []; } if (messages.length > 0) { - messages[0].role = 'content'; - messages[0].title = 'Job Description'; - messages[0].disableCopy = false; - messages[0].expandable = true; + // messages[0].role = 'content'; + // messages[0].title = 'Job Description'; + // messages[0].disableCopy = false; + // messages[0].expandable = true; } - if (-1 !== messages.findIndex(m => m.status === 'done' || (m.actions && m.actions.includes("resume_generated")))) { + if (-1 !== messages.findIndex(m => m.status === 'done')) { // || (m.actions && m.actions.includes("resume_generated")))) { setHasResume(true); setHasFacts(true); } @@ -85,11 +83,11 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro 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; + // 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) */ @@ -99,7 +97,7 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro return filtered; }, [setHasResume, setHasFacts]); - const filterResumeMessages = useCallback((messages: MessageList): MessageList => { + const filterResumeMessages = useCallback((messages: ChatMessage[]): ChatMessage[] => { if (messages === undefined || messages.length === 0) { return []; } @@ -108,20 +106,20 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro 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; + // 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; + // 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) */ @@ -130,18 +128,18 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro return filtered; }, []); - const filterFactsMessages = useCallback((messages: MessageList): MessageList => { + const filterFactsMessages = useCallback((messages: ChatMessage[]): ChatMessage[] => { 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; + // 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) */ @@ -150,33 +148,33 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro return filtered; }, []); - const jobResponse = useCallback(async (message: BackstoryMessage) => { - if (message.actions && message.actions.includes("job_description")) { - if (jobConversationRef.current) { - await jobConversationRef.current.fetchHistory(); - } - } - if (message.actions && message.actions.includes("resume_generated")) { - if (resumeConversationRef.current) { - await resumeConversationRef.current.fetchHistory(); - } - setHasResume(true); - setActiveTab(1); // Switch to Resume tab - } - if (message.actions && message.actions.includes("facts_checked")) { - if (factsConversationRef.current) { - await factsConversationRef.current.fetchHistory(); - } - setHasFacts(true); - } + const jobResponse = useCallback(async (message: ChatMessage) => { + // if (message.actions && message.actions.includes("job_description")) { + // if (jobConversationRef.current) { + // await jobConversationRef.current.fetchHistory(); + // } + // } + // if (message.actions && message.actions.includes("resume_generated")) { + // if (resumeConversationRef.current) { + // await resumeConversationRef.current.fetchHistory(); + // } + // setHasResume(true); + // setActiveTab(1); // Switch to Resume tab + // } + // if (message.actions && message.actions.includes("facts_checked")) { + // if (factsConversationRef.current) { + // await factsConversationRef.current.fetchHistory(); + // } + // setHasFacts(true); + // } }, [setHasFacts, setHasResume, setActiveTab]); - const resumeResponse = useCallback((message: BackstoryMessage): void => { + const resumeResponse = useCallback((message: ChatMessage): void => { console.log('onResumeResponse', message); setHasFacts(true); }, [setHasFacts]); - const factsResponse = useCallback((message: BackstoryMessage): void => { + const factsResponse = useCallback((message: ChatMessage): void => { console.log('onFactsResponse', message); }, []); @@ -207,7 +205,7 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro // , // ]; -// const jobDescriptionPreamble: MessageList = [{ + // const jobDescriptionPreamble: ChatMessage[] = [{ // role: 'info', // content: `Once you paste a job description and press **Generate Resume**, Backstory will perform the following actions: diff --git a/frontend/src/types/api-client.ts b/frontend/src/types/api-client.ts index 38618a8..4aff6a9 100644 --- a/frontend/src/types/api-client.ts +++ b/frontend/src/types/api-client.ts @@ -26,29 +26,19 @@ import { // ============================ interface StreamingOptions { - onMessage?: (message: Types.ChatMessage) => void; - onPartialMessage?: (partialContent: string, messageId?: string) => void; - onComplete?: (finalMessage: Types.ChatMessage) => void; - onError?: (error: Error) => void; onStatusChange?: (status: Types.ChatStatusType) => void; + onMessage?: (message: Types.ChatMessage) => void; + onStreaming?: (chunk: Types.ChatMessageBase) => void; + onComplete?: () => void; + onError?: (error: string | Types.ChatMessageBase) => void; + onWarn?: (warning: string) => void; signal?: AbortSignal; } interface StreamingResponse { messageId: string; cancel: () => void; - promise: Promise; -} - -interface ChatMessageChunk { - id?: string; - sessionId: string; - status: Types.ChatStatusType; - sender: Types.ChatSenderType; - content: string; - isPartial?: boolean; - timestamp: Date; - metadata?: Record; + promise: Promise; } // ============================ @@ -87,7 +77,6 @@ class ApiClient { } async logout(accessToken: string, refreshToken: string): Promise { - console.log(this.defaultHeaders); const response = await fetch(`${this.baseUrl}/auth/logout`, { method: 'POST', headers: this.defaultHeaders, @@ -348,10 +337,8 @@ class ApiClient { const signal = options.signal || abortController.signal; let messageId = ''; - let accumulatedContent = ''; - let currentMessage: Partial = {}; - const promise = new Promise(async (resolve, reject) => { + const promise = new Promise(async (resolve, reject) => { try { const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages/stream`, { method: 'POST', @@ -374,78 +361,65 @@ class ApiClient { } const decoder = new TextDecoder(); + let buffer = ''; + let chatMessage: Types.ChatMessage | null = null; + const chatMessageList : Types.ChatMessage[] = []; try { while (true) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + // Stream ended naturally - create final message + break; + } - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { - if (line.trim() === '') continue; + if (line.trim() === '') continue; // Skip blank lines between SSEs try { - // Handle Server-Sent Events format if (line.startsWith('data: ')) { - const data = line.slice(6); + const data = line.slice(5).trim(); + const incoming: Types.ChatMessageBase = JSON.parse(data); - if (data === '[DONE]') { - // Stream completed - const finalMessage: Types.ChatMessage = { - id: messageId, - sessionId, - status: 'done', - sender: currentMessage.sender || 'ai', - content: accumulatedContent, - timestamp: currentMessage.timestamp || new Date(), - ...currentMessage - }; + // Trigger callbacks based on status + if (incoming.status !== chatMessage?.status) { + options.onStatusChange?.(incoming.status); + } - options.onComplete?.(finalMessage); - resolve(finalMessage); - return; + // Handle different status types + switch (incoming.status) { + case 'streaming': + if (chatMessage === null) { + chatMessage = {...incoming}; + } else { + // Can't do a simple += as typescript thinks .content might not be there + chatMessage.content = (chatMessage?.content || '') + incoming.content; + } + options.onStreaming?.(incoming); + break; + + case 'error': + options.onError?.(incoming); + break; + + default: + chatMessageList.push(incoming); + options.onMessage?.(incoming); + break; } - - const messageChunk: ChatMessageChunk = JSON.parse(data); - - // Update accumulated state - if (messageChunk.id) messageId = messageChunk.id; - if (messageChunk.content) { - accumulatedContent += messageChunk.content; - } - - // Update current message properties - Object.assign(currentMessage, { - ...messageChunk, - content: accumulatedContent - }); - - // Trigger callbacks - if (messageChunk.status) { - options.onStatusChange?.(messageChunk.status); - } - - if (messageChunk.isPartial) { - options.onPartialMessage?.(messageChunk.content, messageId); - } - - const currentCompleteMessage: Types.ChatMessage = { - id: messageId, - sessionId, - status: messageChunk.status, - sender: messageChunk.sender, - content: accumulatedContent, - timestamp: messageChunk.timestamp, - ...currentMessage - }; - - options.onMessage?.(currentCompleteMessage); } - } catch (parseError) { - console.warn('Failed to parse SSE chunk:', parseError); + } catch (error) { + console.warn('Failed to process SSE:', error); + if (error instanceof Error) { + options.onWarn?.(error.message); + } // Continue processing other lines } } @@ -454,25 +428,15 @@ class ApiClient { reader.releaseLock(); } - // If we get here without a [DONE] signal, create final message - const finalMessage: Types.ChatMessage = { - id: messageId || `msg_${Date.now()}`, - sessionId, - status: 'done', - sender: currentMessage.sender || 'ai', - content: accumulatedContent, - timestamp: currentMessage.timestamp || new Date(), - ...currentMessage - }; - - options.onComplete?.(finalMessage); - resolve(finalMessage); - + options.onComplete?.(); + resolve(chatMessageList); } catch (error) { if (signal.aborted) { + options.onComplete?.(); reject(new Error('Request was aborted')); } else { - options.onError?.(error as Error); + options.onError?.((error as Error).message); + options.onComplete?.(); reject(error); } } @@ -492,15 +456,15 @@ class ApiClient { sessionId: string, query: Types.ChatQuery, options?: StreamingOptions - ): Promise { + ): Promise { // If streaming options are provided, use streaming - if (options && (options.onMessage || options.onPartialMessage || options.onStatusChange)) { + if (options && (options.onMessage || options.onStreaming || options.onStatusChange)) { const streamResponse = this.sendMessageStream(sessionId, query, options); return streamResponse.promise; } // Otherwise, use standard response - return this.sendMessage(sessionId, query); + return [await this.sendMessage(sessionId, query)]; } async getChatMessages(sessionId: string, request: Partial = {}): Promise> { @@ -737,4 +701,4 @@ await apiClient.sendMessageAuto(sessionId, 'Quick question'); // Will use standa */ export { ApiClient } -export type { StreamingOptions, StreamingResponse, ChatMessageChunk }; \ No newline at end of file +export type { StreamingOptions, StreamingResponse }; \ No newline at end of file diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 73a3ada..be27af2 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-05-29T05:47:25.809967 +// Generated on: 2025-05-29T21:15:06.572082 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -15,9 +15,11 @@ export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" export type ChatContextType = "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile"; -export type ChatSenderType = "user" | "ai" | "system"; +export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user"; -export type ChatStatusType = "preparing" | "thinking" | "partial" | "streaming" | "done" | "error"; +export type ChatSenderType = "user" | "assistant" | "system"; + +export type ChatStatusType = "initializing" | "streaming" | "done" | "error"; export type ColorBlindMode = "protanopia" | "deuteranopia" | "tritanopia" | "none"; @@ -231,17 +233,26 @@ export interface ChatContext { export interface ChatMessage { id?: string; sessionId: string; - status: "preparing" | "thinking" | "partial" | "streaming" | "done" | "error"; - sender: "user" | "ai" | "system"; senderId?: string; - prompt?: string; - content?: string; - chunk?: string; + status: "initializing" | "streaming" | "done" | "error"; + type: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user"; + sender: "user" | "assistant" | "system"; timestamp: Date; - isEdited?: boolean; + content?: string; metadata?: ChatMessageMetaData; } +export interface ChatMessageBase { + id?: string; + sessionId: string; + senderId?: string; + status: "initializing" | "streaming" | "done" | "error"; + type: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user"; + sender: "user" | "assistant" | "system"; + timestamp: Date; + content?: string; +} + export interface ChatMessageMetaData { model?: "qwen2.5" | "flux-schnell"; temperature?: number; @@ -261,6 +272,17 @@ export interface ChatMessageMetaData { timers?: Record; } +export interface ChatMessageUser { + id?: string; + sessionId: string; + senderId?: string; + status: "initializing" | "streaming" | "done" | "error"; + type?: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user"; + sender: "user" | "assistant" | "system"; + timestamp: Date; + content?: string; +} + export interface ChatOptions { seed?: number; numCtx?: number; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e1f4257..dfd0fbb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -20,8 +20,9 @@ "noEmit": true, "jsx": "react-jsx", "baseUrl": "src", + "sourceMap": true }, -"include": [ - "src/**/*" -] + "include": [ + "src/**/*" + ] } diff --git a/src/backend/agents/base.py b/src/backend/agents/base.py index 837fd92..0c2143e 100644 --- a/src/backend/agents/base.py +++ b/src/backend/agents/base.py @@ -21,12 +21,13 @@ import asyncio from datetime import datetime, UTC from prometheus_client import Counter, Summary, CollectorRegistry # type: ignore -from models import ( ChatQuery, ChatMessage, Tunables, ChatStatusType, ChatMessageMetaData) +from models import ( ChatQuery, ChatMessage, ChatOptions, ChatMessageBase, ChatMessageUser, Tunables, ChatMessageType, ChatSenderType, ChatStatusType, ChatMessageMetaData) from logger import logger import defines from .registry import agent_registry from metrics import Metrics from database import RedisDatabase # type: ignore +import model_cast class LLMMessage(BaseModel): role: str = Field(default="") @@ -342,15 +343,25 @@ class Agent(BaseModel, ABC): async def generate( self, llm: Any, model: str, query: ChatQuery, session_id: str, user_id: str, temperature=0.7 - ) -> AsyncGenerator[ChatMessage, None]: + ) -> AsyncGenerator[ChatMessage | ChatMessageBase, None]: logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + user_message = ChatMessageUser( + session_id=session_id, + tunables=query.tunables, + type=ChatMessageType.USER, + status=ChatStatusType.DONE, + sender=ChatSenderType.USER, + content=query.prompt.strip(), + timestamp=datetime.now(UTC) + ) + chat_message = ChatMessage( session_id=session_id, - prompt=query.prompt, tunables=query.tunables, - status=ChatStatusType.PREPARING, - sender="user", + status=ChatStatusType.INITIALIZING, + type=ChatMessageType.PREPARING, + sender=ChatSenderType.ASSISTANT, content="", timestamp=datetime.now(UTC) ) @@ -361,28 +372,22 @@ class Agent(BaseModel, ABC): messages: List[LLMMessage] = [ LLMMessage(role="system", content=self.system_prompt) ] - messages.extend( - [ - item - for m in self.conversation - for item in [ - LLMMessage(role="user", content=m.prompt.strip() if m.prompt else ""), - LLMMessage(role="assistant", content=m.response.strip()), - ] - ] - ) + messages.extend([ + LLMMessage(role=m.sender, content=m.content.strip()) + for m in self.conversation + ]) # Only the actual user query is provided with the full context message messages.append( - LLMMessage(role="user", content=query.prompt.strip()) + LLMMessage(role=user_message.sender, content=user_message.content.strip()) ) # message.messages = messages chat_message.metadata = ChatMessageMetaData() - chat_message.metadata.options = { - "seed": 8911, - "num_ctx": self.context_size, - "temperature": temperature, # Higher temperature to encourage tool usage - } + chat_message.metadata.options = ChatOptions( + seed=8911, + num_ctx=self.context_size, + temperature=temperature, # Higher temperature to encourage tool usage + ) # Create a dict for storing various timing stats chat_message.metadata.timers = {} @@ -488,17 +493,21 @@ class Agent(BaseModel, ABC): # return # not use_tools - chat_message.status = ChatStatusType.THINKING + chat_message.type = ChatMessageType.THINKING chat_message.content = f"Generating response..." yield chat_message + # Reset the response for streaming chat_message.content = "" start_time = time.perf_counter() + chat_message.type = ChatMessageType.GENERATING + chat_message.status = ChatStatusType.STREAMING + for response in llm.chat( model=model, messages=messages, options={ - **chat_message.metadata.options, + **chat_message.metadata.model_dump(exclude_unset=True), }, stream=True, ): @@ -508,12 +517,13 @@ class Agent(BaseModel, ABC): yield chat_message return - chat_message.status = ChatStatusType.STREAMING - chat_message.chunk = response.message.content - chat_message.content += chat_message.chunk + chat_message.content += response.message.content if not response.done: + chat_chunk = model_cast.cast_to_model(ChatMessageBase, chat_message) + chat_chunk.content = response.message.content yield chat_message + continue if response.done: self.collect_metrics(response) @@ -524,12 +534,15 @@ class Agent(BaseModel, ABC): self.context_tokens = ( response.prompt_eval_count + response.eval_count ) + chat_message.type = ChatMessageType.RESPONSE chat_message.status = ChatStatusType.DONE yield chat_message end_time = time.perf_counter() chat_message.metadata.timers["streamed"] = end_time - start_time - chat_message.status = ChatStatusType.DONE + + # Add the user and chat messages to the conversation + self.conversation.append(user_message) self.conversation.append(chat_message) return diff --git a/src/backend/main.py b/src/backend/main.py index 6fb8f00..73a4a75 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -31,12 +31,13 @@ from models import ( Job, JobApplication, ApplicationStatus, # Chat models - ChatSession, ChatMessage, ChatContext, ChatQuery, + ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, # Supporting models Location, Skill, WorkExperience, Education ) +import model_cast import defines import agents from logger import logger @@ -343,7 +344,7 @@ async def login( expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()) ) - return create_success_response(auth_response.model_dump(by_alias=True)) + return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"⚠️ Login error: {e}") @@ -531,7 +532,7 @@ async def refresh_token_endpoint( expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()) ) - return create_success_response(auth_response.model_dump(by_alias=True)) + return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True)) except jwt.PyJWTError: return JSONResponse( @@ -578,7 +579,7 @@ async def create_candidate( "type": "candidate" }) - return create_success_response(candidate.model_dump(by_alias=True)) + return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Candidate creation error: {e}") @@ -614,7 +615,7 @@ async def get_candidate( ) candidate = Candidate.model_validate(candidates_list[0]) - return create_success_response(candidate.model_dump(by_alias=True)) + return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Get candidate error: {e}") @@ -656,7 +657,7 @@ async def update_candidate( updated_candidate = Candidate.model_validate(candidate_dict) await database.set_candidate(candidate_id, updated_candidate.model_dump()) - return create_success_response(updated_candidate.model_dump(by_alias=True)) + return create_success_response(updated_candidate.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Update candidate error: {e}") @@ -690,7 +691,7 @@ async def get_candidates( ) paginated_response = create_paginated_response( - [c.model_dump(by_alias=True) for c in paginated_candidates], + [c.model_dump(by_alias=True, exclude_unset=True) for c in paginated_candidates], page, limit, total ) @@ -739,7 +740,7 @@ async def search_candidates( ) paginated_response = create_paginated_response( - [c.model_dump(by_alias=True) for c in paginated_candidates], + [c.model_dump(by_alias=True, exclude_unset=True) for c in paginated_candidates], page, limit, total ) @@ -781,7 +782,7 @@ async def create_job( job = Job.model_validate(job_data) await database.set_job(job.id, job.model_dump()) - return create_success_response(job.model_dump(by_alias=True)) + return create_success_response(job.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Job creation error: {e}") @@ -809,7 +810,7 @@ async def get_job( await database.set_job(job_id, job_data) job = Job.model_validate(job_data) - return create_success_response(job.model_dump(by_alias=True)) + return create_success_response(job.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Get job error: {e}") @@ -842,7 +843,7 @@ async def get_jobs( ) paginated_response = create_paginated_response( - [j.model_dump(by_alias=True) for j in paginated_jobs], + [j.model_dump(by_alias=True, exclude_unset=True) for j in paginated_jobs], page, limit, total ) @@ -887,7 +888,7 @@ async def search_jobs( ) paginated_response = create_paginated_response( - [j.model_dump(by_alias=True) for j in paginated_jobs], + [j.model_dump(by_alias=True, exclude_unset=True) for j in paginated_jobs], page, limit, total ) @@ -921,7 +922,7 @@ async def create_chat_session( await database.set_chat_session(chat_session.id, chat_session.model_dump()) logger.info(f"✅ Chat session created: {chat_session.id} for user {current_user.id}") - return create_success_response(chat_session.model_dump(by_alias=True)) + return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Chat session creation error: {e}") @@ -946,7 +947,7 @@ async def get_chat_session( ) chat_session = ChatSession.model_validate(chat_session_data) - return create_success_response(chat_session.model_dump(by_alias=True)) + return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: logger.error(f"Get chat session error: {e}") @@ -986,7 +987,7 @@ async def get_chat_session_messages( messages_list, page, limit, sortBy, sortOrder, filter_dict ) paginated_response = create_paginated_response( - [m.model_dump(by_alias=True) for m in paginated_messages], + [m.model_dump(by_alias=True, exclude_unset=True) for m in paginated_messages], page, limit, total ) @@ -1034,27 +1035,37 @@ async def post_chat_session_message_stream( ) async def message_stream_generator(): """Generator to stream messages""" - async for message in chat_agent.generate( + last_log = None + async for chat_message in chat_agent.generate( llm=llm_manager.get_llm(), model=defines.model, query=chat_query, session_id=session_id, user_id=current_user.id, ): - json_data = message.model_dump(mode='json', by_alias=True) + # If the message is not done, convert it to a ChatMessageBase to remove + # metadata and other unnecessary fields + if chat_message.status != ChatStatusType.DONE: + chat_message = model_cast.cast_to_model(ChatMessageBase, chat_message) + + json_data = chat_message.model_dump(mode='json', by_alias=True, exclude_unset=True) json_str = json.dumps(json_data) - logger.info(f"🔗 Streaming message for session {session_id}: {json_str}") - yield json_str + "\n" + log = f"🔗 Message status={chat_message.status}, type={chat_message.type}" + if last_log != log: + last_log = log + logger.info(log) + yield f"data: {json_str}\n\n" return StreamingResponse( - message_stream_generator(), - media_type="application/json", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", # Prevents Nginx buffering if you're using it - }, - ) + message_stream_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + #"Access-Control-Allow-Origin": "*", # CORS + "X-Accel-Buffering": "no", # Prevents Nginx buffering if you're using it + }, + ) except Exception as e: logger.error(traceback.format_exc()) @@ -1090,7 +1101,7 @@ async def get_chat_sessions( ) paginated_response = create_paginated_response( - [s.model_dump(by_alias=True) for s in paginated_sessions], + [s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions], page, limit, total ) diff --git a/src/backend/model_cast.py b/src/backend/model_cast.py new file mode 100644 index 0000000..19d0ea4 --- /dev/null +++ b/src/backend/model_cast.py @@ -0,0 +1,14 @@ +from typing import Type, TypeVar +from pydantic import BaseModel # type: ignore +import copy + + +T = TypeVar('T', bound=BaseModel) + +def cast_to_model(model_cls: Type[T], source: BaseModel) -> T: + data = {field: getattr(source, field) for field in model_cls.__fields__} + return model_cls(**data) + +def cast_to_model_safe(model_cls: Type[T], source: BaseModel) -> T: + data = {field: copy.deepcopy(getattr(source, field)) for field in model_cls.__fields__} + return model_cls(**data) diff --git a/src/backend/models.py b/src/backend/models.py index 06a03fd..084d26d 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -64,13 +64,24 @@ class InterviewRecommendation(str, Enum): class ChatSenderType(str, Enum): USER = "user" - AI = "ai" + ASSISTANT = "assistant" SYSTEM = "system" -class ChatStatusType(str, Enum): +class ChatMessageType(str, Enum): + ERROR = "error" + GENERATING = "generating" + INFO = "info" PREPARING = "preparing" + PROCESSING = "processing" + RESPONSE = "response" + SEARCHING = "searching" + SYSTEM = "system" THINKING = "thinking" - PARTIAL = "partial" + TOOLING = "tooling" + USER = "user" + +class ChatStatusType(str, Enum): + INITIALIZING = "initializing" STREAMING = "streaming" DONE = "done" ERROR = "error" @@ -572,24 +583,28 @@ class ChatMessageMetaData(BaseModel): class Config: populate_by_name = True # Allow both field names and aliases -class ChatMessage(BaseModel): +class ChatMessageBase(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) session_id: str = Field(..., alias="sessionId") - status: ChatStatusType - sender: ChatSenderType sender_id: Optional[str] = Field(None, alias="senderId") - prompt: str = "" - content: str = "" - chunk: str = "" + status: ChatStatusType + type: ChatMessageType + sender: ChatSenderType timestamp: datetime - #attachments: Optional[List[Attachment]] = None - #reactions: Optional[List[MessageReaction]] = None - is_edited: bool = Field(False, alias="isEdited") - #edit_history: Optional[List[EditHistory]] = Field(None, alias="editHistory") - metadata: ChatMessageMetaData = Field(None) + content: str = "" class Config: populate_by_name = True # Allow both field names and aliases +class ChatMessageUser(ChatMessageBase): + type: ChatMessageType = ChatMessageType.USER + +class ChatMessage(ChatMessageBase): + #attachments: Optional[List[Attachment]] = None + #reactions: Optional[List[MessageReaction]] = None + #is_edited: bool = Field(False, alias="isEdited") + #edit_history: Optional[List[EditHistory]] = Field(None, alias="editHistory") + metadata: ChatMessageMetaData = Field(None) + class ChatSession(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) user_id: Optional[str] = Field(None, alias="userId") diff --git a/src/kill-server.sh b/src/kill-server.sh index 8859181..4a2ba3b 100755 --- a/src/kill-server.sh +++ b/src/kill-server.sh @@ -1,19 +1,6 @@ #!/bin/bash -# Ensure input was provided -if [[ -z "$1" ]]; then - TARGET=$(readlink -f "src/server.py") -else - TARGET=$(readlink -f "$1") -fi - -# Resolve user-supplied path to absolute path - -if [[ ! -f "$TARGET" ]]; then - echo "Target file '$TARGET' not found." - exit 1 -fi - +get_pid() { # Loop through python processes and resolve each script path PID="" for pid in $(pgrep -f python); do @@ -32,9 +19,33 @@ for pid in $(pgrep -f python); do fi fi done +} + +if [[ -z "$1" ]]; then + for file in "src/server.py" "src/backend/main.py"; do + echo "Checking ${file}" + # Ensure input was provided + TARGET=$(readlink -f "$file") + if [[ ! -f "$TARGET" ]]; then + echo "Target file '$TARGET' not found." + exit 1 + fi + get_pid + if [[ "${PID}" != "" ]]; then + break + fi + done +else + TARGET=$(readlink -f "$1") + if [[ ! -f "$TARGET" ]]; then + echo "Target file '$TARGET' not found." + exit 1 + fi + get_pid +fi if [[ -z "$PID" ]]; then - echo "No Python process found running '$TARGET'." + echo "No Python process found running." exit 1 fi