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 { skillsForContext } from '@/lib/skills/registry'; import { advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply, } from '@/lib/skills/positionFlow'; import { assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed, } from '@/lib/skills/workforceFlow'; import { buildFacts } from './insights'; import { createAssistantProvider } from './provider'; import { resolveIntent } from './routing'; import { toSnapshots } from './blocks'; /** 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 }); /* The signed-in account, from the shared `['user']` cache the header and the Profile page already read. */ const { data: user = null } = useQuery({ queryKey: ['user'], queryFn: () => base44.auth.me().catch(() => null), staleTime: Infinity, }); return React.useMemo( () => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }), [applications, postings, interviews, staff, profiles, activity, courses, profile, user] ); } /** * 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 || ''; } /** * Streams a locally built document through the same snapshot pipeline the * provider uses, so a routing reply arrives exactly like any other answer * rather than appearing instantly and reading as a different system. */ async function streamDocument({ document, signal, onFrame, onDone }) { const snapshots = toSnapshots(document); const complete = snapshots[snapshots.length - 1] || []; let latest = []; let interrupted = false; for (const snapshot of snapshots) { if (signal?.aborted) { interrupted = true; break; } latest = snapshot; onFrame(snapshot); await new Promise((resolve) => setTimeout(resolve, 26)); } /** * An interrupted local document still settles complete. * * These documents are already fully computed before the first frame — the * reveal is presentation, not generation. So an interruption should not leave * a half-written sentence in the thread the way stopping a real generation * would: there is nothing partial about the answer, only about the animation. * * This matters most after a write. The mutation refreshes every query it * touched, the panel re-renders on the new data, and the reveal can be cut * short — leaving the confirmation of something that definitely happened * truncated mid-word. */ if (interrupted) onDone(complete); else if (latest.length) onDone(latest); } /** * 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. * * `onNavigate` is called when a question belongs to another Admin page. Both * entry points — a suggestion chip and a typed question — go through `send`, so * routing applies to both without either knowing it exists. */ export function useConversation({ contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview, workforce = null, disabledSkills = [], customSkills = [], roles = [], skillCategories = [], courses = [], }) { const storageKey = `krow_assistant:${contextId}`; const flowKey = `${storageKey}:flow`; const [messages, setMessages] = React.useState([]); const [pending, setPending] = React.useState(null); // { blocks, thinking } const [error, setError] = React.useState(null); const abortRef = React.useRef(null); /* A guided skill in progress — which question it is on and what it has collected. Kept beside the thread rather than inside it, and persisted for the same reason the thread is: leaving Positions and coming back should not silently abandon a position half-described. */ const flowRef = React.useRef(null); const setFlow = React.useCallback((flow) => { flowRef.current = flow || null; try { if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow)); else sessionStorage.removeItem(flowKey); } catch { // The flow stays in memory only. } }, [flowKey]); /* 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([]); } try { const raw = sessionStorage.getItem(flowKey); flowRef.current = raw ? JSON.parse(raw) : null; } catch { flowRef.current = null; } setPending(null); setError(null); }, [storageKey, flowKey]); 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 }); /** * A guided skill in progress takes the message first: while Owliver is * asking the questions, "Chennai" is an answer rather than a question about * the page. The skill has to still be enabled — switching it off in Settings * ends the flow rather than letting it run on without permission. */ let intent = null; if (flowRef.current) { const skill = skillsForContext(contextId, disabledSkills, customSkills) .find((s) => s.id === flowRef.current.skillId); if (!skill) setFlow(null); else { intent = { kind: 'flow', skill, ...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }), }; } } /* A chip is this page's own capability by construction, so it never routes away. A typed question might belong somewhere else entirely. */ if (!intent) { intent = capability ? { kind: 'answer' } : resolveIntent({ question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses, workforce, }); } /** * The one step that writes. It happens before the reply rather than after, * because the reply is the outcome — "Position created successfully" has to * be true when it is said. */ if (intent.kind === 'flow' && intent.create) { let created = null; try { created = await onCreatePosition?.(intent.create.draft, intent.skill); } catch { created = null; } if (created?.id) { intent = { ...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created), }; } else { /* Keep the answers: the summary is still there to try again from. */ intent = { ...intent, flow: { ...intent.flow, stage: 'review' }, doc: positionFailedReply(), followUp: [ { label: 'Create position', prompt: 'Create position' }, { label: 'Change details', prompt: 'Change details' }, ], }; } } /** * The workforce write, on the same terms as the one above: it happens * before the reply, because the reply states the new counts and those have * to be true when they are read. * * `intent.assign` only exists on a turn the admin explicitly confirmed, and * the plan inside it was re-derived from live data at that moment — so this * writes what was agreed to or reports that it could not. */ if (intent.kind === 'workforce' && intent.assign) { let done = null; try { done = await onAssignWorkers?.(intent.assign); } catch { done = null; } intent = { ...intent, kind: 'answer-doc', doc: done ? assignmentDone(intent.assign) : assignmentFailed(), followUp: done ? assignmentFollowUp(intent.assign) : undefined, }; } /** * The interview step. Same contract as the assignment above: it runs before * the reply, only on a confirmed turn, and through the app's existing * status transition rather than a second interview store. */ if (intent.kind === 'workforce' && intent.interview) { let moved = null; try { moved = await onScheduleInterview?.(intent.interview); } catch { moved = null; } intent = { ...intent, kind: 'answer-doc', doc: moved ? interviewDone(intent.interview) : interviewFailed(), }; } /* A skill that collects its input in the chat carries the state of that collection on every turn — including the turn that starts it. */ if ('flow' in intent) setFlow(intent.flow); if (intent.kind !== 'answer') { try { await streamDocument({ document: intent.doc, signal: controller.signal, onFrame: (blocks) => setPending({ blocks, thinking: false }), onDone: (blocks) => { const next = [ ...messagesRef.current, { role: 'assistant', blocks, followUp: intent.followUp }, ]; messagesRef.current = next; persist(next); }, }); } finally { setPending(null); abortRef.current = null; } /* Navigate after the reply is on screen, so the user reads why they moved. The panel re-resolves its context from the new route, which is what makes the next question answer from the page they land on. */ if (controller.signal.aborted) return; if (intent.kind === 'navigate') onNavigate?.(intent.destination); /* A skill's action runs after its reply, for the same reason: the user should read why the form opened before it opens. */ if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill); return; } 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, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview, workforce, setFlow, disabledSkills, customSkills, roles, skillCategories, courses]); const stop = React.useCallback(() => abortRef.current?.abort(), []); const reset = React.useCallback(() => { abortRef.current?.abort(); messagesRef.current = []; setMessages([]); setPending(null); setError(null); /* A new conversation abandons a half-collected position too — the questions it was answering are no longer on screen. */ setFlow(null); try { sessionStorage.removeItem(storageKey); } catch { // Ignore. } }, [storageKey, setFlow]); return { messages, pending, error, busy: Boolean(pending), send, stop, reset, providerId: provider.id, }; }