Initial commit
This commit is contained in:
156
src/components/ai-assistant/useAssistant.js
Normal file
156
src/components/ai-assistant/useAssistant.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
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({ needsWorkerProfile = false } = {}) {
|
||||
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();
|
||||
|
||||
// Gated: this query creates the record if absent, so it must not run for
|
||||
// employer or admin sessions.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile });
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({
|
||||
applications, postings, interviews, staff, profiles, activity, courses,
|
||||
profile: needsWorkerProfile ? profile : null,
|
||||
}),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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); // { question, text, 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({ question: text, text: '', thinking: true });
|
||||
|
||||
try {
|
||||
let assembled = '';
|
||||
for await (const delta of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
assembled += delta;
|
||||
setPending({ question: text, text: assembled, thinking: false });
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
// Keep whatever streamed before the user stopped it — discarding a
|
||||
// half-written answer loses information the user was already reading.
|
||||
if (assembled) {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} else {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user