172 lines
5.6 KiB
JavaScript
172 lines
5.6 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 { 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,
|
|
};
|
|
}
|