agent
This commit is contained in:
@@ -20,6 +20,26 @@ export interface AssistantStep {
|
|||||||
scope?: string;
|
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 {
|
export interface AssistantAnswer {
|
||||||
reply: string;
|
reply: string;
|
||||||
used?: AssistantStep[];
|
used?: AssistantStep[];
|
||||||
@@ -32,6 +52,8 @@ export interface AssistantAnswer {
|
|||||||
* spinner — but it must not be presented as the whole story.
|
* spinner — but it must not be presented as the whole story.
|
||||||
*/
|
*/
|
||||||
incomplete?: boolean;
|
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> {
|
export async function askAssistant(agent: string, question: string): Promise<AssistantAnswer> {
|
||||||
return api.post<AssistantAnswer>(`${WEB}/assistant/ask`, { agent, question });
|
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 { useLocation } from 'react-router-dom';
|
||||||
import { errorMessage } from '@/api/client';
|
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 { useAssistantScope } from './assistantScope';
|
||||||
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
|
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +22,16 @@ import {
|
|||||||
*
|
*
|
||||||
* `agent` names which assistant answers here, and its absence is meaningful: a
|
* `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
|
* 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
|
* accepting a question nothing can answer.
|
||||||
* two order pages; the rest arrive as backend config, not as changes here.
|
*
|
||||||
|
* 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<
|
const CONTEXT: Record<
|
||||||
string,
|
string,
|
||||||
@@ -66,7 +79,7 @@ const CONTEXT: Record<
|
|||||||
/* ── Store Admin ──────────────────────────────────────────────────────── */
|
/* ── Store Admin ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
'/admin/console': {
|
'/admin/console': {
|
||||||
agent: 'orders',
|
agent: 'console',
|
||||||
page: 'Console',
|
page: 'Console',
|
||||||
title: 'Across your branches',
|
title: 'Across your branches',
|
||||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
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': {
|
'/admin/inventory': {
|
||||||
|
agent: 'inventory',
|
||||||
page: 'Inventory',
|
page: 'Inventory',
|
||||||
title: 'Catalogue and stock',
|
title: 'Catalogue and stock',
|
||||||
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
|
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.',
|
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
|
||||||
prompts: [
|
prompts: [
|
||||||
'What is waiting for approval?',
|
'What is waiting for approval?',
|
||||||
'Which products are unpublished?',
|
|
||||||
'What is low or out of stock?',
|
'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': {
|
'/admin/users': {
|
||||||
@@ -134,6 +148,14 @@ interface Exchange {
|
|||||||
question: string;
|
question: string;
|
||||||
answer?: AssistantAnswer;
|
answer?: AssistantAnswer;
|
||||||
error?: string;
|
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. */
|
/** 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';
|
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.
|
* One question and its answer.
|
||||||
*
|
*
|
||||||
@@ -170,10 +313,12 @@ function Exchange({
|
|||||||
entry,
|
entry,
|
||||||
isLast,
|
isLast,
|
||||||
isAsking,
|
isAsking,
|
||||||
|
onDecide,
|
||||||
}: {
|
}: {
|
||||||
entry: Exchange;
|
entry: Exchange;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
isAsking: boolean;
|
isAsking: boolean;
|
||||||
|
onDecide: (approve: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const waiting = isLast && isAsking && !entry.answer && !entry.error;
|
const waiting = isLast && isAsking && !entry.answer && !entry.error;
|
||||||
|
|
||||||
@@ -239,6 +384,14 @@ function Exchange({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{entry.answer.awaiting ? (
|
||||||
|
<ApprovalCard
|
||||||
|
proposal={entry.answer.awaiting}
|
||||||
|
decision={entry.decision}
|
||||||
|
onDecide={onDecide}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{entry.answer.sources?.length ? (
|
{entry.answer.sources?.length ? (
|
||||||
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
|
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
|
||||||
{entry.answer.sources.map((source) => (
|
{entry.answer.sources.map((source) => (
|
||||||
@@ -354,6 +507,50 @@ export function AssistantPanel({
|
|||||||
setThread([]);
|
setThread([]);
|
||||||
}, [key]);
|
}, [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 send = async (text: string) => {
|
||||||
const question = text.trim();
|
const question = text.trim();
|
||||||
if (!question || !agent || !canAsk) return;
|
if (!question || !agent || !canAsk) return;
|
||||||
@@ -524,7 +721,13 @@ export function AssistantPanel({
|
|||||||
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
|
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
|
||||||
>
|
>
|
||||||
{thread.map((entry, index) => (
|
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user