From f3fe53d2ac6802ea8e7df4899a7eba3fe39265a4 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 23 Sep 2026 17:25:09 +0530 Subject: [PATCH] agent --- src/api/assistant.ts | 35 ++++ src/components/shell/AssistantPanel.tsx | 219 +++++++++++++++++++++++- 2 files changed, 246 insertions(+), 8 deletions(-) diff --git a/src/api/assistant.ts b/src/api/assistant.ts index a100826..980ba50 100644 --- a/src/api/assistant.ts +++ b/src/api/assistant.ts @@ -20,6 +20,26 @@ export interface AssistantStep { scope?: string; } +/** One line on an approval card: what is about to change, in checkable detail. */ +export interface ProposalDetail { + label: string; + value: string; +} + +/** + * A change Buddy has resolved and is waiting on a person for. + * + * Nothing has happened when this arrives. The `card` is signed by the server + * and opaque here — the console sends it back unchanged, and anything the + * browser altered stops it verifying. + */ +export interface Proposal { + summary: string; + details?: ProposalDetail[]; + warning?: string; + card: string; +} + export interface AssistantAnswer { reply: string; used?: AssistantStep[]; @@ -32,6 +52,8 @@ export interface AssistantAnswer { * spinner — but it must not be presented as the whole story. */ incomplete?: boolean; + /** Set when a change is resolved and waiting. One card, never a list. */ + awaiting?: Proposal; } /** @@ -64,3 +86,16 @@ export async function assistantAvailable(): Promise { export async function askAssistant(agent: string, question: string): Promise { return api.post(`${WEB}/assistant/ask`, { agent, question }); } + +/** + * Perform a change the person has agreed to. + * + * A separate call with no question in it, because it is a different act: the + * card names the action and the session names the person, and no model is + * consulted. The server re-checks both against the live database before writing + * — so this can legitimately fail with "somebody already approved that", which + * is an answer rather than an error. + */ +export async function approveAssistant(agent: string, card: string): Promise { + return api.post(`${WEB}/assistant/approve`, { agent, card }); +} diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index c68f3f1..29cbe16 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -1,7 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import { Fragment, 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 { + approveAssistant, + askAssistant, + assistantAvailable, + type AssistantAnswer, +} from '@/api/assistant'; import { useAssistantScope } from './assistantScope'; import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; import { @@ -17,8 +22,16 @@ import { * * `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. + * accepting a question nothing can answer. + * + * A page is only given an agent when that agent can answer every chip on it. A + * chip that comes back "I cannot look that up" is worse than no chip — it reads + * as the assistant being broken rather than as a feature not built yet. Reports + * has no agent for exactly that reason: two of its four questions have no tool + * behind them. + * + * The agents themselves are YAML on the backend, so a new one is a file there + * and one line here. */ const CONTEXT: Record< string, @@ -66,7 +79,7 @@ const CONTEXT: Record< /* ── Store Admin ──────────────────────────────────────────────────────── */ '/admin/console': { - agent: 'orders', + agent: 'console', page: 'Console', title: 'Across your branches', greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.', @@ -92,15 +105,16 @@ const CONTEXT: Record< ], }, '/admin/inventory': { + agent: 'inventory', page: 'Inventory', title: 'Catalogue and stock', greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.', reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.', prompts: [ 'What is waiting for approval?', - 'Which products are unpublished?', 'What is low or out of stock?', - 'How do I upload a product sheet?', + 'What has run out completely?', + 'Which branch is waiting on most stock?', ], }, '/admin/users': { @@ -134,6 +148,14 @@ interface Exchange { question: string; answer?: AssistantAnswer; error?: string; + /** + * What became of a change Buddy proposed. + * + * `undefined` means the card is still on screen waiting. Once decided the + * card is replaced by its outcome and cannot be pressed again — a card that + * stayed live after approval is a second write waiting to happen. + */ + decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string }; } /** Replaces the last entry, which is always the one in flight. */ @@ -158,6 +180,127 @@ function composerHint(available: boolean | null, hasAgent: boolean, isAsking: bo return 'Ask about this page'; } +/** + * A change waiting on the person. + * + * Framed and set apart from the reply on purpose. The sentence above it was + * written by a model; this was resolved by the server against the database, and + * the two must not read as one thing. What is shown here — the ids, the names, + * the quantity — is what the button actually agrees to. + * + * Once decided the buttons are gone, replaced by what happened. A card that + * stayed pressable after approval is a second write waiting for a double-click. + */ +function ApprovalCard({ + proposal, + decision, + onDecide, +}: { + proposal: NonNullable; + decision: Exchange['decision']; + onDecide: (approve: boolean) => void; +}) { + const busy = decision?.state === 'approving'; + + const outcomeColour = + decision?.state === 'failed' + ? 'var(--color-error, #d64545)' + : decision?.state === 'done' + ? 'var(--color-success, #1f9d55)' + : 'var(--color-ink-3)'; + + return ( +
+

+ {proposal.summary} +

+ + {proposal.details?.length ? ( +
+ {proposal.details.map((detail) => ( + +
{detail.label}
+
+ {detail.value} +
+
+ ))} +
+ ) : null} + + {proposal.warning ? ( +

+ {proposal.warning} +

+ ) : null} + + {decision === undefined ? ( +
+ + {/* "Not now", not "Reject". Nothing is refused and nothing is + recorded — the card is simply left alone, and it expires unused. */} + +
+ ) : ( +

+ {busy + ? 'Making the change…' + : decision.state === 'dismissed' + ? 'Left alone. Nothing was changed.' + : (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))} +

+ )} +
+ ); +} + /** * One question and its answer. * @@ -170,10 +313,12 @@ function Exchange({ entry, isLast, isAsking, + onDecide, }: { entry: Exchange; isLast: boolean; isAsking: boolean; + onDecide: (approve: boolean) => void; }) { const waiting = isLast && isAsking && !entry.answer && !entry.error; @@ -239,6 +384,14 @@ function Exchange({

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

{entry.answer.sources.map((source) => ( @@ -354,6 +507,50 @@ export function AssistantPanel({ setThread([]); }, [key]); + /* + * Approving is its own call, with no question in it. + * + * Indexed rather than acting on the last exchange: a person can scroll up and + * approve an earlier card, and nothing about a card ties it to being the most + * recent thing said. + */ + const decide = async (index: number, approve: boolean) => { + const entry = thread[index]; + const card = entry?.answer?.awaiting?.card; + if (!card || !agent || entry.decision) return; + + if (!approve) { + // Dismissing is purely local: the server was never told, because there is + // nothing to undo. The card simply expires unused. + setThread((current) => + current.map((e, i) => (i === index ? { ...e, decision: { state: 'dismissed' as const } } : e)), + ); + return; + } + + setThread((current) => + current.map((e, i) => (i === index ? { ...e, decision: { state: 'approving' as const } } : e)), + ); + + try { + const done = await approveAssistant(agent, card); + setThread((current) => + current.map((e, i) => + i === index ? { ...e, decision: { state: 'done' as const, message: done.reply } } : e, + ), + ); + } catch (error) { + // "Somebody already approved that" arrives here, and it is an answer + // rather than a fault — shown on the card, which stays decided so the + // button cannot be pressed again into the same refusal. + setThread((current) => + current.map((e, i) => + i === index ? { ...e, decision: { state: 'failed' as const, message: errorMessage(error) } } : e, + ), + ); + } + }; + const send = async (text: string) => { const question = text.trim(); if (!question || !agent || !canAsk) return; @@ -524,7 +721,13 @@ export function AssistantPanel({ style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }} > {thread.map((entry, index) => ( - + void decide(index, approve)} + /> ))} ) : null}