diff --git a/frontend/src/BackstoryApp.tsx b/frontend/src/BackstoryApp.tsx index 5683fac..ec7e61c 100644 --- a/frontend/src/BackstoryApp.tsx +++ b/frontend/src/BackstoryApp.tsx @@ -7,8 +7,8 @@ import { backstoryTheme } from './BackstoryTheme'; import { SeverityType } from 'components/Snack'; import { Query } from 'types/types'; import { ConversationHandle } from 'components/Conversation'; -import { UserProvider } from 'components/UserContext'; -import { UserRoute } from 'routes/UserRoute'; +import { UserProvider } from 'hooks/useUser'; +import { CandidateRoute } from 'routes/CandidateRoute'; import { BackstoryLayout } from 'components/layout/BackstoryLayout'; import './BackstoryApp.css'; @@ -17,27 +17,17 @@ import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; -import { connectionBase } from './utils/Global'; - -// Cookie handling functions -const getCookie = (name: string) => { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift(); - return null; -}; - -const setCookie = (name: string, value: string, days = 7) => { - const expires = new Date(Date.now() + days * 864e5).toUTCString(); - document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Strict`; -}; +import { debugConversion } from 'types/conversion'; +import { User, Guest, Candidate } from 'types/types'; const BackstoryApp = () => { + const [user, setUser] = useState(null); + const [guest, setGuest] = useState(null); + const [candidate, setCandidate] = useState(null); const navigate = useNavigate(); const location = useLocation(); const snackRef = useRef(null); const chatRef = useRef(null); - const [sessionId, setSessionId] = useState(undefined); const setSnack = useCallback((message: string, severity?: SeverityType) => { snackRef.current?.setSnack(message, severity); }, [snackRef]); @@ -48,72 +38,50 @@ const BackstoryApp = () => { }; const [page, setPage] = useState(""); - // Extract session ID from URL query parameter or cookie - const urlParams = new URLSearchParams(window.location.search); - const urlSessionId = urlParams.get('id'); - const cookieSessionId = getCookie('session_id'); - - // Fetch or join session on mount - useEffect(() => { - const fetchSession = async () => { - try { - let response; - let newSessionId; - let action = "" - if (urlSessionId) { - // Attempt to join session from URL - response = await fetch(`${connectionBase}/api/join-session/${urlSessionId}`, { - credentials: 'include', - }); - if (!response.ok) { - throw new Error('Session not found'); - } - newSessionId = (await response.json()).id; - action = "Joined"; - } else if (cookieSessionId) { - // Attempt to join session from cookie - response = await fetch(`${connectionBase}/api/join-session/${cookieSessionId}`, { - credentials: 'include', - }); - if (!response.ok) { - // Cookie session invalid, create new session - response = await fetch(`${connectionBase}/api/create-session`, { - method: 'POST', - credentials: 'include', - }); - if (!response.ok) { - throw new Error('Failed to create session'); - } - action = "Created new"; - } else { - action = "Joined"; - } - newSessionId = (await response.json()).id; - } else { - // Create a new session - response = await fetch(`${connectionBase}/api/create-session`, { - method: 'POST', - credentials: 'include', - }); - if (!response.ok) { - throw new Error('Failed to create session'); - } - action = "Created new"; - newSessionId = (await response.json()).id; - } - setSessionId(newSessionId); - setSnack(`${action} session ${newSessionId}`); - - // Store in cookie if user opts in - setCookie('session_id', newSessionId); - // Clear all query parameters, preserve the current path - navigate(location.pathname, { replace: true }); - } catch (err) { - setSnack("" + err); - } + const createGuestSession = () => { + console.log("TODO: Convert this to query the server for the session instead of generating it."); + const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const guest: Guest = { + sessionId, + createdAt: new Date(), + lastActivity: new Date(), + ipAddress: 'unknown', + userAgent: navigator.userAgent }; - fetchSession(); - }, [cookieSessionId, setSnack, urlSessionId, location.pathname, navigate]); + setGuest(guest); + debugConversion(guest, 'Guest Session'); + }; + + const checkExistingAuth = () => { + const token = localStorage.getItem('accessToken'); + const userData = localStorage.getItem('userData'); + if (token && userData) { + try { + const user = JSON.parse(userData); + // Convert dates back to Date objects if they're stored as strings + if (user.createdAt && typeof user.createdAt === 'string') { + user.createdAt = new Date(user.createdAt); + } + if (user.updatedAt && typeof user.updatedAt === 'string') { + user.updatedAt = new Date(user.updatedAt); + } + if (user.lastLogin && typeof user.lastLogin === 'string') { + user.lastLogin = new Date(user.lastLogin); + } + setUser(user); + } catch (e) { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userData'); + } + } + }; + + // Create guest session on component mount + useEffect(() => { + createGuestSession(); + checkExistingAuth(); + }, []); useEffect(() => { const currentRoute = location.pathname.split("/")[1] ? `/${location.pathname.split("/")[1]}` : "/"; @@ -123,15 +91,14 @@ const BackstoryApp = () => { // Render appropriate routes based on user type return ( - + - } /> + } /> {/* Static/shared routes */} { const apiClient = new ApiClient(); - const [currentUser, setCurrentUser] = useState(null); + const [currentUser, setCurrentUser] = useState(null); const [guestSession, setGuestSession] = useState(null); const [tabValue, setTabValue] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [phone, setPhone] = useState(null); + const name = (currentUser?.userType === 'candidate' ? (currentUser as Candidate).username : currentUser?.email) || ''; // Login form state const [loginForm, setLoginForm] = useState({ @@ -259,7 +260,7 @@ const BackstoryTestApp: React.FC = () => { - Welcome, {currentUser.username} + Welcome, {name} @@ -436,7 +408,7 @@ const Conversation = forwardRef((props: C sx={{ display: "flex", margin: 'auto 0px' }} size="large" edge="start" - disabled={stopRef.current || sessionId === undefined || processing === false} + disabled={stopRef.current || !chatSession || processing === false} > diff --git a/frontend/src/components/Document.tsx b/frontend/src/components/Document.tsx index 1cc462e..fb1f135 100644 --- a/frontend/src/components/Document.tsx +++ b/frontend/src/components/Document.tsx @@ -7,11 +7,10 @@ interface DocumentProps extends BackstoryElementProps { } const Document = (props: DocumentProps) => { - const { sessionId, setSnack, submitQuery, filepath } = props; + const { setSnack, submitQuery, filepath } = props; const backstoryProps = { submitQuery, setSnack, - sessionId }; const [document, setDocument] = useState(""); diff --git a/frontend/src/components/GenerateImage.tsx b/frontend/src/components/GenerateImage.tsx index b37778c..3bc8f44 100644 --- a/frontend/src/components/GenerateImage.tsx +++ b/frontend/src/components/GenerateImage.tsx @@ -2,24 +2,25 @@ import React, { useEffect, useState, useRef } from 'react'; import Box from '@mui/material/Box'; import PropagateLoader from 'react-spinners/PropagateLoader'; import { Quote } from 'components/Quote'; -import { streamQueryResponse, StreamQueryController } from 'services/streamQueryResponse'; -import { connectionBase } from 'utils/Global'; import { BackstoryElementProps } from 'components/BackstoryTab'; -import { useUser } from 'components/UserContext'; +import { useUser } from 'hooks/useUser'; +import { Candidate, ChatSession } from 'types/types'; interface GenerateImageProps extends BackstoryElementProps { - prompt: string + prompt: string; + chatSession: ChatSession; }; const GenerateImage = (props: GenerateImageProps) => { const { user } = useUser(); - const {sessionId, setSnack, prompt} = props; + const { setSnack, chatSession, prompt } = props; const [processing, setProcessing] = useState(false); const [status, setStatus] = useState(''); const [image, setImage] = useState(''); + const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || ''; // Only keep refs that are truly necessary - const controllerRef = useRef(null); + const controllerRef = useRef(null); // Effect to trigger profile generation when user data is ready useEffect(() => { @@ -34,56 +35,54 @@ const GenerateImage = (props: GenerateImageProps) => { setProcessing(true); const start = Date.now(); - controllerRef.current = streamQueryResponse({ - query: { - prompt: prompt, - agentOptions: { - username: user?.username, - } - }, - type: "image", - sessionId, - connectionBase, - onComplete: (msg) => { - switch (msg.status) { - case "partial": - case "done": - if (msg.status === "done") { - if (!msg.response) { - setSnack("Image generation failed", "error"); - } else { - setImage(msg.response); - } - setProcessing(false); - controllerRef.current = null; - } - break; - case "error": - console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`); - setSnack(msg.response || "", "error"); - setProcessing(false); - controllerRef.current = null; - break; - default: - let data: any = {}; - try { - data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response; - } catch (e) { - data = { message: msg.response }; - } - if (msg.status !== "heartbeat") { - console.log(data); - } - if (data.message) { - setStatus(data.message); - } - break; - } - } - }); - }, [user, prompt, sessionId, setSnack]); + // controllerRef.current = streamQueryResponse({ + // query: { + // prompt: prompt, + // agentOptions: { + // username: name, + // } + // }, + // type: "image", + // onComplete: (msg) => { + // switch (msg.status) { + // case "partial": + // case "done": + // if (msg.status === "done") { + // if (!msg.response) { + // setSnack("Image generation failed", "error"); + // } else { + // setImage(msg.response); + // } + // setProcessing(false); + // controllerRef.current = null; + // } + // break; + // case "error": + // console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`); + // setSnack(msg.response || "", "error"); + // setProcessing(false); + // controllerRef.current = null; + // break; + // default: + // let data: any = {}; + // try { + // data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response; + // } catch (e) { + // data = { message: msg.response }; + // } + // if (msg.status !== "heartbeat") { + // console.log(data); + // } + // if (data.message) { + // setStatus(data.message); + // } + // break; + // } + // } + // }); + }, [user, prompt, setSnack]); - if (!sessionId) { + if (!chatSession) { return <>; } @@ -96,7 +95,7 @@ const GenerateImage = (props: GenerateImageProps) => { maxWidth: { xs: '100%', md: '700px', lg: '1024px' }, minHeight: "max-content", }}> - {image !== '' && {prompt}} + {image !== '' && {prompt}} { prompt && } diff --git a/frontend/src/components/Message.tsx b/frontend/src/components/Message.tsx index 501308d..eac508f 100644 --- a/frontend/src/components/Message.tsx +++ b/frontend/src/components/Message.tsx @@ -32,6 +32,7 @@ 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' | @@ -304,14 +305,15 @@ type MessageList = BackstoryMessage[]; interface MessageProps extends BackstoryElementProps { sx?: SxProps, - message: BackstoryMessage, + message: ChatMessage, expanded?: boolean, onExpand?: (open: boolean) => void, className?: string, + chatSession?: ChatSession, }; interface MessageMetaProps { - metadata: MessageMetaData, + metadata: Record, messageProps: MessageProps }; @@ -446,12 +448,11 @@ const MessageMeta = (props: MessageMetaProps) => { }; const Message = (props: MessageProps) => { - const { message, submitQuery, sx, className, onExpand, setSnack, sessionId, expanded } = props; + const { message, submitQuery, sx, className, chatSession, onExpand, setSnack, expanded } = props; const [metaExpanded, setMetaExpanded] = useState(false); const textFieldRef = useRef(null); const backstoryProps = { submitQuery, - sessionId, setSnack }; @@ -475,7 +476,8 @@ const Message = (props: MessageProps) => { return ( { overflow: "auto", /* Handles scrolling for the div */ }} > - + - {(message.disableCopy === undefined || message.disableCopy === false) && } + {/*(message.disableCopy === undefined || message.disableCopy === false) &&*/ } {message.metadata && ( @@ -381,7 +374,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => { sx={{ display: "flex", margin: 'auto 0px' }} size="large" edge="start" - disabled={controllerRef.current === null || !sessionId || processing === false} + disabled={controllerRef.current === null || processing === false} > diff --git a/frontend/src/pages/LoadingPage.tsx b/frontend/src/pages/LoadingPage.tsx index 6c69d06..1658ca2 100644 --- a/frontend/src/pages/LoadingPage.tsx +++ b/frontend/src/pages/LoadingPage.tsx @@ -1,18 +1,19 @@ import Box from '@mui/material/Box'; import { BackstoryPageProps } from '../components/BackstoryTab'; -import { BackstoryMessage, Message } from '../components/Message'; +import { Message } from '../components/Message'; +import { ChatMessage } from 'types/types'; const LoadingPage = (props: BackstoryPageProps) => { - const backstoryPreamble: BackstoryMessage = { - role: 'info', - title: 'Please wait while connecting to Backstory...', - disableCopy: true, - content: '...', - expandable: false, + const preamble: ChatMessage = { + sender: 'system', + status: 'done', + sessionId: '', + content: 'Please wait while connecting to Backstory...', + timestamp: new Date() } return - + }; diff --git a/frontend/src/pages/ResumeBuilderPage.tsx b/frontend/src/pages/ResumeBuilderPage.tsx index e5b02ec..58f75d3 100644 --- a/frontend/src/pages/ResumeBuilderPage.tsx +++ b/frontend/src/pages/ResumeBuilderPage.tsx @@ -23,7 +23,6 @@ import './ResumeBuilderPage.css'; const ResumeBuilderPage: React.FC = (props: BackstoryPageProps) => { const { sx, - sessionId, setSnack, submitQuery, } = props @@ -196,191 +195,194 @@ const ResumeBuilderPage: React.FC = (props: BackstoryPagePro setHasFacts(false); }, [setHasFacts]); - const renderJobDescriptionView = useCallback((sx?: SxProps) => { - console.log('renderJobDescriptionView'); - const jobDescriptionQuestions = [ - - - - , - ]; + return (Not re-implmented yet); - const jobDescriptionPreamble: MessageList = [{ - role: 'info', - content: `Once you paste a job description and press **Generate Resume**, Backstory will perform the following actions: -1. **Job Analysis**: LLM extracts requirements from '\`Job Description\`' to generate a list of desired '\`Skills\`'. -2. **Candidate Analysis**: LLM determines candidate qualifications by performing skill assessments. +// const renderJobDescriptionView = useCallback((sx?: SxProps) => { +// console.log('renderJobDescriptionView'); +// const jobDescriptionQuestions = [ +// +// +// +// , +// ]; + +// const jobDescriptionPreamble: MessageList = [{ +// role: 'info', +// content: `Once you paste a job description and press **Generate Resume**, Backstory will perform the following actions: + +// 1. **Job Analysis**: LLM extracts requirements from '\`Job Description\`' to generate a list of desired '\`Skills\`'. +// 2. **Candidate Analysis**: LLM determines candidate qualifications by performing skill assessments. - For each '\`Skill\`' from **Job Analysis** phase: +// For each '\`Skill\`' from **Job Analysis** phase: - 1. **RAG**: Retrieval Augmented Generation collection is queried for context related content for each '\`Skill\`'. - 2. **Evidence Creation**: LLM is queried to generate supporting evidence of '\`Skill\`' from the '\`RAG\`' and '\`Candidate Resume\`'. -3. **Resume Generation**: LLM is provided the output from the **Candidate Analysis:Evidence Creation** phase and asked to generate a professional resume. +// 1. **RAG**: Retrieval Augmented Generation collection is queried for context related content for each '\`Skill\`'. +// 2. **Evidence Creation**: LLM is queried to generate supporting evidence of '\`Skill\`' from the '\`RAG\`' and '\`Candidate Resume\`'. +// 3. **Resume Generation**: LLM is provided the output from the **Candidate Analysis:Evidence Creation** phase and asked to generate a professional resume. -See [About > Resume Generation Architecture](/about/resume-generation) for more details. -`, - disableCopy: true - }]; +// See [About > Resume Generation Architecture](/about/resume-generation) for more details. +// `, +// disableCopy: true +// }]; - if (!hasJobDescription) { - return + // if (!hasJobDescription) { + // return - } else { - return - } - }, [filterJobDescriptionMessages, hasJobDescription, sessionId, setSnack, jobResponse, resetJobDescription, hasFacts, hasResume, submitQuery]); + // } else { + // return + // } + // }, [filterJobDescriptionMessages, hasJobDescription, sessionId, setSnack, jobResponse, resetJobDescription, hasFacts, hasResume, submitQuery]); - /** - * Renders the resume view with loading indicator - */ - const renderResumeView = useCallback((sx?: SxProps) => { - const resumeQuestions = [ - - - - , - ]; + // /** + // * Renders the resume view with loading indicator + // */ + // const renderResumeView = useCallback((sx?: SxProps) => { + // const resumeQuestions = [ + // + // + // + // , + // ]; - if (!hasFacts) { - return - } else { - return - } - }, [filterResumeMessages, hasFacts, sessionId, setSnack, resumeResponse, resetResume, hasResume, submitQuery]); + // if (!hasFacts) { + // return + // } else { + // return + // } + // }, [filterResumeMessages, hasFacts, sessionId, setSnack, resumeResponse, resetResume, hasResume, submitQuery]); - /** - * Renders the fact check view - */ - const renderFactCheckView = useCallback((sx?: SxProps) => { - const factsQuestions = [ - - - , - ]; + // /** + // * Renders the fact check view + // */ + // const renderFactCheckView = useCallback((sx?: SxProps) => { + // const factsQuestions = [ + // + // + // , + // ]; - return - }, [ sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts, submitQuery]); + // return + // }, [ sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts, submitQuery]); - return ( - - {/* Tabs */} - - - {hasResume && } - {hasFacts && } - + // return ( + // + // {/* Tabs */} + // + // + // {hasResume && } + // {hasFacts && } + // - {/* Document display area */} - - {renderJobDescriptionView(/*{ height: "calc(100% - 72px - 48px)" }*/)} - {renderResumeView(/*{ height: "calc(100% - 72px - 48px)" }*/)} - {renderFactCheckView(/*{ height: "calc(100% - 72px - 48px)" }*/)} - - - ); + // {/* Document display area */} + // + // {renderJobDescriptionView(/*{ height: "calc(100% - 72px - 48px)" }*/)} + // {renderResumeView(/*{ height: "calc(100% - 72px - 48px)" }*/)} + // {renderFactCheckView(/*{ height: "calc(100% - 72px - 48px)" }*/)} + // + // + // ); }; export { diff --git a/frontend/src/routes/CandidateRoute.tsx b/frontend/src/routes/CandidateRoute.tsx new file mode 100644 index 0000000..30a5272 --- /dev/null +++ b/frontend/src/routes/CandidateRoute.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useUser } from "../hooks/useUser"; +import { Box } from "@mui/material"; + +import { SetSnackType } from '../components/Snack'; +import { LoadingComponent } from "../components/LoadingComponent"; +import { User, Guest, Candidate } from 'types/types'; +import { ApiClient } from "types/api-client"; + +interface CandidateRouteProps { + guest?: Guest | null; + user?: User | null; + setSnack: SetSnackType, +}; + +const CandidateRoute: React.FC = (props: CandidateRouteProps) => { + const apiClient = new ApiClient(); + const { setSnack } = props; + const { username } = useParams<{ username: string }>(); + const [candidate, setCandidate] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + if (candidate?.username === username || !username) { + return; + } + const getCandidate = async (username: string) => { + try { + const result : Candidate = await apiClient.getCandidate(username); + setCandidate(result); + navigate('/chat'); + } catch { + setSnack(`Unable to obtain information for ${username}.`, "error"); + } + } + + getCandidate(username); + }, [candidate, username, setCandidate]); + + if (candidate === null) { + return ( + + ); + } else { + return (<>); + } +}; + +export { CandidateRoute }; diff --git a/frontend/src/routes/UserRoute.tsx b/frontend/src/routes/UserRoute.tsx deleted file mode 100644 index 39f5959..0000000 --- a/frontend/src/routes/UserRoute.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useEffect } from "react"; -import { useParams, useNavigate } from "react-router-dom"; -import { useUser } from "../components/UserContext"; -import { User } from "../types/types"; -import { Box } from "@mui/material"; -import { connectionBase } from "../utils/Global"; -import { SetSnackType } from '../components/Snack'; -import { LoadingComponent } from "../components/LoadingComponent"; - -interface UserRouteProps { - sessionId?: string | null; - setSnack: SetSnackType, -}; - -const UserRoute: React.FC = (props: UserRouteProps) => { - const { sessionId, setSnack } = props; - const { username } = useParams<{ username: string }>(); - const { user, setUser } = useUser(); - const navigate = useNavigate(); - - useEffect(() => { - if (!sessionId) { - return; - } - - const fetchUser = async (username: string): Promise => { - try { - let response; - response = await fetch(`${connectionBase}/api/u/${username}/${sessionId}`, { - method: 'POST', - credentials: 'include', - }); - if (!response.ok) { - throw new Error('Session not found'); - } - const user: User = await response.json(); - console.log("Loaded user:", user); - setUser(user); - navigate('/chat'); - } catch (err) { - setSnack("" + err); - setUser(null); - navigate('/'); - } - return null; - }; - - if (user?.username !== username && username) { - fetchUser(username); - } else { - if (user?.username) { - navigate('/chat'); - } else { - navigate('/'); - } - } - }, [user, username, setUser, sessionId, setSnack, navigate]); - - if (sessionId === undefined || !user) { - return ( - - ); - } else { - return (<>); - } -}; - -export { UserRoute }; diff --git a/frontend/src/services/streamQueryResponse.tsx b/frontend/src/services/streamQueryResponse.tsx deleted file mode 100644 index 9401565..0000000 --- a/frontend/src/services/streamQueryResponse.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { BackstoryMessage } from 'components/Message'; -import { Query } from 'types/types'; -import { jsonrepair } from 'jsonrepair'; - -type StreamQueryOptions = { - query: Query; - type: string; - sessionId: string; - connectionBase: string; - onComplete: (message: BackstoryMessage) => void; - onStreaming?: (message: string) => void; -}; - -type StreamQueryController = { - abort: () => void -}; - -const streamQueryResponse = (options: StreamQueryOptions) => { - const { - query, - type, - sessionId, - connectionBase, - onComplete, - onStreaming, - } = options; - - const abortController = new AbortController(); - - const run = async () => { - query.prompt = query.prompt.trim(); - - let data: any = query; - if (type === "job_description") { - data = { - prompt: "", - agent_options: { - job_description: query.prompt, - }, - }; - } - - try { - const response = await fetch(`${connectionBase}/api/${type}/${sessionId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(data), - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`Server responded with ${response.status}: ${response.statusText}`); - } - - if (!response.body) { - throw new Error('Response body is null'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let streaming_response = ''; - - const processLine = async (line: string) => { - const update = JSON.parse(jsonrepair(line)); - - switch (update.status) { - case "streaming": - streaming_response += update.chunk; - onStreaming?.(streaming_response); - break; - case 'error': - const errorMessage: BackstoryMessage = { - ...update, - role: 'error', - origin: type, - content: update.response ?? '', - }; - onComplete(errorMessage); - break; - default: - const message: BackstoryMessage = { - ...update, - role: 'assistant', - origin: type, - prompt: update.prompt ?? '', - content: update.response ?? '', - expanded: update.status === 'done', - expandable: update.status !== 'done', - }; - streaming_response = ''; - onComplete(message); - break; - } - }; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.trim()) continue; - try { - await processLine(line); - } catch (e) { - console.error('Error processing line:', e); - console.error(line); - } - } - } - - if (buffer.trim()) { - try { - await processLine(buffer); - } catch (e) { - console.error('Error processing remaining buffer:', e); - } - } - - } catch (error) { - if ((error as any).name === 'AbortError') { - console.log('Query aborted'); - onComplete({ - role: 'error', - origin: type, - content: 'Query was cancelled.', - response: error, - status: 'error', - } as BackstoryMessage); - } else { - console.error('Fetch error:', error); - onComplete({ - role: 'error', - origin: type, - content: 'Unable to process query', - response: "" + error, - status: 'error', - } as BackstoryMessage); - } - } - }; - - run(); - - return { - abort: () => abortController.abort(), - }; -}; - -export type { - StreamQueryController -}; - -export { streamQueryResponse }; \ No newline at end of file diff --git a/frontend/src/types/api-client.ts b/frontend/src/types/api-client.ts index 3b82c9f..bd39da1 100644 --- a/frontend/src/types/api-client.ts +++ b/frontend/src/types/api-client.ts @@ -1,8 +1,8 @@ /** - * API Client Example + * Enhanced API Client with Streaming Support * * This demonstrates how to use the generated types with the conversion utilities - * for seamless frontend-backend communication. + * for seamless frontend-backend communication, including streaming responses. */ // Import generated types (from running generate_types.py) @@ -21,6 +21,40 @@ import { PaginatedRequest } from './conversion'; +// ============================ +// Streaming Types and Interfaces +// ============================ + +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; + 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; +} + +// ============================ +// Enhanced API Client Class +// ============================ + class ApiClient { private baseUrl: string; private defaultHeaders: Record; @@ -257,7 +291,7 @@ class ApiClient { } // ============================ - // Chat Methods + // Chat Methods (Enhanced with Streaming) // ============================ async createChatSession(context: Types.ChatContext): Promise { @@ -278,16 +312,186 @@ class ApiClient { return handleApiResponse(response); } - async sendMessage(sessionId: string, content: string): Promise { + /** + * Send message with standard response (non-streaming) + */ + async sendMessage(sessionId: string, query: Types.Query): Promise { const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest({ content })) + body: JSON.stringify(formatApiRequest({ query })) }); return handleApiResponse(response); } + /** + * Send message with streaming response support + */ + sendMessageStream( + sessionId: string, + query: Types.Query, + options: StreamingOptions = {} + ): StreamingResponse { + const abortController = new AbortController(); + const signal = options.signal || abortController.signal; + + let messageId = ''; + let accumulatedContent = ''; + let currentMessage: Partial = {}; + + const promise = new Promise(async (resolve, reject) => { + try { + const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages/stream`, { + method: 'POST', + headers: { + ...this.defaultHeaders, + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache' + }, + body: JSON.stringify(formatApiRequest({ query })), + signal + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Response body is not readable'); + } + + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.trim() === '') continue; + + try { + // Handle Server-Sent Events format + if (line.startsWith('data: ')) { + const data = line.slice(6); + + 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 + }; + + options.onComplete?.(finalMessage); + resolve(finalMessage); + return; + } + + 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); + // Continue processing other lines + } + } + } + } finally { + 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); + + } catch (error) { + if (signal.aborted) { + reject(new Error('Request was aborted')); + } else { + options.onError?.(error as Error); + reject(error); + } + } + }); + + return { + messageId, + cancel: () => abortController.abort(), + promise + }; + } + + /** + * Send message with automatic streaming detection + */ + async sendMessageAuto( + sessionId: string, + query: Types.Query, + options?: StreamingOptions + ): Promise { + // If streaming options are provided, use streaming + if (options && (options.onMessage || options.onPartialMessage || options.onStatusChange)) { + const streamResponse = this.sendMessageStream(sessionId, query, options); + return streamResponse.promise; + } + + // Otherwise, use standard response + return this.sendMessage(sessionId, query); + } + async getChatMessages(sessionId: string, request: Partial = {}): Promise> { const paginatedRequest = createPaginatedRequest(request); const params = toUrlParams(formatApiRequest(paginatedRequest)); @@ -333,16 +537,11 @@ class ApiClient { // Error Handling Helper // ============================ - // ============================ - // Error Handling Helper - // ============================ - async handleRequest(requestFn: () => Promise): Promise { try { const response = await requestFn(); return await handleApiResponse(response); } catch (error) { - // Log error for debugging console.error('API request failed:', error); throw error; } @@ -352,28 +551,153 @@ class ApiClient { // Utility Methods // ============================ - /** - * Update authorization token for future requests - */ setAuthToken(token: string): void { this.defaultHeaders['Authorization'] = `Bearer ${token}`; } - /** - * Remove authorization token - */ clearAuthToken(): void { delete this.defaultHeaders['Authorization']; } - /** - * Get current base URL - */ getBaseUrl(): string { return this.baseUrl; } } +// ============================ +// React Hooks for Streaming +// ============================ + +/* React Hook Examples for Streaming Chat +import { useState, useEffect, useCallback, useRef } from 'react'; + +export function useStreamingChat(sessionId: string) { + const [messages, setMessages] = useState([]); + const [currentMessage, setCurrentMessage] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(null); + + const apiClient = useApiClient(); + const streamingRef = useRef(null); + + const sendMessage = useCallback(async (query: Types.Query) => { + setError(null); + setIsStreaming(true); + setCurrentMessage(null); + + const streamingOptions: StreamingOptions = { + onMessage: (message) => { + setCurrentMessage(message); + }, + onPartialMessage: (content, messageId) => { + setCurrentMessage(prev => prev ? + { ...prev, content: prev.content + content } : + { + id: messageId || '', + sessionId, + status: 'streaming', + sender: 'ai', + content, + timestamp: new Date() + } + ); + }, + onStatusChange: (status) => { + setCurrentMessage(prev => prev ? { ...prev, status } : null); + }, + onComplete: (finalMessage) => { + setMessages(prev => [...prev, finalMessage]); + setCurrentMessage(null); + setIsStreaming(false); + }, + onError: (err) => { + setError(err.message); + setIsStreaming(false); + setCurrentMessage(null); + } + }; + + try { + streamingRef.current = apiClient.sendMessageStream(sessionId, query, streamingOptions); + await streamingRef.current.promise; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send message'); + setIsStreaming(false); + } + }, [sessionId, apiClient]); + + const cancelStreaming = useCallback(() => { + if (streamingRef.current) { + streamingRef.current.cancel(); + setIsStreaming(false); + setCurrentMessage(null); + } + }, []); + + return { + messages, + currentMessage, + isStreaming, + error, + sendMessage, + cancelStreaming + }; +} + +// Usage in React component: +function ChatInterface({ sessionId }: { sessionId: string }) { + const { + messages, + currentMessage, + isStreaming, + error, + sendMessage, + cancelStreaming + } = useStreamingChat(sessionId); + + const handleSendMessage = (text: string) => { + sendMessage(text); + }; + + return ( +
+
+ {messages.map(message => ( +
+ {message.sender}: {message.content} +
+ ))} + + {currentMessage && ( +
+ {currentMessage.sender}: {currentMessage.content} + {isStreaming && ...} +
+ )} +
+ + {error &&
{error}
} + +
+ { + if (e.key === 'Enter') { + handleSendMessage(e.currentTarget.value); + e.currentTarget.value = ''; + } + }} + disabled={isStreaming} + /> + {isStreaming && ( + + )} +
+
+ ); +} +*/ + // ============================ // Usage Examples // ============================ @@ -382,193 +706,54 @@ class ApiClient { // Initialize API client const apiClient = new ApiClient(); -// Login and set auth token +// Standard message sending (non-streaming) try { - const authResponse = await apiClient.login('user@example.com', 'password'); - apiClient.setAuthToken(authResponse.accessToken); - console.log('Logged in as:', authResponse.user); + const message = await apiClient.sendMessage(sessionId, 'Hello, how are you?'); + console.log('Response:', message.content); } catch (error) { - console.error('Login failed:', error); + console.error('Failed to send message:', error); } -// Create a new candidate +// Streaming message with callbacks +const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me a long story', { + onPartialMessage: (content, messageId) => { + console.log('Partial content:', content); + // 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 + }, + onError: (error) => { + console.error('Streaming error:', error); + // Handle error + } +}); + +// Can cancel the stream if needed +setTimeout(() => { + streamResponse.cancel(); +}, 10000); // Cancel after 10 seconds + +// Wait for completion try { - const newCandidate = await apiClient.createCandidate({ - email: 'candidate@example.com', - status: 'active', - firstName: 'John', - lastName: 'Doe', - skills: [], - experience: [], - education: [], - preferredJobTypes: ['full-time'], - location: { - city: 'San Francisco', - country: 'USA' - }, - languages: [], - certifications: [] - }); - console.log('Created candidate:', newCandidate); + const finalMessage = await streamResponse.promise; + console.log('Stream completed:', finalMessage); } catch (error) { - console.error('Failed to create candidate:', error); + console.error('Stream failed:', error); } -// Search for jobs -try { - const jobResults = await apiClient.searchJobs('software engineer', { - location: 'San Francisco', - experienceLevel: 'senior' - }); - - console.log(`Found ${jobResults.total} jobs:`); - jobResults.data.forEach(job => { - console.log(`- ${job.title} at ${job.location.city}`); - }); -} catch (error) { - console.error('Job search failed:', error); -} +// Auto-detection: streaming if callbacks provided, standard otherwise +await apiClient.sendMessageAuto(sessionId, 'Quick question', { + onPartialMessage: (content) => console.log('Streaming:', content) +}); // Will use streaming -// Get paginated candidates -try { - const candidates = await apiClient.getCandidates({ - page: 1, - limit: 10, - sortBy: 'createdAt', - sortOrder: 'desc', - filters: { - status: 'active', - skills: ['javascript', 'react'] - } - }); - - console.log(`Page ${candidates.page} of ${candidates.totalPages}`); - console.log(`${candidates.data.length} candidates on this page`); -} catch (error) { - console.error('Failed to get candidates:', error); -} - -// Start a chat session -try { - const chatSession = await apiClient.createChatSession({ - type: 'job_search', - aiParameters: { - name: 'Job Search Assistant', - model: 'gpt-4', - temperature: 0.7, - maxTokens: 2000, - topP: 0.95, - frequencyPenalty: 0.0, - presencePenalty: 0.0, - isDefault: false, - createdAt: new Date(), - updatedAt: new Date() - } - }); - - // Send a message - const message = await apiClient.sendMessage( - chatSession.id, - 'Help me find software engineering jobs in San Francisco' - ); - - console.log('AI Response:', message.content); -} catch (error) { - console.error('Chat session failed:', error); -} +await apiClient.sendMessageAuto(sessionId, 'Quick question'); // Will use standard */ -// ============================ -// React Hook Examples -// ============================ - -/* -// Custom hooks for React applications -import { useState, useEffect } from 'react'; - -export function useApiClient() { - const [client] = useState(() => new ApiClient(process.env.REACT_APP_API_URL || '')); - return client; -} - -export function useCandidates(request?: Partial) { - const [data, setData] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const apiClient = useApiClient(); - - useEffect(() => { - async function fetchCandidates() { - try { - setLoading(true); - setError(null); - const result = await apiClient.getCandidates(request); - setData(result); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch candidates'); - } finally { - setLoading(false); - } - } - - fetchCandidates(); - }, [request]); - - return { data, loading, error, refetch: () => fetchCandidates() }; -} - -export function useJobs(request?: Partial) { - const [data, setData] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const apiClient = useApiClient(); - - useEffect(() => { - async function fetchJobs() { - try { - setLoading(true); - setError(null); - const result = await apiClient.getJobs(request); - setData(result); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch jobs'); - } finally { - setLoading(false); - } - } - - fetchJobs(); - }, [request]); - - return { data, loading, error, refetch: () => fetchJobs() }; -} - -// Usage in React component: -function CandidateList() { - const { data: candidates, loading, error } = useCandidates({ - limit: 10, - sortBy: 'createdAt' - }); - - if (loading) return
Loading candidates...
; - if (error) return
Error: {error}
; - if (!candidates) return
No candidates found
; - - return ( -
-

Candidates ({candidates.total})

- {candidates.data.map(candidate => ( -
- {candidate.firstName} {candidate.lastName} - {candidate.email} -
- ))} - - {candidates.hasMore && ( - - )} -
- ); -} -*/ - -export { ApiClient }; \ No newline at end of file +export { ApiClient } +export type { StreamingOptions, StreamingResponse, ChatMessageChunk }; \ No newline at end of file diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 4728a77..4757638 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/models.py -// Generated on: 2025-05-28T20:34:39.642452 +// Source: src/backend/models.py +// Generated on: 2025-05-28T21:47:08.590102 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -17,6 +17,8 @@ export type ChatContextType = "job_search" | "candidate_screening" | "interview_ export type ChatSenderType = "user" | "ai" | "system"; +export type ChatStatusType = "partial" | "done" | "streaming" | "thinking" | "error"; + export type ColorBlindMode = "protanopia" | "deuteranopia" | "tritanopia" | "none"; export type DataSourceType = "document" | "website" | "api" | "database" | "internal"; @@ -127,7 +129,7 @@ export interface Attachment { export interface AuthResponse { accessToken: string; refreshToken: string; - user: BaseUser; + user: any; expiresAt: number; } @@ -148,7 +150,6 @@ export interface Authentication { export interface BaseUser { id?: string; - username: string; email: string; phone?: string; createdAt: Date; @@ -160,7 +161,6 @@ export interface BaseUser { export interface BaseUserWithType { id?: string; - username: string; email: string; phone?: string; createdAt: Date; @@ -173,7 +173,6 @@ export interface BaseUserWithType { export interface Candidate { id?: string; - username: string; email: string; phone?: string; createdAt: Date; @@ -182,6 +181,7 @@ export interface Candidate { profileImage?: string; status: "active" | "inactive" | "pending" | "banned"; userType?: "candidate"; + username: string; firstName: string; lastName: string; fullName: string; @@ -250,6 +250,7 @@ export interface ChatContext { export interface ChatMessage { id?: string; sessionId: string; + status: "partial" | "done" | "streaming" | "thinking" | "error"; sender: "user" | "ai" | "system"; senderId?: string; content: string; @@ -320,7 +321,6 @@ export interface Education { export interface Employer { id?: string; - username: string; email: string; phone?: string; createdAt: Date; diff --git a/src/backend/models.py b/src/backend/models.py index 81d5de4..f40d84a 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -67,6 +67,13 @@ class ChatSenderType(str, Enum): AI = "ai" SYSTEM = "system" +class ChatStatusType(str, Enum): + PARTIAL = "partial" + DONE = "done" + STREAMING = "streaming" + THINKING = "thinking" + ERROR = "error" + class ChatContextType(str, Enum): JOB_SEARCH = "job_search" CANDIDATE_SCREENING = "candidate_screening" @@ -544,6 +551,7 @@ class ChatContext(BaseModel): class ChatMessage(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") content: str