Files
krow_talent_app/src/components/ai-assistant/useAssistant.js
2026-08-11 19:17:40 +05:30

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