406 lines
14 KiB
JavaScript
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,
|
|
};
|
|
}
|