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:
1 parent
2ab6d452dd
commit
6fcf82afb0
17 files changed
+2381
-1846
No files matched your search
@@ -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",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
||||
Reference in new issue
Block a user