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,139 @@
/**
* Provider seam for the Krow AI Assistant.
*
* The UI never generates an answer and never knows where one came from. It calls
* `provider.stream(request)` and renders the chunks. That single boundary is
* what makes the backend swappable: today a local provider computes answers from
* dashboard data, and later an HTTP provider can call a real service without a
* single change to the component.
*
* ── Contract ────────────────────────────────────────────────────────────────
*
* provider.id: string
* provider.stream(request): AsyncIterable<string> // yields text deltas
*
* request = {
* contextId: string, // e.g. 'employer.overview'
* capability: string | null, // capability id, or null for free text
* question: string, // what the user typed, or the capability label
* facts: object, // the dashboard fact sheet (see insights.js)
* signal: AbortSignal, // aborts an in-flight response
* }
*
* Deltas rather than a whole string is deliberate: it is the shape every
* streaming completion API returns, so the local provider and a future remote
* one are interchangeable without the UI learning a second pattern.
*/
import { getContext } from './contexts';
/** Words per delta. Chunking by word keeps reflow to once per word. */
const WORDS_PER_CHUNK = 3;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Local provider — answers computed from the dashboard's own data.
*
* Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable.
*/
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
return {
id: 'local',
async *stream({ contextId, capability, question, facts, signal }) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
const answer = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? context.respond(question, facts)
: context.respond(question, facts);
// A brief pause before the first delta, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
const tokens = String(answer).split(/(\s+)/);
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
if (signal?.aborted) return;
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
await sleep(chunkDelay);
}
},
};
}
/**
* HTTP provider — the path to a real backend.
*
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
* yields the text deltas. Unused today; it exists so the shape of the
* integration is settled rather than guessed at later. Swapping providers is a
* one-line change in `AssistantProvider`.
*
* The request body sends `contextId`, `capability` and `question` — not the
* fact sheet. Dashboard data should be fetched server-side from the caller's own
* session rather than posted from the browser, so the client cannot ask about
* records it is not entitled to see.
*/
export function createHttpProvider({ endpoint, headers = {} }) {
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
return {
id: 'http',
async *stream({ contextId, capability, question, signal }) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ contextId, capability, question }),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Assistant request failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// The final element may be a partial line; keep it for the next read.
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsed = JSON.parse(payload);
if (parsed.delta) yield parsed.delta;
} catch {
// A non-JSON data line is treated as a raw delta.
yield payload;
}
}
}
},
};
}
/**
* The provider the app uses.
*
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
* switch, with no other code change.
*/
export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
}