From 8537b09f33d694ced504381d8108ced135ae6a65 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 23 Sep 2026 13:14:18 +0530 Subject: [PATCH] Wire the Buddy composer to the assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel has been complete except for the one part that answers. Its composer was deliberately disabled — it used to accept text, light up the send button, and swallow the submit, because no assistant endpoint existed. This connects it to the one that does. It is enabled only when a model is configured AND the page has an agent, checked at runtime via /assistant/status rather than assumed at build time. The placeholder says which of the two is missing: "Not connected yet" no model on this deployment "No assistant for this page yet" no agent for this route "Ask about this page" live Two different facts, two different sentences. A person on Inventory can move to Sales and get an answer today; a person whose deployment has no model can do nothing from the browser. Flattening both into "not connected" would be true and useless. The old rule is kept: never accept a message nothing will read. An answer shows its working — the tools it ran, their row counts, and a link to the page holding the same rows. Buddy states things with the confidence of a sentence, and the only honest way to present that is beside the evidence, so a person can disagree with it. Refused calls are shown too: an answer that quietly dropped one would look like Buddy chose not to look. A partial answer says so in amber, not as a grey hint. An answer cut off mid-sentence reads as a complete one otherwise, which is the failure the flag exists to prevent. The thread clears on navigation. An answer about Sales sitting above the Inventory page reads as being about what is on screen; losing the history is the smaller cost. Phase 2 ships one agent, covering Console and Sales. The rest arrive as backend config, not as changes here. Co-Authored-By: Claude Opus 5 --- src/api/assistant.ts | 66 ++++++ src/components/shell/AssistantPanel.tsx | 293 ++++++++++++++++++++++-- 2 files changed, 340 insertions(+), 19 deletions(-) create mode 100644 src/api/assistant.ts 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({