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

+241 -3
View File
@@ -10,6 +10,8 @@ type Entry = {
startedAt: number;
abortedAt: number | null;
appended: RunMessage[];
/** 挂起等用户(没有结束事件时状态为 waiting,summary 用它) */
waiting?: string | null;
};
const store = new Map<string, Entry>();
@@ -239,10 +241,242 @@ function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: numb
return e;
}
// ── AGUI-3 演示:审查报告的 3 个示例(9883534f 巡检汇报、31d16a1a 方案 + 审批),截图 before / after 共用 ──
const at0 = (steps: [number, AgentRunEventBody][]): Step[] => {
let t = 0;
return steps.map(([dt, body]) => {
t += dt;
return { delayMs: t, body };
});
};
const toolPair = (
id: string,
cap: string,
input: string,
out: string,
isError = false,
): [number, AgentRunEventBody][] => [
[120, { type: "tool_call_start", toolCallId: id, capabilityId: cap, inputSummary: input, recovery: false }],
[380, { type: "tool_call_end", toolCallId: id, capabilityId: cap, isError, outputSummary: out, recovery: false }],
];
const textSteps = (text: string): [number, AgentRunEventBody][] =>
chunks(text, 40).map((c) => [20, { type: "text_delta", delta: c }]);
/** 旧汇报格式(改 prompt 之前模型写的):函数名、字段名、UUID、6 列表格 */
export const LEGACY_REPORT = `## 巡检结果
已调用 \`site.list(includeDisabled=true)\` 与 \`download.inspect\`、\`inventory.query\` 核对,结论如下:
| 站点 ID | 名称 | enabled | loginStatus | seeding | health_check |
| --- | --- | --- | --- | --- | --- |
| 062e5c51-9d1e-4c55-a5a2-3f7b1f3e0c11 | 憨憨 | true | ok | 24 | unauthorized |
- \`download.inspect\` 返回 downloader=null:下载器未配置(qb-main 未登记)。
- \`inventory.query\` 返回 mediaServer=null:媒体服务器未接入。
- \`site.health_check\` 被拒绝(缺少 site.health_check 的持续授权),未能检测连通性。
下一步:配置下载器后再调用 download.inspect;授权 site.health_check 后重试。`;
/** 新汇报格式(AGUI-3 prompt):第一句结论、用名字、≤10 行 */
export const NEW_REPORT =
"下载器和媒体服务器都还没接入,所以没法检查。站点「憨憨」正常,在做种 24 个。\n\n- 接入下载器:设置 → 下载器\n- 接入媒体服务器:设置 → 媒体服务器";
function checkupSteps(goal: string, report: string): Step[] {
const plan = (rev: number, s1: AgentPlanStep["status"], s2: AgentPlanStep["status"], s3: AgentPlanStep["status"]) =>
[
[
80,
{
type: "plan_updated",
revision: rev,
steps: [
{ id: "s1", title: "看站点状态", status: s1, note: null },
{ id: "s2", title: "核对下载器", status: s2, note: null },
{ id: "s3", title: "核对媒体服务器", status: s3, note: null },
],
},
],
] as [number, AgentRunEventBody][];
return at0([
[0, { type: "run_started", goal, resumed: false }],
...toolPair("p1", "agent.plan", '{"steps":[{"id":"s1"}]}', '{"revision":1}'),
...plan(1, "running", "pending", "pending"),
[60, { type: "thinking_delta", delta: "先列站点,再看下载器和媒体服务器有没有接入。" }],
...toolPair("c1", "site.list", '{"includeDisabled":true}', '{"items":[{"name":"憨憨","seeding":24}]}'),
...toolPair(
"c2",
"site.health_check",
'{"siteId":"062e5c51-9d1e-4c55-a5a2-3f7b1f3e0c11"}',
'{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权或有效的单次审批"}}',
true,
),
...toolPair("p2", "agent.plan", '{"steps":[{"id":"s1","status":"done"}]}', '{"revision":2}'),
...plan(2, "done", "running", "pending"),
...toolPair("c3", "download.inspect", '{"status":"downloading"}', '{"summary":"没有配置下载器"}'),
...toolPair("c4", "download.inspect", '{"status":"stalled"}', '{"summary":"没有配置下载器"}'),
[60, { type: "thinking_delta", delta: "下载器没配置。再看媒体库。" }],
...toolPair("c5", "inventory.query", "{}", '{"items":[],"mediaServer":null}'),
...toolPair("p3", "agent.plan", '{"steps":[{"id":"s3","status":"done"}]}', '{"revision":3}'),
...plan(3, "done", "done", "done"),
...textSteps(report),
[200, { type: "run_completed", finalText: report, usage: { ...USAGE, costUsd: 0.014, totalTokens: 320_700 } }],
]);
}
/** 例 3:方案绑定下载计划(选定即批准),选了之后直接提交 */
const DUNE_PLAN = "00000000-0000-4000-8000-00000000b003";
const DUNE_DECISION = "00000000-0000-4000-8000-00000000d003";
function optionSteps(goal: string): Step[] {
return at0([
[0, { type: "run_started", goal, resumed: false }],
...toolPair("p1", "agent.plan", "{}", '{"revision":1}'),
[
60,
{
type: "plan_updated",
revision: 1,
steps: [
{ id: "s1", title: "找 1080p 资源", status: "done", note: null },
{ id: "s2", title: "生成下载计划", status: "done", note: null },
{ id: "s3", title: "等你选方案", status: "running", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
],
},
],
...toolPair("c1", "release.search", '{"q":"Dune Part Two 1080p"}', '{"items":[1,2]}'),
...toolPair("c2", "download.plan", `{"sourceReleaseId":"…"}`, `{"plan":{"id":"${DUNE_PLAN}","status":"draft"}}`),
...textSteps("找到 1 个合适的 1080p 资源,站点「憨憨」的登录明天过期。"),
...toolPair("p2", "agent.propose_options", "{}", '{"outcome":"pending"}'),
[
80,
{
type: "options_proposed",
invocationId: "mock-dune-options",
decisionId: DUNE_DECISION,
question: "《沙丘:第二部》1080p 怎么收?",
options: [
{
id: "a",
title: "提交下载",
summary: "Dune.Part.Two.2024.1080p.BluRay.x264-GRP,免费,做种 41",
risks: ["站点登录明天过期,之后下载可能断", "缓存卷会用到 86%"],
estimatedCost: "12 GB",
action: {
capabilityId: "download.submit",
planId: DUNE_PLAN,
label: "提交下载",
detail: "《沙丘:第二部》· 12.0 GB",
approves: true,
},
},
{
id: "b",
title: "先修站点",
summary: "先刷新「憨憨」的登录,再提交",
risks: [],
estimatedCost: null,
action: null,
},
{ id: "c", title: "先不下", summary: "等 REMUX 出现再说", risks: [], estimatedCost: null, action: null },
],
recommendedId: "a",
stepId: "s3",
expiresAt: iso(Date.now() + 1_800_000),
timeoutAction: "recommended",
},
],
]);
}
/** 选定方案后的续跑:选了带动作的方案 → 直接提交,不出审批卡 */
function afterChoice(e: Entry, optionId: string | null) {
const base = Date.now() - e.startedAt;
const done: [number, AgentRunEventBody][] =
optionId === "a"
? [
[
100,
{
type: "options_resolved",
invocationId: "mock-dune-options",
decisionId: DUNE_DECISION,
outcome: "chosen",
choice: "a",
reason: null,
by: "user",
},
],
...toolPair("c3", "download.submit", `{"planId":"${DUNE_PLAN}"}`, '{"taskId":"…","status":"submitted"}'),
...textSteps("已提交《沙丘:第二部》1080p(12 GB),下载完成后自动整理入库。"),
[100, { type: "run_completed", finalText: "", usage: USAGE }],
]
: [
[
100,
{
type: "options_resolved",
invocationId: "mock-dune-options",
decisionId: DUNE_DECISION,
outcome: optionId ? "chosen" : "rejected",
choice: optionId,
reason: null,
by: "user",
},
],
...textSteps(optionId === "b" ? "好,先不提交,等站点登录刷新后再说。" : "好,这次先不下。"),
[100, { type: "run_completed", finalText: "", usage: USAGE }],
];
let t = base;
for (const [dt, body] of done) {
t += dt;
e.steps.push({ delayMs: t, body });
}
e.waiting = null;
}
/** mock 的方案作答 */
export function mockChoose(runId: string, optionId: string | null): RunSnapshot {
seed();
const e = store.get(runId);
if (e?.waiting === "等待选择方案") afterChoice(e, optionId);
return mockDetail(runId);
}
function seed() {
if (seeded) return;
seeded = true;
const now = Date.now();
const fixture = (n: number, goal: string, steps: Step[], ago: number, extra: Partial<Entry> = {}) => {
const e: Entry = {
run: baseRun(uuid(n), goal, { proposeFirst: false }, now - ago, "running"),
steps,
startedAt: now - ago,
abortedAt: null,
appended: [],
...extra,
};
e.run = { ...e.run, provider: "opencode-go", model: "deepseek-v4.1-flash" };
store.set(e.run.id, e);
return e;
};
const CHECKUP = "检查站点、下载器和媒体服务器是否正常";
fixture(11, `${CHECKUP}(旧汇报格式)`, checkupSteps(CHECKUP, LEGACY_REPORT), 40 * 60_000);
const parent = fixture(12, CHECKUP, checkupSteps(CHECKUP, NEW_REPORT), 30 * 60_000);
const child = fixture(
14,
"看站点「憨憨」最近的抓取记录",
checkupSteps("看站点「憨憨」最近的抓取记录", "「憨憨」最近 24 小时抓取 96 次,全部成功。"),
29 * 60_000,
);
child.run = { ...child.run, parentRunId: parent.run.id, trigger: "subagent", depth: 1 };
fixture(
13,
"[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 把 1080p 收进来",
optionSteps("把沙丘:第二部的 1080p 收进来"),
20 * 60_000,
{ waiting: "等待选择方案" },
);
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
@@ -321,7 +555,9 @@ function view(e: Entry): AgentRunView {
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
const status: AgentRunView["status"] = !end
? "running"
? e.waiting && evs.length === e.steps.length
? "waiting"
: "running"
: end.type === "run_completed"
? "succeeded"
: end.reason === "budget"
@@ -344,7 +580,7 @@ function view(e: Entry): AgentRunView {
costUsd: +(USAGE.costUsd * frac).toFixed(4),
durationMs: Date.now() - e.startedAt,
},
summary: end ? (end.type === "run_failed" ? end.message : text) : null,
summary: end ? (end.type === "run_failed" ? end.message : text) : status === "waiting" ? (e.waiting ?? null) : null,
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
finishedAt: end ? end.at : null,
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
@@ -415,7 +651,9 @@ function report(e: Entry, run: AgentRunView): GoalReport | null {
downloadTaskId: uuid(7002),
planId: uuid(7001),
status: "downloading",
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
name: run.goal.includes("1080p")
? "Dune.Part.Two.2024.1080p.BluRay.x264-GRP"
: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
},
]
: [],