update chatbox
This commit is contained in:
@@ -2,33 +2,32 @@
|
||||
* 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
|
||||
* `provider.stream(request)` and renders the snapshots. 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.
|
||||
* change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* provider.id: string
|
||||
* provider.stream(request): AsyncIterable<string> // yields text deltas
|
||||
* provider.stream(request): AsyncIterable<Block[]>
|
||||
*
|
||||
* Each yielded value is the response *so far* as an array of blocks (see
|
||||
* blocks.js). Snapshots rather than deltas because a response is structured:
|
||||
* a table or a KPI row has no meaningful half-state, and the renderer stays a
|
||||
* pure function of the latest snapshot.
|
||||
*
|
||||
* 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)
|
||||
* question: string, // what the user typed, or the chip's prompt
|
||||
* facts: object, // 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;
|
||||
import { toSnapshots } from './blocks';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -38,7 +37,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
* 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 } = {}) {
|
||||
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
|
||||
return {
|
||||
id: 'local',
|
||||
|
||||
@@ -46,21 +45,20 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
||||
const context = getContext(contextId);
|
||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||
|
||||
const answer = capability
|
||||
const document = 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
|
||||
// A brief pause before the first frame, 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) {
|
||||
for (const snapshot of toSnapshots(document)) {
|
||||
if (signal?.aborted) return;
|
||||
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
|
||||
await sleep(chunkDelay);
|
||||
yield snapshot;
|
||||
await sleep(frameDelay);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -69,15 +67,15 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
||||
/**
|
||||
* 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`.
|
||||
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
|
||||
* A line may carry a whole block (`{ block: … }`) or a text delta
|
||||
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
|
||||
* it exists so the shape of the integration is settled rather than guessed at.
|
||||
*
|
||||
* 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.
|
||||
* The request body sends `contextId`, `capability` and `question` — not the fact
|
||||
* sheet. Dashboard data should be read 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');
|
||||
@@ -99,28 +97,37 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const blocks = [];
|
||||
let buffer = '';
|
||||
|
||||
const appendDelta = (delta) => {
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last?.type === 'text') last.text += delta;
|
||||
else blocks.push({ type: 'text', text: delta });
|
||||
};
|
||||
|
||||
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.
|
||||
// The final element may be a partial line; hold 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;
|
||||
if (parsed.block) blocks.push(parsed.block);
|
||||
else if (parsed.delta) appendDelta(parsed.delta);
|
||||
} catch {
|
||||
// A non-JSON data line is treated as a raw delta.
|
||||
yield payload;
|
||||
appendDelta(payload);
|
||||
}
|
||||
yield [...blocks];
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -128,10 +135,9 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses.
|
||||
*
|
||||
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
|
||||
* switch, with no other code change.
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user