agnets done
This commit is contained in:
@@ -26,182 +26,424 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
import { getContext } from './contexts';
|
||||
import { note, toSnapshots } from './blocks';
|
||||
import { confirmation, note } from './blocks';
|
||||
|
||||
/**
|
||||
* An answer, at the depth the agent asked for.
|
||||
* The agent provider: the real runtime, over the real API.
|
||||
*
|
||||
* Three modes, and only two of them do anything: `balanced` is today's
|
||||
* behaviour exactly, so every existing answer and every agent that declares no
|
||||
* reasoning is unchanged.
|
||||
* The only provider that answers. Everything that makes that safe lives on the
|
||||
* server:
|
||||
*
|
||||
* `webSearch` is handled here too, and handled honestly. No search provider is
|
||||
* configured in this deployment, so an agent with the flag set gets a note
|
||||
* saying so rather than an answer that quietly came from nowhere. Silently
|
||||
* ignoring the flag would be worse: the configuration would read as working.
|
||||
* - **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.
|
||||
*/
|
||||
function shapeForDepth(document, agent, facts) {
|
||||
if (!agent || !document?.blocks?.length) return document;
|
||||
|
||||
const blocks = [...document.blocks];
|
||||
|
||||
if (agent.reasoning === 'fast') {
|
||||
/* The headline and the first supporting block. Enough to answer, without
|
||||
the breakdown someone asking a quick question did not want. */
|
||||
const trimmed = blocks.slice(0, 2);
|
||||
return { ...document, blocks: trimmed.length ? trimmed : blocks };
|
||||
}
|
||||
|
||||
if (agent.reasoning === 'deep') {
|
||||
const counted = [
|
||||
facts?.applications?.length != null && `${facts.applications.length} applications`,
|
||||
facts?.postings?.length != null && `${facts.postings.length} positions`,
|
||||
facts?.staff?.length != null && `${facts.staff.length} hires`,
|
||||
].filter(Boolean);
|
||||
if (counted.length) {
|
||||
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
|
||||
}
|
||||
}
|
||||
|
||||
if (agent.webSearch) {
|
||||
blocks.push(note(
|
||||
'Web search is enabled on this agent, but no search provider is configured '
|
||||
+ 'in this deployment, so nothing outside this workspace was consulted.'
|
||||
));
|
||||
}
|
||||
|
||||
return { ...document, blocks };
|
||||
}
|
||||
|
||||
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 = 380, frameDelay = 26 } = {}) {
|
||||
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
id: 'local',
|
||||
id: 'agent',
|
||||
|
||||
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
|
||||
const context = getContext(contextId);
|
||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||
|
||||
const document = capability
|
||||
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
||||
?? context.respond(question, facts)
|
||||
: context.respond(question, facts);
|
||||
|
||||
/**
|
||||
* The agent's reasoning mode, as answer depth.
|
||||
*
|
||||
* A real configuration effect rather than a label: `fast` returns the
|
||||
* headline and stops, `deep` says which records the reading counted.
|
||||
* `balanced` — the default, and what an agent that declares nothing gets
|
||||
* — is untouched, so this cannot change any existing answer.
|
||||
*
|
||||
* Depth never changes *what* was read. The page decided that before the
|
||||
* provider was called; this only decides how much of it to say.
|
||||
*/
|
||||
const shaped = shapeForDepth(document, agent, facts);
|
||||
|
||||
// A brief pause before the first frame, so the answer reads as considered
|
||||
// rather than precomputed.
|
||||
await sleep(latency);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
for (const snapshot of toSnapshots(shaped)) {
|
||||
if (signal?.aborted) return;
|
||||
yield snapshot;
|
||||
await sleep(frameDelay);
|
||||
async *stream({ 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP provider — the path to a real backend.
|
||||
*
|
||||
* 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 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');
|
||||
|
||||
return {
|
||||
id: 'http',
|
||||
|
||||
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
|
||||
const response = await fetch(endpoint, {
|
||||
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
/* `agent` and `owliverContext` travel; the fact sheet still does not.
|
||||
Dashboard records are read server-side from the caller's own session,
|
||||
so the client cannot ask about records it is not entitled to see —
|
||||
and an agent cannot widen that by being named in the body. */
|
||||
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
|
||||
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 || !response.body) {
|
||||
throw new Error(`Assistant request failed: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
yield [note(await describeFailure(response))];
|
||||
return;
|
||||
}
|
||||
|
||||
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; 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.block) blocks.push(parsed.block);
|
||||
else if (parsed.delta) appendDelta(parsed.delta);
|
||||
} catch {
|
||||
appendDelta(payload);
|
||||
}
|
||||
yield [...blocks];
|
||||
}
|
||||
/* 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses. Local by default; point
|
||||
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
|
||||
* code change.
|
||||
* 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;
|
||||
yield markdownToBlocks(text);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const lines = String(markdown).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').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 endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
||||
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
|
||||
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',
|
||||
// eslint-disable-next-line require-yield
|
||||
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