Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

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