Files
krow_talent_app/src/components/ai-assistant/useAssistant.js
2026-08-13 21:41:14 +05:30

406 lines
14 KiB
JavaScript

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,
};
}