feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见

- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换,
  头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗
- 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话
  (GET /agent/runs?roots=true,新增可选参数)
- 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用,
  可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs
- 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息
- 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 13:56:38 +08:00
1 parent dd0f6e2c09
commit 3692793dff
35 files changed
+1454 -1137

No files matched your search

+200 -2
View File
@@ -1,4 +1,4 @@
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentRunNode, AgentUsageTotals } from "@umt/contracts";
import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
@@ -14,6 +14,55 @@ type Entry = {
waiting?: string | null;
};
/** 子会话事件镜像进父会话(和服务端 subagents.ts 一样:spawned → 每条工具 / 开始 / 结束事件 → completed) */
function mirrorChild(parent: Step[], child: Entry, offsetMs: number): Step[] {
const out: Step[] = [...parent];
const at = (d: number) => offsetMs + d + 100;
out.push({
delayMs: offsetMs,
body: {
type: "subagent_spawned",
childRunId: child.run.id,
goal: child.run.goal,
mode: "execute",
depth: 1,
capabilities: ["site.list", "site.health_check"],
budget: {},
},
});
child.steps.forEach((st, i) => {
const b = st.body;
if (b.type === "tool_call_start" || b.type === "tool_call_end" || b.type === "run_started") {
out.push({
delayMs: at(st.delayMs),
body: {
type: "subagent_event",
childRunId: child.run.id,
childSeq: i,
childType: b.type,
...(b.type === "run_started" ? {} : { capabilityId: b.capabilityId }),
summary: "",
},
});
}
if (b.type === "run_completed" || b.type === "run_failed") {
out.push({
delayMs: at(st.delayMs) + 50,
body: {
type: "subagent_completed",
childRunId: child.run.id,
status: b.type === "run_completed" ? "succeeded" : "failed",
summary: b.type === "run_completed" ? b.finalText : b.message,
usage: b.usage,
toolCalls: child.steps.filter((x) => x.body.type === "tool_call_end").length,
durationMs: st.delayMs,
},
});
}
});
return out.sort((a, b) => a.delayMs - b.delayMs);
}
const store = new Map<string, Entry>();
let seeded = false;
@@ -470,6 +519,130 @@ function seed() {
29 * 60_000,
);
child.run = { ...child.run, parentRunId: parent.run.id, trigger: "subagent", depth: 1 };
{
// 父会话第一次看站点之后派出子会话(子会话比父会话晚 1 分钟开始)
const spawnAt = parent.steps.findIndex(
(x) => x.body.type === "tool_call_end" && x.body.capabilityId === "site.list",
);
const base = parent.steps[spawnAt]?.delayMs ?? 1000;
parent.steps = mirrorChild(
[
...parent.steps.slice(0, spawnAt + 1),
...at0([
[base + 50, { type: "text_delta", delta: "派一个子会话去看「憨憨」的抓取记录。" }],
[
60,
{
type: "tool_call_start",
toolCallId: "sp1",
capabilityId: "agent.spawn",
inputSummary: '{"goal":"看站点「憨憨」最近的抓取记录"}',
recovery: false,
},
],
[
60,
{
type: "tool_call_end",
toolCallId: "sp1",
capabilityId: "agent.spawn",
isError: false,
outputSummary: `{"runId":"${child.run.id}"}`,
recovery: false,
},
],
]),
...parent.steps.slice(spawnAt + 1).map((x) => ({ ...x, delayMs: x.delayMs + 60_000 })),
],
child,
base + 200,
);
}
// 运行中的会话:派出两个子会话,一个很快查完,一个还在跑(看子会话卡片的实时样子)
{
const goal = "检查所有站点的登录状态和最近的抓取";
const live = fixture(15, goal, [], 0);
const quick = fixture(16, "看站点「憨憨」最近的抓取记录", [], 0);
const slow = fixture(17, "逐个检测站点「观众」「憨憨」的登录状态", [], 0);
for (const c of [quick, slow]) c.run = { ...c.run, parentRunId: live.run.id, trigger: "subagent", depth: 1 };
quick.startedAt = live.startedAt + 2_000;
slow.startedAt = live.startedAt + 2_100;
quick.steps = at0([
[0, { type: "run_started", goal: quick.run.goal, resumed: false }],
...toolPair("q1", "site.list", "{}", '{"items":[{"name":"憨憨"}]}'),
[2_000, { type: "text_delta", delta: "「憨憨」最近 24 小时抓取 96 次,全部成功。" }],
...toolPair("q2", "site.list", "{}", '{"items":[{"name":"憨憨"}]}'),
[
3_000,
{
type: "run_completed",
finalText: "「憨憨」最近 24 小时抓取 96 次,全部成功。",
usage: { ...USAGE, totalTokens: 8_200, costUsd: 0.006 },
},
],
]);
slow.steps = at0([
[0, { type: "run_started", goal: slow.run.goal, resumed: false }],
...toolPair("w1", "site.list", "{}", '{"items":[{"name":"观众"},{"name":"憨憨"}]}'),
[400, { type: "text_delta", delta: "先测「观众」。" }],
[
100,
{
type: "tool_call_start",
toolCallId: "w2",
capabilityId: "site.health_check",
inputSummary: '{"site":"观众"}',
recovery: false,
},
],
[
600_000,
{
type: "tool_call_end",
toolCallId: "w2",
capabilityId: "site.health_check",
isError: false,
outputSummary: "{}",
recovery: false,
},
],
[1_000, { type: "run_completed", finalText: "两个站点登录都正常。", usage: USAGE }],
]);
const spawn = (id: string, g: string, out: string): [number, AgentRunEventBody][] => [
[
100,
{
type: "tool_call_start",
toolCallId: id,
capabilityId: "agent.spawn",
inputSummary: `{"goal":"${g}"}`,
recovery: false,
},
],
[
100,
{
type: "tool_call_end",
toolCallId: id,
capabilityId: "agent.spawn",
isError: false,
outputSummary: `{"runId":"${out}"}`,
recovery: false,
},
],
];
let steps: Step[] = at0([
[0, { type: "run_started", goal, resumed: false }],
...textSteps("两件事互不相关,各派一个子会话去查。"),
...spawn("sa", quick.run.goal, quick.run.id),
...spawn("sb", slow.run.goal, slow.run.id),
[620_000, { type: "text_delta", delta: "两个站点登录都正常,「憨憨」最近抓取全部成功。" }],
[100, { type: "run_completed", finalText: "两个站点登录都正常。", usage: USAGE }],
]);
steps = mirrorChild(steps, quick, 2_000);
steps = mirrorChild(steps, slow, 2_100);
live.steps = steps;
}
fixture(
13,
"[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 把 1080p 收进来",
@@ -619,7 +792,32 @@ export function mockDetail(id: string, op?: { abort?: boolean; append?: string }
e.steps.sort((a, b) => a.delayMs - b.delayMs);
}
const run = view(e);
return { run, messages: e.appended, report: report(e, run) };
const children = [...store.values()].filter((c) => c.run.parentRunId === id).map((c) => node(view(c)));
return {
run,
messages: e.appended,
report: report(e, run),
children,
...(run.parentRunId && store.get(run.parentRunId) ? { parent: node(view(store.get(run.parentRunId)!)) } : {}),
};
}
function node(r: AgentRunView): AgentRunNode {
return {
id: r.id,
parentRunId: r.parentRunId,
goal: r.goal,
status: r.status,
depth: r.depth,
spawnReason: r.spawnReason,
toolCallCount: r.toolCallCount,
usage: r.usage,
summary: r.summary,
capabilities: [],
budget: r.budget,
createdAt: r.createdAt,
finishedAt: r.finishedAt,
};
}
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */