refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules
The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.
79 errors, and two optional markers cleared 63 of them.
`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.
Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.
The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.
Measured against `dde4ba6`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 83/83 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
758
src/components/ai-assistant/provider.ts
Normal file
758
src/components/ai-assistant/provider.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
/**
|
||||
* Provider seam for the Owliver dashboard panel.
|
||||
*
|
||||
* The UI never generates an answer and never knows where one came from. It calls
|
||||
* `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
|
||||
* change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* provider.id: string
|
||||
* 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 chip's prompt
|
||||
* facts: object, // dashboard fact sheet (see insights.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*/
|
||||
|
||||
import { confirmation, note } from './blocks';
|
||||
|
||||
/**
|
||||
* The agent provider: the real runtime, over the real API.
|
||||
*
|
||||
* The only provider that answers. Everything that makes that safe lives on the
|
||||
* server:
|
||||
*
|
||||
* - **The principal is the session's.** The body carries a question and
|
||||
* nothing else about who is asking. The browser cannot name a caller, so it
|
||||
* cannot ask about records it is not entitled to see.
|
||||
* - **The tools and corpora are the SPEC's.** Not the request's. An agent
|
||||
* reads what its published definition says it may read, and no field here
|
||||
* can widen that.
|
||||
* - **A write cannot happen from a question.** The backend answers a proposed
|
||||
* write with a confirmation payload and performs nothing. Approving it is a
|
||||
* second, explicit call carrying the token — see `confirmation` below.
|
||||
*
|
||||
* One snapshot, not a stream. The endpoint is not streaming yet, so this yields
|
||||
* the finished answer once. The signature is the streaming one because that is
|
||||
* what the seam is, and switching to real streaming later changes this function
|
||||
* and nothing else.
|
||||
*/
|
||||
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
id: 'agent',
|
||||
|
||||
/**
|
||||
* Every snapshot leaves through here, and every snapshot is sanitised.
|
||||
*
|
||||
* The wrapper is the point. Below it there are four ways a response gets
|
||||
* built — streamed deltas, a completed run, a bounded run's trailing
|
||||
* message, and the two failure notes — and only one of them passes through
|
||||
* the markdown parser that removes citation ids. Sanitising at the yield
|
||||
* rather than at each construction means a fifth way, added later, cannot
|
||||
* reintroduce the leak by forgetting a call.
|
||||
*/
|
||||
async *stream(request) {
|
||||
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
|
||||
},
|
||||
|
||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
if (!agent?.id) {
|
||||
yield [note('No agent is available for this page.')];
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
/* Ask for a stream. The server answers the same run either way — the
|
||||
final event carries exactly the body the non-streaming path
|
||||
returns — so a deployment that cannot stream degrades to one late
|
||||
snapshot rather than to a broken panel. */
|
||||
Accept: 'text/event-stream, application/json',
|
||||
},
|
||||
/* The session cookie. Without it the API answers 401, which is the
|
||||
correct answer to a browser that is not signed in. */
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
input: question,
|
||||
confirmation: confirmation || undefined,
|
||||
/* Pins the conversation to the version it started with. Every answer
|
||||
comes back carrying its version; sending it on the next turn is what
|
||||
stops an edit published mid-thread from silently changing which
|
||||
agent is answering. */
|
||||
agentVersion: agentVersion || undefined,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
yield [note(await describeFailure(response))];
|
||||
return;
|
||||
}
|
||||
|
||||
/* Not a stream after all — a proxy that buffers, or a server answering
|
||||
JSON. Read it whole. */
|
||||
if (!isEventStream(response) || !response.body) {
|
||||
yield toBlocks(await response.json());
|
||||
return;
|
||||
}
|
||||
|
||||
yield* readRunStream(response, signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server actually opened a stream, rather than answering JSON.
|
||||
*
|
||||
* Defensive about `headers` because the answer to "is this a stream" must be
|
||||
* NO when anything is unexpected. A response shape this does not recognise gets
|
||||
* read whole, which works; assuming a stream and finding none would hang.
|
||||
*/
|
||||
function isEventStream(response) {
|
||||
const type = response?.headers?.get?.('Content-Type') || '';
|
||||
return type.includes('text/event-stream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the run's event stream, yielding the answer as it grows.
|
||||
*
|
||||
* Two kinds of event and they are handled very differently:
|
||||
*
|
||||
* - `delta` is a fragment of assistant text. The accumulated text is
|
||||
* re-parsed into blocks on every one, so a half-written answer renders as
|
||||
* far as it makes sense to — a table mid-construction stays as text until
|
||||
* its rows arrive, which reads better than a broken table.
|
||||
* - `run` is the finished result: the same body the non-streaming path
|
||||
* returns, carrying confirmations, the termination and the token cost.
|
||||
* Whatever text streamed is replaced by it, because the final snapshot is
|
||||
* authoritative and the deltas were a preview of it.
|
||||
*
|
||||
* A client that ignored every delta and read only the last event would be in
|
||||
* exactly the state it would have reached without streaming. That is what keeps
|
||||
* the two paths honest rather than merely similar.
|
||||
*/
|
||||
async function* readRunStream(response, signal) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let buffer = '';
|
||||
let text = '';
|
||||
let final = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
/* The last element may be half a 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;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(payload);
|
||||
} catch {
|
||||
/* A malformed frame is dropped rather than rendered. It cannot be
|
||||
assistant text — the server encodes every event as JSON — so
|
||||
showing it would put transport noise in front of a reader. */
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof event.delta === 'string') {
|
||||
text += event.delta;
|
||||
/* Still arriving: the frontier rules apply, so a citation split
|
||||
across two frames is never rendered half-written. */
|
||||
yield markdownToBlocks(text, { partial: true });
|
||||
continue;
|
||||
}
|
||||
if (event.run) final = event.run;
|
||||
if (event.error) {
|
||||
yield [note(event.error.message || 'The agent could not be reached.')];
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) break;
|
||||
}
|
||||
} finally {
|
||||
/* Releasing matters on an abort: a reader still holding the body keeps the
|
||||
connection open, and a user who pressed Stop expects it to stop. */
|
||||
try { reader.cancel(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
if (final) {
|
||||
yield toBlocks(final);
|
||||
return;
|
||||
}
|
||||
/* The stream ended without a final event — the run was aborted, or the
|
||||
connection dropped mid-answer. Whatever arrived is kept: a half-read answer
|
||||
is worth more to the reader than an empty panel. */
|
||||
if (text) yield markdownToBlocks(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a run result into blocks.
|
||||
*
|
||||
* The termination decides the shape, and every one of the six produces
|
||||
* something a person can act on. A run that ended without completing is not an
|
||||
* error to swallow: it has an answer-so-far worth keeping and a reason worth
|
||||
* reading.
|
||||
*/
|
||||
function toBlocks(run) {
|
||||
const blocks = [];
|
||||
|
||||
if (run.output) blocks.push(...markdownToBlocks(run.output));
|
||||
|
||||
/* Pending writes before the trailing note: somebody scrolling to the bottom
|
||||
should meet the decision, not a footnote about token cost. */
|
||||
for (const c of run.confirmations || []) {
|
||||
blocks.push(confirmation(c));
|
||||
}
|
||||
|
||||
/* The surface layer's wording for a run that did not complete. Rendered as a
|
||||
note rather than as prose, so it reads as the system speaking rather than
|
||||
as the agent's own words. */
|
||||
if (run.message) blocks.push(note(run.message));
|
||||
|
||||
if (!blocks.length) {
|
||||
blocks.push(note('The agent finished without saying anything.'));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* ── Citations ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Why any of this exists.
|
||||
*
|
||||
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
|
||||
* <source> carries an id: cite it when you use what it says", and the
|
||||
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
|
||||
* model picks a format, and picks a different one on a different day. The ids
|
||||
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
|
||||
* them whole or truncated to their first block.
|
||||
*
|
||||
* The panel has no citation surface to render any of that into, so whatever
|
||||
* shape the model chose arrives on screen as raw markup. The formats seen so
|
||||
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
|
||||
* below are written against the SHAPE of an identifier rather than against
|
||||
* either format's syntax, so a third spelling of the same idea is far more
|
||||
* likely to be caught than to be a new bug.
|
||||
*
|
||||
* A citation id is hex and dashes — a UUID or a leading run of one. That is
|
||||
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
|
||||
* counts in prose are not addresses, are never inside citation syntax, and no
|
||||
* rule here looks at a bare number.
|
||||
*/
|
||||
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
|
||||
|
||||
/**
|
||||
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
|
||||
*
|
||||
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
|
||||
* written against the ones in today's output would let tomorrow's through.
|
||||
*/
|
||||
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
|
||||
|
||||
/**
|
||||
* The link spelling, and the brackets the model wraps a run of them in —
|
||||
* `([337042b3](#), [2b94bc43](#))`.
|
||||
*
|
||||
* Identified by two conditions TOGETHER, never either alone: the target must be
|
||||
* a bare `#` anchor, AND the label must look like an identifier rather than
|
||||
* words. A real link has a real href, a real anchor link has a destination
|
||||
* after the `#`, and a link a person would click has a label they could read.
|
||||
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
|
||||
* and even `[Read more](#)` on screen.
|
||||
*
|
||||
* The group is removed whole rather than link by link, because removing them
|
||||
* one at a time leaves `(, )` behind — which reads worse than the ids did.
|
||||
*/
|
||||
const CITATION_GROUP = new RegExp(
|
||||
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
|
||||
|
||||
/**
|
||||
* The prose spelling: the model narrating the attribute rather than marking it
|
||||
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
|
||||
*
|
||||
* Why this exists is the same reason the other two do. `context.go` hands the
|
||||
* model `<source id="…">` and tells it to cite the id without saying how, so
|
||||
* the model reaches for whatever syntax feels natural that day. This one is not
|
||||
* markup at all — it is the id written out in a parenthesis, which is why no
|
||||
* tag rule and no link rule saw it.
|
||||
*
|
||||
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
|
||||
* whole rule and it has to survive future edits: a worker's record id is the
|
||||
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
|
||||
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
|
||||
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
|
||||
* parenthesis containing a reference word and nothing but ids, which is not a
|
||||
* shape prose takes for any other reason.
|
||||
*
|
||||
* Backticks are optional on each side independently, because a model that opens
|
||||
* a code span and forgets to close it before the bracket must not defeat this.
|
||||
*/
|
||||
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
|
||||
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
|
||||
const CITATION_LABELLED = new RegExp(
|
||||
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
|
||||
*
|
||||
* A markdown link is `[label](target)`; a bracket holding only identifiers is
|
||||
* not a link and is not something prose does. The lookahead leaves anything
|
||||
* followed by `(` to the link rules, so a genuine link whose label happens to
|
||||
* be a reference number keeps its destination and stays on screen.
|
||||
*/
|
||||
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
|
||||
word to disambiguate it, so the id itself has to carry the evidence: at least
|
||||
eight hex characters, or a dashed group. Without that `[2026]` is four hex
|
||||
digits and a year in brackets would disappear from an answer. */
|
||||
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
|
||||
const CITATION_BRACKETED = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Whatever is still arriving at the end of the text.
|
||||
*
|
||||
* The streaming half of the problem, and it is a real one rather than a
|
||||
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
|
||||
* every delta, so a citation split across two frames is a state the reader can
|
||||
* see. `…before a first shift ([3370` renders for as long as the next delta
|
||||
* takes to arrive.
|
||||
*
|
||||
* Both rules are anchored to the end of the text, so they can only ever
|
||||
* describe the frontier of the stream and never something the answer has
|
||||
* already moved past. The fragment is held back until it completes, at which
|
||||
* point the rules above remove it properly — which is buffering, expressed as
|
||||
* a parse rather than as a second copy of the text.
|
||||
*/
|
||||
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
|
||||
const CITATION_LINK_PARTIAL = new RegExp([
|
||||
/* An open bracket holding at least one COMPLETE citation and not yet closed:
|
||||
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
|
||||
complete link is removed by the rule above and the `(` is stranded. */
|
||||
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
|
||||
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
|
||||
`[337042b3](`, `[337042b3](#`. */
|
||||
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
|
||||
].join('|'), 'i');
|
||||
|
||||
/**
|
||||
* The same two, part-written.
|
||||
*
|
||||
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
|
||||
* stream re-parses everything on every delta. Both are anchored to the end, so
|
||||
* they describe only the frontier.
|
||||
*
|
||||
* The labelled rule accepts any short leading word rather than only a reference
|
||||
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
|
||||
* everything. The cost is that an ordinary parenthetical is held back for the
|
||||
* frames between its bracket and its first non-hex character —
|
||||
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
|
||||
* A parenthesis arriving a frame late is not something a reader can notice; a
|
||||
* half-written reference id is exactly what they reported.
|
||||
*/
|
||||
const CITATION_LABELLED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
|
||||
'i'
|
||||
);
|
||||
const CITATION_BRACKETED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
|
||||
'i'
|
||||
);
|
||||
|
||||
/**
|
||||
* What a lifted citation leaves behind.
|
||||
*
|
||||
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
|
||||
* is not, and a sentence ending in an empty bracket reads as broken markup
|
||||
* rather than as a clean sentence. Applied after the removals, never before.
|
||||
*/
|
||||
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
|
||||
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
|
||||
const DOUBLED_SPACES = / {2,}/g;
|
||||
|
||||
/**
|
||||
* Removes citation markup, keeping the sentence inside it.
|
||||
*
|
||||
* The wrapper is addressing, not content — it tells a client which retrieved
|
||||
* chunk a claim came from — and with nowhere to render it the honest move is to
|
||||
* show the claim and drop the envelope. The backend's citation metadata is
|
||||
* untouched: it is still on the run, still in the trajectory, and this only
|
||||
* decides what reaches a reader.
|
||||
*
|
||||
* Content is never altered, only the wrapper around it, so markdown inside a
|
||||
* citation — bold, a bullet, a table row — parses exactly as it would have
|
||||
* unwrapped.
|
||||
*
|
||||
* If the panel ever grows a real citation affordance, this is the seam: parse
|
||||
* the ids out here into a block the renderer can draw, rather than discarding
|
||||
* them. Nothing else has to move.
|
||||
*/
|
||||
export function stripCitations(markdown, { partial = false } = {}) {
|
||||
let out = String(markdown ?? '')
|
||||
.replace(CITATION_TAG, '')
|
||||
.replace(CITATION_GROUP, '')
|
||||
.replace(CITATION_LABELLED, '')
|
||||
.replace(CITATION_BRACKETED, '');
|
||||
|
||||
/**
|
||||
* The frontier rules, and ONLY while there is a frontier.
|
||||
*
|
||||
* They describe something that is still being written, so they are wrong to
|
||||
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
|
||||
* bracket holding two hex characters, which is indistinguishable from the
|
||||
* first two characters of an id still arriving. Mid-stream, holding it back
|
||||
* for a frame is right. At the end of a completed answer there is nothing
|
||||
* more coming, the bracket is all there will ever be, and removing it deletes
|
||||
* a footnote marker from the reader's answer.
|
||||
*
|
||||
* The caller knows which it is: `readRunStream` passes `partial` on a delta
|
||||
* and not on the final snapshot. That is the only place the distinction
|
||||
* exists, so it is the only place it can be made.
|
||||
*
|
||||
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
|
||||
* an unclosed bracket — strip the link first and `(,` is left on screen,
|
||||
* which is the broken bracket this exists to prevent. Matching the
|
||||
* unterminated group first takes the whole fragment.
|
||||
*/
|
||||
if (partial) {
|
||||
out = out
|
||||
.replace(CITATION_TAG_PARTIAL, '')
|
||||
.replace(CITATION_LINK_PARTIAL, '')
|
||||
.replace(CITATION_LABELLED_PARTIAL, '')
|
||||
.replace(CITATION_BRACKETED_PARTIAL, '');
|
||||
}
|
||||
|
||||
return out
|
||||
.replace(CITATION_LINK, '')
|
||||
.replace(EMPTY_PARENS, '')
|
||||
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
|
||||
.replace(DOUBLED_SPACES, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that carry text a person reads, on any block.
|
||||
*
|
||||
* An allow-list rather than a deny-list, because the two mistakes do not cost
|
||||
* the same: missing a display field leaks an id, while sanitising an address
|
||||
* field would corrupt a confirmation token, a route or a record id and break
|
||||
* what it points at. A new block type gets its display keys covered for free; a
|
||||
* new addressing key is safe by default.
|
||||
*/
|
||||
const DISPLAY_KEYS = new Set([
|
||||
'text', 'sub', 'label', 'title', 'summary', 'caption',
|
||||
'description', 'detail', 'note', 'heading', 'hint',
|
||||
]);
|
||||
|
||||
/** Keys whose value is a list of sentences rather than one. */
|
||||
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
|
||||
|
||||
/**
|
||||
* Citation-proofs a whole response, whatever shape it arrived in.
|
||||
*
|
||||
* `markdownToBlocks` strips the model's markdown, and for a completed answer
|
||||
* that is the whole story. It is NOT the whole story for the response: the same
|
||||
* provider also emits `note(run.message)` when a run did not complete,
|
||||
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
|
||||
* carrying server wording composed around model-supplied arguments. None of
|
||||
* those go through the markdown parser, so each was a way for an id to reach
|
||||
* the DOM without passing the one place that removes them.
|
||||
*
|
||||
* Rather than a `stripCitations` call at each — three sites today, and a fourth
|
||||
* the next time the provider learns to say something — every block the agent
|
||||
* provider yields goes through here.
|
||||
*
|
||||
* Walks recursively so nested shapes are reached (a table's rows, a
|
||||
* confirmation's warnings, an insight's items) and touches only the keys above:
|
||||
* `token`, `id`, `route`, `to` and everything else addressing-like is left
|
||||
* exactly as the server sent it.
|
||||
*/
|
||||
export function sanitizeBlocks(blocks) {
|
||||
return (blocks || []).map((block) => sanitizeValue(block, null));
|
||||
}
|
||||
|
||||
function sanitizeValue(value, key) {
|
||||
if (typeof value === 'string') {
|
||||
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
/* The key travels into the elements, strings and objects alike. A string in
|
||||
`items` is display text; an object in `rows` is a row, and only the key
|
||||
says so — its own cell keys are positional (`c0`, `c1`) and carry no
|
||||
meaning at all. An object in `columns` still defers to its own keys. */
|
||||
return value.map((entry) => sanitizeValue(entry, key));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
|
||||
positional and carry no meaning, so the row itself marks them. */
|
||||
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||
*
|
||||
* A real model writes markdown — headings, numbered steps, tables. The local
|
||||
* simulator never did: it emitted short single paragraphs, so the text renderer
|
||||
* only ever handled inline bold and italic. Point the panel at a real model and
|
||||
* `## What I'd do, in order` arrives on screen with the hashes still attached,
|
||||
* and a comparison table arrives as pipes.
|
||||
*
|
||||
* The fix is NOT to render markdown inside a text block. The panel already has
|
||||
* a heading block, a list block and a table block, all styled with the same
|
||||
* tokens as the dashboard cards beside them — so the honest move is to parse
|
||||
* into those, and let a generated answer look like it belongs to Krow rather
|
||||
* than like a chat window that happens to be embedded in it.
|
||||
*
|
||||
* Deliberately a small parser and not a markdown library. Four constructs is
|
||||
* what a model actually produces in an answer; anything else falls through as a
|
||||
* paragraph, which reads correctly even when it is not styled richly. A full
|
||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||
* writes.
|
||||
*/
|
||||
export function markdownToBlocks(markdown, { partial = false } = {}) {
|
||||
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = null;
|
||||
let ordered = false;
|
||||
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join(' ').trim();
|
||||
paragraph = [];
|
||||
if (text) blocks.push({ type: 'text', text });
|
||||
};
|
||||
const flushList = () => {
|
||||
if (listItems?.length) blocks.push({ type: 'list', items: listItems, ordered });
|
||||
listItems = null;
|
||||
};
|
||||
const flushAll = () => { flushParagraph(); flushList(); };
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) { flushAll(); continue; }
|
||||
|
||||
/* A heading. The level is dropped: this panel has one heading style, and
|
||||
inventing three would give a 380px column a hierarchy it cannot show. */
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
||||
if (heading) {
|
||||
flushAll();
|
||||
blocks.push({ type: 'heading', text: stripInline(heading[2]) });
|
||||
continue;
|
||||
}
|
||||
|
||||
/* A table: a pipe row followed by a separator row. Checked together,
|
||||
because a single pipe row is far more likely to be prose. */
|
||||
if (trimmed.startsWith('|') && isSeparatorRow(lines[i + 1])) {
|
||||
flushAll();
|
||||
const { block, next } = parseTable(lines, i);
|
||||
if (block) { blocks.push(block); i = next; continue; }
|
||||
}
|
||||
|
||||
const bullet = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
||||
if (bullet || numbered) {
|
||||
const wantOrdered = Boolean(numbered);
|
||||
/* A list that changes kind mid-way is two lists. */
|
||||
if (listItems && ordered !== wantOrdered) flushList();
|
||||
flushParagraph();
|
||||
ordered = wantOrdered;
|
||||
listItems = listItems || [];
|
||||
listItems.push((bullet || numbered)[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
flushAll();
|
||||
|
||||
return blocks.length ? blocks : [{ type: 'text', text: String(markdown).trim() }];
|
||||
}
|
||||
|
||||
/** `|---|---:|` — the row that makes the one above it a header. */
|
||||
function isSeparatorRow(line) {
|
||||
return Boolean(line && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-'));
|
||||
}
|
||||
|
||||
function splitRow(line) {
|
||||
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a markdown table starting at `start`.
|
||||
*
|
||||
* Rows with the wrong number of cells are padded or trimmed rather than
|
||||
* dropped. A model occasionally miscounts a pipe, and losing a whole row of an
|
||||
* answer over a formatting slip is worse than showing an empty cell — which the
|
||||
* renderer already draws as an em dash.
|
||||
*/
|
||||
function parseTable(lines, start) {
|
||||
const header = splitRow(lines[start]);
|
||||
const columns = header.map((label, i) => ({ key: `c${i}`, label: stripInline(label) }));
|
||||
|
||||
const rows = [];
|
||||
let i = start + 2;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!line.trim().startsWith('|')) break;
|
||||
const cells = splitRow(line);
|
||||
const row = {};
|
||||
columns.forEach((col, n) => { row[col.key] = stripInline(cells[n] ?? ''); });
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
if (!rows.length) return { block: null, next: start };
|
||||
return { block: { type: 'table', columns, rows }, next: i - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes markdown a cell or heading cannot show.
|
||||
*
|
||||
* Table cells and headings are rendered as plain strings by their components,
|
||||
* so `**Maria**` would appear with the asterisks. Paragraphs and list items are
|
||||
* left alone — those go through `Inline`, which renders bold properly.
|
||||
*/
|
||||
function stripInline(value) {
|
||||
return String(value)
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
/* A link keeps its label and loses its target. Headings and cells are drawn
|
||||
as plain strings by their components, so an anchor cannot survive here —
|
||||
and the label alone reads correctly, where the raw `[label](href)` does
|
||||
not. `Inline` renders the real thing everywhere a link CAN be one. */
|
||||
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed request, in one sentence a person can act on.
|
||||
*
|
||||
* The status is what distinguishes the cases that matter, and they are
|
||||
* genuinely different actions: sign in again, ask someone for access, or wait.
|
||||
* Flattening them into "something went wrong" makes the user's next move a
|
||||
* guess.
|
||||
*/
|
||||
async function describeFailure(response) {
|
||||
let detail = '';
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body?.error?.message || '';
|
||||
} catch {
|
||||
/* A non-JSON error body is a proxy or a gateway, not this API. The status
|
||||
still says enough. */
|
||||
}
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
return 'Your session has expired. Sign in again to keep asking.';
|
||||
case 404:
|
||||
/* Deliberately the same answer for "no such agent" and "not yours" — the
|
||||
API refuses to distinguish them, and repeating the distinction here
|
||||
would undo that. */
|
||||
return 'That agent is not available on this workspace.';
|
||||
case 422:
|
||||
return detail || 'That agent cannot run right now.';
|
||||
case 429:
|
||||
return 'Too many requests just now. Try again in a moment.';
|
||||
default:
|
||||
return detail || 'The agent could not be reached. Try again in a moment.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses.
|
||||
*
|
||||
* There used to be three: a local simulator that computed answers from data
|
||||
* already in the browser, a streaming HTTP provider for a deployment that had
|
||||
* one, and the agent. The simulator is gone, and its removal is the point of
|
||||
* this file's current shape.
|
||||
*
|
||||
* WHY IT WENT
|
||||
*
|
||||
* Two answering paths behind one avatar meant the same question got different
|
||||
* answers depending on phrasing — "assign the strongest free worker" matched a
|
||||
* template and reported nobody was available, while "put the best free worker
|
||||
* on" reached the agent, which found somebody and proposed them. A user cannot
|
||||
* be expected to know which sentence talks to which system, and a product where
|
||||
* the wording decides the answer is a demo with good manners.
|
||||
*
|
||||
* WHAT IT COST, SAID PLAINLY
|
||||
*
|
||||
* The simulator was instant, free, and could not be wrong about a figure — it
|
||||
* read the same cache the page rendered from. The agent takes ten to twenty
|
||||
* seconds and costs tokens. That is a real regression on speed, accepted in
|
||||
* exchange for answers that can be followed up, cannot be beaten by a synonym,
|
||||
* and can act on what they find.
|
||||
*
|
||||
* AN UNCONFIGURED DEPLOYMENT NOW SAYS SO
|
||||
*
|
||||
* With no VITE_AGENT_API there is nothing to fall back to. That state is
|
||||
* explicit rather than silent: every question is answered with the reason,
|
||||
* because a panel that quietly does nothing is the worst of the three
|
||||
* possibilities and the hardest to diagnose.
|
||||
*/
|
||||
export function createAssistantProvider() {
|
||||
const base = import.meta.env?.VITE_AGENT_API;
|
||||
return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider for a deployment with no agent configured.
|
||||
*
|
||||
* Answers every question with the same sentence, which is the honest thing to
|
||||
* do: nothing here can answer, and pretending otherwise is what the simulator
|
||||
* was doing.
|
||||
*/
|
||||
export function createUnconfiguredProvider() {
|
||||
return {
|
||||
id: 'unconfigured',
|
||||
|
||||
async *stream() {
|
||||
yield [note(
|
||||
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||
+ 'backend a model credential, and this panel will answer from your workspace.'
|
||||
)];
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user