agent
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user