diff --git a/src/api/assistant.ts b/src/api/assistant.ts new file mode 100644 index 0000000..a100826 --- /dev/null +++ b/src/api/assistant.ts @@ -0,0 +1,66 @@ +/** + * Nearle Buddy. + * + * Two calls: is this switched on, and here is a question. The tenant is not one + * of them — the server reads it from the session token, which is the whole + * point. A request body that carried a tenant would be a request body somebody + * could edit. + */ + +import { api, WEB } from './client'; + +/** One tool the assistant ran, so an answer can show its working. */ +export interface AssistantStep { + tool: string; + /** `ok` or `refused`. Refusals are shown, not hidden — an answer that quietly + dropped one would look like Buddy chose not to look. */ + outcome: string; + rows?: number; + detail?: string; + scope?: string; +} + +export interface AssistantAnswer { + reply: string; + used?: AssistantStep[]; + model?: string; + /** Console routes showing the rows the answer came from. */ + sources?: string[]; + /** + * True when the loop hit its own limits, or the model's reply was cut off + * mid-sentence. The answer is still worth showing — a partial answer beats a + * spinner — but it must not be presented as the whole story. + */ + incomplete?: boolean; +} + +/** + * Is the assistant switched on for this deployment? + * + * Asked once when the panel mounts, and the answer decides whether the composer + * is enabled. The panel has said "Not connected yet" since it was built; this is + * what finally answers that at runtime rather than at build time. + * + * Never throws. A console that cannot reach this endpoint should show a + * disabled field, not an error page — the panel is beside the work, not the + * work itself. + */ +export async function assistantAvailable(): Promise { + try { + const status = await api.get<{ available?: boolean }>(`${WEB}/assistant/status`); + return status?.available === true; + } catch { + return false; + } +} + +/** + * Ask a question. + * + * `agent` names which assistant answers — the console sends the one matching the + * page the panel sits beside. Phase 2 ships one, so an unknown name is refused + * rather than silently answered by the wrong agent. + */ +export async function askAssistant(agent: string, question: string): Promise { + return api.post(`${WEB}/assistant/ask`, { agent, question }); +} diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index 2d8e98a..c68f3f1 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -1,5 +1,7 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; +import { errorMessage } from '@/api/client'; +import { askAssistant, assistantAvailable, type AssistantAnswer } from '@/api/assistant'; import { useAssistantScope } from './assistantScope'; import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; import { @@ -10,10 +12,24 @@ import { useAssistantWidth, } from './assistantWidth'; -/** Per-route context, so the panel knows which page it is sitting beside. */ +/** + * Per-route context, so the panel knows which page it is sitting beside. + * + * `agent` names which assistant answers here, and its absence is meaningful: a + * route without one has no assistant yet, and the composer says so rather than + * accepting a question nothing can answer. Phase 2 ships one agent covering the + * two order pages; the rest arrive as backend config, not as changes here. + */ const CONTEXT: Record< string, - { page: string; title: string; greeting: string; reading: string; prompts: string[] } + { + page: string; + title: string; + greeting: string; + reading: string; + prompts: string[]; + agent?: string; + } > = { '/nearle/stores': { page: 'Stores', @@ -50,6 +66,7 @@ const CONTEXT: Record< /* ── Store Admin ──────────────────────────────────────────────────────── */ '/admin/console': { + agent: 'orders', page: 'Console', title: 'Across your branches', greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.', @@ -62,6 +79,7 @@ const CONTEXT: Record< ], }, '/admin/sales': { + agent: 'orders', page: 'Sales', title: 'Orders and deliveries', greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.', @@ -111,7 +129,144 @@ const CONTEXT: Record< }, }; -const FALLBACK = { +/** One question and what came back. `answer` and `error` are exclusive. */ +interface Exchange { + question: string; + answer?: AssistantAnswer; + error?: string; +} + +/** Replaces the last entry, which is always the one in flight. */ +function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] { + if (thread.length === 0) return thread; + return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry)); +} + +/** + * What the composer says when it cannot be used, and why. + * + * Three different states with three different sentences. "Not connected yet" + * for everything would be true and useless: a person whose deployment has no + * model can do nothing, but a person on the Inventory page can move to Sales + * and get an answer today. + */ +function composerHint(available: boolean | null, hasAgent: boolean, isAsking: boolean): string { + if (isAsking) return 'Thinking…'; + if (available === null) return 'Checking…'; + if (available === false) return 'Not connected yet'; + if (!hasAgent) return 'No assistant for this page yet'; + return 'Ask about this page'; +} + +/** + * One question and its answer. + * + * The answer shows what it ran, not just what it concluded. Buddy states things + * with the confidence of a sentence, and the only honest way to present that is + * beside the tools it used and a link to the page holding the same rows — so a + * person can disagree with it. + */ +function Exchange({ + entry, + isLast, + isAsking, +}: { + entry: Exchange; + isLast: boolean; + isAsking: boolean; +}) { + const waiting = isLast && isAsking && !entry.answer && !entry.error; + + return ( +
+

+ {entry.question} +

+ + {waiting ? ( +

Looking…

+ ) : null} + + {entry.error ? ( +

+ {entry.error} +

+ ) : null} + + {entry.answer ? ( +
+

+ {entry.answer.reply} +

+ + {/* Said plainly, not as a subtle grey hint. A partial answer that + looks complete is the failure this flag exists to prevent. */} + {entry.answer.incomplete ? ( +

+ This answer is partial — Buddy ran out of room before finishing. +

+ ) : null} + + {entry.answer.used?.length ? ( +

+ {entry.answer.used + .map((step) => + step.outcome === 'ok' + ? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}` + : `${step.tool} · refused`, + ) + .join(' ')} +

+ ) : null} + + {entry.answer.sources?.length ? ( +

+ {entry.answer.sources.map((source) => ( + + See the rows + + ))} +

+ ) : null} +
+ ) : null} +
+ ); +} + +/** + * Typed against `CONTEXT`'s own shape so the two cannot drift. + * + * Without this the fallback has no `agent` key at all, and `routeContext` — + * which is one or the other — loses the field entirely. A page that fell + * through to the fallback would then be a compile error rather than simply a + * page with no assistant, which is what it is. + */ +const FALLBACK: (typeof CONTEXT)[string] = { page: 'Console', title: 'Good afternoon', greeting: 'Ask about anything on this page.', @@ -159,6 +314,68 @@ export function AssistantPanel({ const scopeLabel = useAssistantScope(); const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext; + /* + * Whether Buddy can answer here at all, and why not when it cannot. + * + * Two separate reasons, kept apart because they are two different facts and + * the person can act on one of them. `available === false` means this + * deployment has no model configured — nothing to be done from the browser. + * `agent === undefined` means this page has no assistant yet, which is about + * the page and not the deployment. + * + * `null` is "not asked yet": the composer stays disabled during the check, so + * it is never briefly live against a server that turns out to have no model. + */ + const [available, setAvailable] = useState(null); + const [thread, setThread] = useState([]); + const [isAsking, setAsking] = useState(false); + const agent = routeContext.agent; + const canAsk = available === true && Boolean(agent) && !isAsking; + + useEffect(() => { + let live = true; + void assistantAvailable().then((ok) => { + if (live) setAvailable(ok); + }); + // Cancelled on unmount so a slow answer cannot set state on a closed panel. + return () => { + live = false; + }; + }, []); + + /* + * The thread is per-page and deliberately not persisted. + * + * An answer about Sales sitting above the Inventory page is worse than no + * answer: it reads as being about what is on screen. Clearing on navigation + * costs a person their history, which is the smaller loss. + */ + useEffect(() => { + setThread([]); + }, [key]); + + const send = async (text: string) => { + const question = text.trim(); + if (!question || !agent || !canAsk) return; + + setDraft(''); + setAsking(true); + setThread((current) => [...current, { question }]); + + try { + const answer = await askAssistant(agent, question); + setThread((current) => replaceLast(current, (entry) => ({ ...entry, answer }))); + } catch (error) { + // Shown in the thread rather than as a toast: the question is still on + // screen, and the failure belongs next to it. + setThread((current) => + replaceLast(current, (entry) => ({ ...entry, error: errorMessage(error) })), + ); + } finally { + setAsking(false); + } + }; + /** * Three widths, in priority order: stacked (the phone layout owns it), * expanded (the one-click half-screen), then whatever the operator dragged @@ -296,25 +513,53 @@ export function AssistantPanel({

+ {/* The conversation. Above the spacer, so a short thread sits under the + greeting and a long one pushes the chips and composer down rather + than scrolling away from them. */} + {thread.length > 0 ? ( +
+ {thread.map((entry, index) => ( + + ))} +
+ ) : null} +
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge is a bug no amount of fade masking fixes. */}
{context.prompts.map((prompt) => ( - setDraft(prompt)} /> + (canAsk ? void send(prompt) : setDraft(prompt))} + /> ))}
- {/* Disabled, not merely inert. - + {/* Live, at last — but only when something can actually answer. + This composer used to accept text and enable a brand-purple send - button the moment you typed — then swallow the submit. There is no - assistant endpoint anywhere in `src/api`. A disabled field says the - same thing honestly and costs nobody a message they thought they - sent. */} + button the moment you typed, then swallow the submit, because no + assistant endpoint existed. It was disabled rather than left to lie. + It is enabled here exactly when a model is configured AND this page + has an agent, and the placeholder says which of the two is missing + otherwise. The rule it keeps is the old one: never accept a message + nothing will read. */}
event.preventDefault()} + onSubmit={(event) => { + event.preventDefault(); + void send(draft); + }} style={{ display: 'flex', alignItems: 'flex-end', @@ -341,9 +586,18 @@ export function AssistantPanel({ }} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - disabled - placeholder="Not connected yet" - aria-label="Ask Nearle Buddy — not connected yet" + onKeyDown={(event) => { + // Enter sends, shift+Enter writes a second line. The composer is + // one line tall most of the time, so requiring the button would + // make every question a two-action job. + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void send(draft); + } + }} + disabled={!canAsk} + placeholder={composerHint(available, Boolean(agent), isAsking)} + aria-label={`Ask Nearle Buddy — ${composerHint(available, Boolean(agent), isAsking)}`} style={{ display: 'block', width: '100%', @@ -362,18 +616,19 @@ export function AssistantPanel({