feat(web): 智能体对话重做(AGUI-3)

- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话
  聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」),
  展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。
- agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡,
  回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。
- 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠;
  「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。
- 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止,
  / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。
- 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。
- 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。
- 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。
- 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:41:34 +08:00
1 parent 2ab6d452dd
commit 6fcf82afb0
17 files changed
+2381 -1846

No files matched your search

@@ -1,11 +1,18 @@
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts";
import { ListChecks, MessageCircleQuestion, Star } from "lucide-react";
import {
type AgentOptionView,
type AgentPlanStep,
CAPABILITY_CATALOG,
type KnownCapabilityId,
type RiskLevel,
} from "@umt/contracts";
import { ChevronRight, ListChecks, LoaderCircle, MessageCircleQuestion } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -28,7 +35,8 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
id: s.id,
title: s.title,
status: STEP_STATUS[s.status],
...(s.note ? { detail: <span title={s.note}>{s.note}</span> } : {}),
// 备注只在失败时显示(失败原因);其余备注是模型的过程说明,不上屏
...(s.note && s.status === "failed" ? { detail: <span title={s.note}>{s.note}</span> } : {}),
}));
}
@@ -90,163 +98,206 @@ export function RunPlan({
// ── 方案选择 ──
const NONE = "__none__";
/** 方案排序:推荐项放第一位 */
export function orderOptions<T extends { id: string }>(options: T[], recommendedId: string | null): T[] {
const rec = options.find((o) => o.id === recommendedId);
return rec ? [rec, ...options.filter((o) => o !== rec)] : options;
}
/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */
/**
* 方案按钮上的动作要点:「提交下载 · 《沙丘:第二部》· 12.0 GB」;标题已经是动作名时只写对象和大小。
* 没有动作时用模型给的估算代价。
*/
export function optionPoint(o: AgentOptionView): string | null {
if (o.action) {
const label = o.title.includes(o.action.label) ? null : o.action.label;
return [label, o.action.detail].filter(Boolean).join(" · ") || null;
}
return o.estimatedCost ?? null;
}
/** 超时说明里的时间:当天只写时分 */
function deadlineText(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toDateString() === new Date().toDateString()
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
: formatDateTime(iso);
}
/**
* agent.propose_options 的方案卡(AGUI-3):每个方案一个按钮,点一下就选定;推荐项在第一位、是主按钮。
* 绑定了计划的方案写明动作要点,管理员选定即批准(服务端核对绑定)。风险折叠;「都不要…」点开才出原因框。
*/
export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) {
const name = useId();
const choose = useChooseOption();
const [picked, setPicked] = useState<string>(view.recommendedId ?? "");
const riskId = useId();
const [riskOpen, setRiskOpen] = useState(false);
const [noneOpen, setNoneOpen] = useState(false);
const [reason, setReason] = useState("");
const [picking, setPicking] = useState<string | null>(null);
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
const resolved = view.resolved;
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
const closed = !!resolved || !!sent || !waiting;
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null;
const submit = () => {
const optionId = picked === NONE ? null : picked;
if (!picked) return;
const options = orderOptions(view.options, view.recommendedId);
const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r })));
const submit = (optionId: string | null) => {
if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,智能体按它调整");
return;
}
setPicking(optionId ?? "__none__");
choose.mutate(
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
{
onSuccess: () => {
setSent({ optionId, reason: reason.trim() });
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案");
const o = view.options.find((x) => x.id === optionId);
toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已退回,智能体调整方案");
},
onError: (e) => toast.error(errorMessage(e)),
onSettled: () => setPicking(null),
},
);
};
const label = (id: string | null) => {
const o = view.options.find((x) => x.id === id);
if (!o) return id ?? "";
const point = optionPoint(o);
return point && o.action ? `${o.title} · ${point}` : o.title;
};
const outcome = resolved
? resolved.outcome === "chosen"
? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}`
? `${resolved.by === "timeout" ? "超时自动选了" : "已选"}:${label(resolved.choice)}`
: resolved.outcome === "stop"
? "超时未选,按设置停止"
? "超时没选,已停止"
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
: sent
? sent.optionId
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
: `都不要:${sent.reason},等待智能体调整`
? `已选:${label(sent.optionId)}`
: `都不要:${sent.reason}`
: !waiting
? "会话已不在等待这个选择"
? "已不再等这个选择"
: null;
if (closed)
return (
<p
data-slot="options-resolved"
className="flex w-full min-w-0 items-start gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption text-muted-foreground"
>
<MessageCircleQuestion aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
<span className="text-foreground/85">{view.question}</span> · {outcome}
</span>
</p>
);
return (
<section
aria-label="选择方案"
className={cn(
"w-full min-w-0 rounded-xl border p-3",
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
)}
>
<section aria-label="选择方案" className="w-full min-w-0 rounded-xl border border-warning/40 bg-warning/[0.06] p-3">
<p className="flex items-start gap-2 text-caption font-medium text-foreground">
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 break-words">{view.question}</span>
</p>
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
<legend className="sr-only">{view.question}</legend>
{view.options.map((o) => {
const selected = closed ? chosenId === o.id : picked === o.id;
<ul className="mt-2.5 flex flex-col gap-1.5">
{options.map((o) => {
const recommended = o.id === view.recommendedId;
const point = optionPoint(o);
return (
<label
key={o.id}
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
selected && "border-foreground/40 bg-foreground/[0.04]",
closed && !selected && "opacity-60",
closed && "cursor-default",
)}
>
<input
type="radio"
name={name}
value={o.id}
checked={selected}
onChange={() => setPicked(o.id)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-1.5">
<span className="text-caption font-medium text-foreground">{o.title}</span>
{view.recommendedId === o.id ? (
<StatusBadge>
<Star className="size-3" />
推荐
</StatusBadge>
<li key={o.id}>
<button
type="button"
disabled={choose.isPending}
onClick={() => submit(o.id)}
data-recommended={recommended || undefined}
className={cn(
"flex w-full min-w-0 cursor-pointer flex-col gap-0.5 rounded-lg border px-3 py-2 text-left outline-none transition-colors duration-micro focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
recommended
? "pill-primary border-transparent"
: "border-border/70 bg-background/40 hover:bg-foreground/[0.04]",
)}
>
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-caption font-medium">
{picking === o.id ? (
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
) : null}
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null}
<span className="min-w-0 break-words">{o.title}</span>
{point ? <span className="font-normal opacity-80">· {point}</span> : null}
{recommended ? <span className="font-normal opacity-80">(推荐)</span> : null}
</span>
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
{o.risks.length ? (
<ul className="mt-1 space-y-0.5 text-meta text-warning">
{o.risks.map((r) => (
<li key={r} className="break-words">
风险:{r}
</li>
))}
</ul>
) : null}
</span>
</label>
<span className={cn("text-meta break-words", recommended ? "opacity-80" : "text-muted-foreground")}>
{o.summary}
{o.action ? (o.action.approves ? "。选定即执行。" : "。执行前还会再确认。") : ""}
</span>
</button>
</li>
);
})}
{closed ? null : (
<label
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-dashed border-border px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
picked === NONE && "border-foreground/40",
)}
</ul>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
{risks.length ? (
<button
type="button"
aria-expanded={riskOpen}
aria-controls={riskId}
onClick={() => setRiskOpen((v) => !v)}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<input
type="radio"
name={name}
value={NONE}
checked={picked === NONE}
onChange={() => setPicked(NONE)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
</label>
)}
</fieldset>
{!closed && picked === NONE ? (
<Textarea
className="mt-2 min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
{closed ? (
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
) : (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
{picked === NONE ? "退回" : "选定"}
{risks.length} 条风险
<ChevronRight aria-hidden className={cn("size-3.5", CHEVRON_CLASS, riskOpen && "rotate-90")} />
</button>
) : null}
<button
type="button"
aria-expanded={noneOpen}
onClick={() => setNoneOpen((v) => !v)}
className="cursor-pointer rounded-md underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
都不要…
</button>
<span className="ml-auto">
{deadlineText(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}
</span>
</div>
<Disclosure id={riskId} open={riskOpen}>
<ul className="mt-1.5 space-y-0.5 text-meta text-warning">
{risks.map((r) => (
<li key={r.key} className="break-words">
{r.option}:{r.text}
</li>
))}
</ul>
</Disclosure>
{noneOpen ? (
<div className="mt-2 flex flex-col gap-2">
<Textarea
className="min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
<Button
size="sm"
variant="outline"
className="self-start"
disabled={!reason.trim() || choose.isPending}
onClick={() => submit(null)}
>
退回
</Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
</span>
</div>
)}
) : null}
</section>
);
}
// ── 步骤处的审批卡 ──
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划",
organize_plan: "整理计划",
@@ -261,11 +312,12 @@ const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
consumed: "consumed",
};
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
/** 需审批的能力调用:只显示摘要和【批准】【拒绝】;计划 ID、payload hash、版本、到期进技术信息 */
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
const admin = useIsAdminSession();
const live = useApproval(view.approvalId);
const decide = useDecideApproval();
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState("");
const a = live.data;
const expired = new Date(view.expiresAt).getTime() < Date.now();
@@ -295,15 +347,10 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
status={status}
title={`批准「${cap?.title ?? view.capabilityId}」?`}
description={view.summary}
badges={
<>
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
</>
}
onApprove={() => act("approved")}
onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
onReject={() => (rejecting ? act("rejected") : setRejecting(true))}
rejectLabel={rejecting ? "确认拒绝" : "拒绝"}
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={
status === "rejected" && decidedReason
? `已拒绝:${decidedReason}`
@@ -312,30 +359,27 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
: undefined
}
>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">计划</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
</dd>
<dt className="text-muted-foreground">payload hash</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
<dt className="text-muted-foreground">版本</dt>
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
<dt className="text-muted-foreground">到期</dt>
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
{formatDateTime(view.expiresAt)}
</dd>
</dl>
{status === "pending" && admin ? (
{rejecting && status === "pending" ? (
<Textarea
className="mt-2 min-h-14 text-caption"
className="mb-2 min-h-14 text-caption"
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
placeholder="理由或建议(可选),智能体按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
<TechInfo
items={[
{ label: "步骤", value: stepTitle ?? null },
{ label: "风险", value: RISK_LABEL[view.riskLevel] },
{ label: "计划", value: `${PLAN_LABEL[view.planType] ?? view.planType} · ${view.planId}`, wide: true },
{ label: "payload hash", value: view.payloadHash, wide: true },
{ label: "版本", value: `r${view.expectedRevision}` },
{ label: "到期", value: formatDateTime(view.expiresAt) },
{ label: "审批 ID", value: view.approvalId, wide: true },
]}
/>
</ApprovalCard>
);
}