import * as React from 'react'; import { useQuery } from '@tanstack/react-query'; import { base44 } from '@/api/base44Client'; import { useApplications, useCourses, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfile, useWorkerProfiles, } from '@/lib/krowHooks'; import { buildFacts } from './insights'; import { createAssistantProvider } from './provider'; /** One provider instance for the app's lifetime. */ const provider = createAssistantProvider(); /** * Reads the same React Query caches the pages render from, so the assistant * costs no extra requests and cannot be looking at a different snapshot than the * card beside it. */ export function useAssistantFacts() { const { data: applications = [] } = useApplications(); const { data: postings = [] } = useJobPostings(); const { data: interviews = [] } = useInterviews(); const { data: staff = [] } = useStaff(); const { data: profiles = [] } = useWorkerProfiles(); const { data: activity = [] } = useUserActivity(); const { data: courses = [] } = useCourses(); // The signed-in worker's own record, read from cache only: `enabled: false` // means this never runs the query, which would *create* a WorkerProfile for an // admin who has never opened Forge. So the Forge context is personal on pages // that already loaded it, and falls back to library-level facts elsewhere // rather than manufacturing a record as a side effect of opening a panel. const { data: profile = null } = useWorkerProfile({ enabled: false }); return React.useMemo( () => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }), [applications, postings, interviews, staff, profiles, activity, courses, profile] ); } /** * The signed-in user's first name, for personalizing the greeting. * * Shares the `['user']` query key with the rest of the app, so this is a cache * read rather than another request. */ export function useCurrentUserName() { const { data } = useQuery({ queryKey: ['user'], queryFn: () => base44.auth.me().catch(() => null), staleTime: Infinity, }); return data?.full_name || ''; } /** * Conversation state for one page context. * * Owns the thread, the in-flight response, and abort handling. The thread is * kept per context in sessionStorage so navigating away from Candidates and back * does not silently discard the conversation — while a stale thread from last * week is still not resurrected. */ export function useConversation({ contextId, facts }) { const storageKey = `krow_assistant:${contextId}`; const [messages, setMessages] = React.useState([]); const [pending, setPending] = React.useState(null); // { blocks, thinking } const [error, setError] = React.useState(null); const abortRef = React.useRef(null); /* Load the stored thread, and reload when the context changes. */ React.useEffect(() => { try { const raw = sessionStorage.getItem(storageKey); setMessages(raw ? JSON.parse(raw) : []); } catch { setMessages([]); } setPending(null); setError(null); }, [storageKey]); const persist = React.useCallback((next) => { setMessages(next); try { sessionStorage.setItem(storageKey, JSON.stringify(next)); } catch { // Quota or private mode — the thread stays in memory only. } }, [storageKey]); /* Abort any in-flight response when the context changes or we unmount. */ React.useEffect(() => () => abortRef.current?.abort(), [storageKey]); // The stream callback runs outside render, so it needs the latest thread // rather than the value captured when the request started. const messagesRef = React.useRef(messages); messagesRef.current = messages; const send = React.useCallback(async ({ question, capability = null }) => { const text = String(question).trim(); if (!text) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; const withUser = [...messagesRef.current, { role: 'user', text }]; messagesRef.current = withUser; persist(withUser); setError(null); setPending({ blocks: [], thinking: true }); try { let latest = []; for await (const snapshot of provider.stream({ contextId, capability, question: text, facts, signal: controller.signal, })) { if (controller.signal.aborted) break; latest = snapshot; setPending({ blocks: snapshot, thinking: false }); } // Keep whatever arrived before the user stopped it — discarding a // half-written answer loses what they were already reading. Stopping // before the first block, though, should leave no empty turn behind. if (latest.length) { const next = [ ...messagesRef.current, { role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined }, ]; messagesRef.current = next; persist(next); } } catch (e) { if (e?.name !== 'AbortError') { setError('I could not complete that. Try again in a moment.'); } } finally { setPending(null); abortRef.current = null; } }, [contextId, facts, persist]); const stop = React.useCallback(() => abortRef.current?.abort(), []); const reset = React.useCallback(() => { abortRef.current?.abort(); messagesRef.current = []; setMessages([]); setPending(null); setError(null); try { sessionStorage.removeItem(storageKey); } catch { // Ignore. } }, [storageKey]); return { messages, pending, error, busy: Boolean(pending), send, stop, reset, providerId: provider.id, }; }