This commit is contained in:
2026-09-23 17:25:09 +05:30
parent 8537b09f33
commit f3fe53d2ac
2 changed files with 246 additions and 8 deletions

View File

@@ -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<boolean> {
export async function askAssistant(agent: string, question: string): Promise<AssistantAnswer> {
return api.post<AssistantAnswer>(`${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<AssistantAnswer> {
return api.post<AssistantAnswer>(`${WEB}/assistant/approve`, { agent, card });
}

View File

@@ -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<AssistantAnswer['awaiting']>;
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 (
<div
style={{
border: '1px solid var(--color-line)',
borderRadius: 12,
padding: '11px 12px',
background: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<p style={{ margin: 0, fontSize: 12.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{proposal.summary}
</p>
{proposal.details?.length ? (
<dl style={{ margin: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 10px' }}>
{proposal.details.map((detail) => (
<Fragment key={detail.label}>
<dt style={{ fontSize: 11.5, color: 'var(--color-ink-3)' }}>{detail.label}</dt>
<dd style={{ margin: 0, fontSize: 11.5, color: 'var(--color-ink-1)' }}>
{detail.value}
</dd>
</Fragment>
))}
</dl>
) : null}
{proposal.warning ? (
<p
style={{
margin: 0,
fontSize: 11.5,
lineHeight: 1.5,
color: 'var(--color-warning, #b7860b)',
}}
>
{proposal.warning}
</p>
) : null}
{decision === undefined ? (
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => onDecide(true)}
style={{
flex: 1,
padding: '7px 10px',
borderRadius: 8,
border: 0,
background: 'var(--color-brand)',
color: '#fff',
fontSize: 12.5,
fontWeight: 600,
cursor: 'pointer',
}}
>
Approve
</button>
{/* "Not now", not "Reject". Nothing is refused and nothing is
recorded — the card is simply left alone, and it expires unused. */}
<button
type="button"
onClick={() => onDecide(false)}
style={{
padding: '7px 12px',
borderRadius: 8,
border: '1px solid var(--color-line)',
background: 'transparent',
color: 'var(--color-ink-2)',
fontSize: 12.5,
cursor: 'pointer',
}}
>
Not now
</button>
</div>
) : (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: outcomeColour }}>
{busy
? 'Making the change…'
: decision.state === 'dismissed'
? 'Left alone. Nothing was changed.'
: (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))}
</p>
)}
</div>
);
}
/**
* 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({
</p>
) : null}
{entry.answer.awaiting ? (
<ApprovalCard
proposal={entry.answer.awaiting}
decision={entry.decision}
onDecide={onDecide}
/>
) : null}
{entry.answer.sources?.length ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
{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) => (
<Exchange key={index} entry={entry} isLast={index === thread.length - 1} isAsking={isAsking} />
<Exchange
key={index}
entry={entry}
isLast={index === thread.length - 1}
isAsking={isAsking}
onDecide={(approve) => void decide(index, approve)}
/>
))}
</div>
) : null}