From c67fd96639e9c23246e6b256a455f9f6830c5a6f Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:23:30 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(web):=20=E8=AE=A1=E5=88=92=E9=A1=B5?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E5=B7=A1=E6=A3=80=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=B0=BA=E5=AF=B8,=E5=A4=9A=E5=87=BA?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E7=9C=81=E7=95=A5=E3=80=81=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=8A=BD=E5=B1=89=E7=9C=8B=E5=85=A8=E6=96=87(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../components/schedules/agent-check-card.tsx | 329 +++++++++++------- .../components/schedules/schedule-board.tsx | 2 +- 2 files changed, 197 insertions(+), 134 deletions(-) diff --git a/apps/web/app/components/schedules/agent-check-card.tsx b/apps/web/app/components/schedules/agent-check-card.tsx index ed147829..bd07be18 100644 --- a/apps/web/app/components/schedules/agent-check-card.tsx +++ b/apps/web/app/components/schedules/agent-check-card.tsx @@ -1,13 +1,13 @@ import { type AgentCheckConfig, CAPABILITY_CATALOG, type KnownCapabilityId, type ScheduledJob } from "@umt/contracts"; -import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react"; -import { type ReactNode, useEffect, useRef } from "react"; +import { ChevronRight, History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { useDeleteSchedule } from "~/api/schedules.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { DetailSheet } from "~/components/console/detail-sheet.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { TechInfo } from "~/components/console/tech-info.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -19,10 +19,8 @@ import { IntervalSelect } from "./interval-select.tsx"; import { CHECK_TRIGGER_LABEL, formatUsd, goalTemplateParts, RUN_STATUS } from "./labels.ts"; // 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。 -// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。 - -/** 卡片上能力白名单最多列几个,其余折成"+N" */ -const MAX_CAPS = 6; +// 卡片统一尺寸(FIX-9):名称 / 描述 / 目标各一行省略,最近汇总(或失败原因)固定 2 行,模型与费用一行, +// 操作按钮贴底,网格按行等高。完整内容(目标、能力、预算、模型、最近会话)在「详情」抽屉里看。 /** 会话摘要是 Markdown:列表里只取纯文字 */ const plainSummary = (t: string) => @@ -44,6 +42,12 @@ export type DefaultUpstream = { const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) => models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n"); +/** 目标模板的纯文字版(悬浮提示用),占位符写成名称 */ +const goalPlain = (template: string) => + goalTemplateParts(template) + .map((p) => ("text" in p ? p.text : `[${p.label}]`)) + .join(""); + /** 目标模板:占位符显示成名称标签(执行时替换成实际值),不露 `{{now}}` */ function GoalTemplate({ template }: { template: string }) { return ( @@ -96,27 +100,22 @@ export function AgentCheckCard({ if (highlight) ref.current?.scrollIntoView({ block: "center" }); }, [highlight]); + const [detailOpen, setDetailOpen] = useState(false); const c = job.config as Partial; - const budget = c.budget ?? {}; - const allow = c.capabilityAllowlist ?? []; const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null; // 费用 / 模型由后端按最近 100 次会话汇总(GET /schedules 的 agentRuns) const stats = job.agentRuns; - const runs = stats?.recent ?? []; - const budgetParts = [ - budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null, - budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null, - budget.maxTokens ? `${formatTokens(budget.maxTokens)} token` : null, - budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null, - ].filter(Boolean); + const latest = stats?.recent[0]; + const failed = job.lastStatus === "failed" && !!job.lastError; return (
@@ -125,9 +124,9 @@ export function AgentCheckCard({

{job.name}

- {job.description ? ( -

{job.description}

- ) : null} +

+ {job.description} +

-
+
- + 下次
-

- {last ? ( - job.lastStatus === "ok" ? ( - 上次成功 + +

+

+ {last ? ( + job.lastStatus === "ok" ? ( + 上次成功 + ) : ( + {last.label} + ) ) : ( - {last.label} - ) - ) : ( - 还没运行过 - )} - {job.lastRunAt ? {formatRelative(job.lastRunAt)} : null} - {stats?.count ? · 每次约 {formatUsd(stats.avgCostUsd)} : null} -

+ 还没运行过 + )} + {job.lastRunAt ? {formatRelative(job.lastRunAt)} : null} +

+

+ 目标 + + {c.goalTemplate ? : "—"} + +

+

+ {failed ? `失败原因:${job.lastError}` : latest ? plainSummary(latest.summary ?? latest.goal) : null} +

+

+ + + + {stats?.count ? · 每次约 {formatUsd(stats.avgCostUsd)} : null} + +

+
- {job.lastStatus === "failed" && job.lastError ? ( -
- 失败原因:{job.lastError} -

{job.lastError}

-
- ) : null} - - -
- - - {c.goalTemplate ? : "—"} - - - - {allow.length ? ( - - {allow.slice(0, MAX_CAPS).map((id) => ( - - {capTitle(id)} - - ))} - {allow.length > MAX_CAPS ? ( - - +{allow.length - MAX_CAPS} - - ) : null} - - ) : ( - "全部只读能力" - )} - {c.allowRecover ? · 允许授权内恢复 : null} - - - {CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]} - {c.reportAsDecision ? · 结果写入待处理 : null} - - {budgetParts.length ? budgetParts.join(" · ") : "默认"} - - {stats?.lastModel ? ( - - {stats.lastProvider ? `${stats.lastProvider} · ` : ""} - {stats.lastModel} - {stats.models.length > 1 ? ( - 等 {stats.models.length} 个模型 - ) : null} - - ) : upstream ? ( - - {upstream.name} · {upstream.model ?? "模型未指定"} - - ) : ( - 未配置模型服务 - )} - - {stats?.count ? ( - - - 上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)} - - - ) : null} -
- {runs.length ? ( -
-

最近会话

-
    - {runs.slice(0, 3).map((r) => { - const st = agentRunLabel(r.status); - return ( -
  • - - {r.status === "succeeded" ? null : {st.label}} - - {plainSummary(r.summary ?? r.goal)} - - - {formatRelative(r.finishedAt ?? r.createdAt)} - - -
  • - ); - })} -
-
- ) : null} -
- -
+
+ +
); } + +function ModelText({ stats, upstream }: { stats: ScheduledJob["agentRuns"]; upstream: DefaultUpstream }) { + if (stats?.lastModel) { + return ( + <> + {stats.lastProvider ? `${stats.lastProvider} · ` : ""} + {stats.lastModel} + {stats.models.length > 1 ? ( + 等 {stats.models.length} 个模型 + ) : null} + + ); + } + if (upstream) { + return ( + <> + {upstream.name} · {upstream.model ?? "模型未指定"} + + ); + } + return 未配置模型服务; +} + +/** 「详情」抽屉:卡片上省略掉的内容全在这里(目标全文、能力、触发条件、预算、模型、费用、失败原因、最近会话) */ +function AgentCheckDetail({ + job, + upstream, + open, + onOpenChange, +}: { + job: ScheduledJob; + upstream: DefaultUpstream; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const c = job.config as Partial; + const budget = c.budget ?? {}; + const allow = c.capabilityAllowlist ?? []; + const stats = job.agentRuns; + const runs = stats?.recent ?? []; + const budgetParts = [ + budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null, + budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null, + budget.maxTokens ? `${formatTokens(budget.maxTokens)} token` : null, + budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null, + ].filter(Boolean); + + return ( + + {job.lastStatus === "failed" && job.lastError ? ( +

+ 失败原因:{job.lastError} +

+ ) : null} +
+ + {c.goalTemplate ? : "—"} + + + {allow.length ? ( + + {allow.map((id) => ( + + {capTitle(id)} + + ))} + + ) : ( + "全部只读能力" + )} + {c.allowRecover ? · 允许授权内恢复 : null} + + + {CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]} + {c.reportAsDecision ? · 结果写入待处理 : null} + + {budgetParts.length ? budgetParts.join(" · ") : "默认"} + + + + + + {stats?.count ? ( + + + 上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)} + + + ) : null} +
+ {runs.length ? ( +
+

最近会话

+
    + {runs.slice(0, 3).map((r) => { + const st = agentRunLabel(r.status); + return ( +
  • + + + {r.status === "succeeded" ? null : {st.label}} + {formatRelative(r.finishedAt ?? r.createdAt)} + + {plainSummary(r.summary ?? r.goal)} + +
  • + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/schedules/schedule-board.tsx b/apps/web/app/components/schedules/schedule-board.tsx index 1330c7bd..73073481 100644 --- a/apps/web/app/components/schedules/schedule-board.tsx +++ b/apps/web/app/components/schedules/schedule-board.tsx @@ -169,7 +169,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) { {checks.length === 0 ? ( ) : ( -
+
{checks.map((j) => ( Date: Wed, 30 Sep 2026 19:26:28 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(web):=20=E4=BC=9A=E8=AF=9D=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E7=BB=93=E6=9E=9C=E8=A1=8C=E5=8F=AA=E5=88=97=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=A0=B8=E5=AE=9E=E7=9A=84=E4=BA=8B=E5=AE=9E,?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6=E6=A0=87=E8=AE=B0(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉「说明」「汇总」与「未经核查」;没有动作时一行小字,有动作时列提交的下载与完成的整理并可点到记录, 汇报里的数字与系统记录不一致时行尾加「与汇报不一致」。统计行已有费用时不重复。 Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 153 +++++++++++------- .../app/components/agent/agent-run-panel.tsx | 1 + apps/web/app/components/agent/agui3.test.ts | 101 +++++++++++- 3 files changed, 193 insertions(+), 62 deletions(-) diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 3bb8e1d8..7b4a5b87 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -588,101 +588,134 @@ export function AgentConversation({ // ── 实际结果 ── -/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */ -export function resultText(report: GoalReport | null): string { - if (!report) return "汇总中…"; +type ResultFact = { text: string; to?: string; tone?: "warning" }; + +/** + * 实际结果一行:系统核实的事实(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本)。 + * 没有动作时只有「没有提交下载或整理」;有动作时「提交了 2 个下载、完成 1 次整理」,只有一条记录的项直接链到它。 + */ +export function resultFacts(report: GoalReport | null): ResultFact[] { + if (!report) return [{ text: "汇总中…" }]; const r = report; - switch (r.state) { - case "no_actions": - return "没有提交下载或整理"; - case "waiting": - return r.imported - ? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行` - : `${r.submitted || r.inProgress} 个下载已提交,还没入库`; - case "completed": - return `已入库 ${r.imported} 个`; - case "partial": - return [ - r.imported ? `已入库 ${r.imported} 个` : "", - r.inProgress ? `${r.inProgress} 个还在进行` : "", - r.failed ? `${r.failed} 个失败` : "", - ] - .filter(Boolean) - .join(","); - case "failed": - return r.failed ? `${r.failed} 个没有成功` : "没有达成目标"; + if (r.state === "no_actions") return [{ text: "没有提交下载或整理" }]; + const downloads = Math.max(r.submitted, r.plans.length); + const done = r.organization.filter((o) => o.status === "succeeded" || o.status === "partial"); + const facts: ResultFact[] = []; + if (downloads) { + const only = r.downloads.length === 1 ? r.downloads[0] : undefined; + facts.push({ + text: `提交了 ${downloads} 个下载`, + to: only ? `/console/downloads/${only.downloadTaskId}` : r.downloads.length ? "/console/downloads" : undefined, + }); } + if (done.length) { + const only = done.length === 1 ? done[0] : undefined; + facts.push({ + text: `完成 ${done.length} 次整理`, + to: only ? `/console/organize/${only.recordId}` : "/console/organize", + }); + } + if (r.imported && r.imported !== done.length) facts.push({ text: `已入库 ${r.imported} 个` }); + if (r.inProgress) facts.push({ text: `${r.inProgress} 个还在进行` }); + if (r.failed) facts.push({ text: `${r.failed} 个没有成功`, tone: "warning" }); + return facts.length ? facts : [{ text: "没有提交下载或整理" }]; +} + +/** 模型汇报里写明的数字(只认「提交了 N 个」「整理了 N 个」这类明确说法;没写数字的不算) */ +export function claimedCounts(text: string | null): { downloads?: number; organized?: number } { + if (!text) return {}; + const out: { downloads?: number; organized?: number } = {}; + const dl = text.match(/(?:提交|添加|推送|开始下载)了?\s*(\d+)\s*(?:个|项|部|集)/); + if (dl?.[1]) out.downloads = Number(dl[1]); + const org = + text.match(/整理了?\s*(\d+)\s*(?:个|次|项|部|集)/) ?? text.match(/(\d+)\s*(?:个|次|项)[^,。,.\n]{0,4}整理/); + if (org?.[1]) out.organized = Number(org[1]); + return out; +} + +/** 汇报里的数字与系统记录不一致的地方;一致或汇报没写数字时为空 */ +export function reportMismatch(report: GoalReport | null, text: string | null): string[] { + if (!report) return []; + const c = claimedCounts(text); + const downloads = Math.max(report.submitted, report.plans.length); + const organized = report.organization.filter((o) => o.status === "succeeded" || o.status === "partial").length; + const out: string[] = []; + if (c.downloads !== undefined && c.downloads !== downloads) + out.push(`汇报说提交了 ${c.downloads} 个下载,系统记录是 ${downloads} 个`); + if (c.organized !== undefined && c.organized !== organized) + out.push(`汇报说整理了 ${c.organized} 个,系统记录完成 ${organized} 次`); + return out; } /** - * 回答下面一行实际结果:「没有提交下载或整理 · 41 秒 · 约 $0.01 · 技术信息 ›」。 - * 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。 + * 回答下面一行实际结果:「没有提交下载或整理 · 41 秒」或「提交了 2 个下载、完成 1 次整理 · 41 秒」。 + * 数字与汇报不一致时行尾加「与汇报不一致」(悬停看说明,技术信息里也有);模型、token、会话 ID 进技术信息。 */ export function ResultLine({ run, report, live, subagents = 0, + showCost = true, }: { run: AgentRunView; report: GoalReport | null; live: AgentStreamState; /** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */ subagents?: number; + /** 上方统计行已显示费用时传 false,不重复 */ + showCost?: boolean; }) { const u = { ...run.usage, ...(live.usage ?? {}) }; const durationMs = u.durationMs ?? (run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null); const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); - const links = report - ? [ - ...report.downloads.map((d) => ({ - id: d.downloadTaskId, - label: d.name ?? "下载任务", - to: `/console/downloads/${d.downloadTaskId}`, - })), - ...report.organization.map((o) => ({ - id: o.recordId, - label: o.targetPath?.split("/").pop() || "整理记录", - to: `/console/organize/${o.recordId}`, - })), - ] - : []; - const bad = report?.state === "failed" || report?.state === "partial"; + const facts = resultFacts(report); const last = lastReportText(live.items); const rawReport = last && humanizeReport(last).replaced.length ? last : null; + const mismatch = reportMismatch(report, last ?? report?.modelText ?? null); const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null; return (

{report ? null : } - 实际结果:{resultText(report)} + + {facts.map((f, i) => ( + + {i ? "、" : ""} + {f.to ? ( + + {f.text} + + ) : ( + {f.text} + )} + + ))} + {subagents ? · 派出 {subagents} 个子会话 : null} {durationMs ? · {durationLabel(durationMs)} : null} - {u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} + {showCost && u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} + {mismatch.length ? ( + + 与汇报不一致 + + ) : null}

- {links.length ? ( -
    - {links.slice(0, 4).map((l) => ( -
  • - - {l.label} - -
  • - ))} - {links.length > 4 ?
  • 等 {links.length} 项
  • : null} -
- ) : null} {err ?

{err}

: null} c.parentRunId === r.id).length} + showCost={false} /> ) : null} diff --git a/apps/web/app/components/agent/agui3.test.ts b/apps/web/app/components/agent/agui3.test.ts index 0d8b7568..bda0c88f 100644 --- a/apps/web/app/components/agent/agui3.test.ts +++ b/apps/web/app/components/agent/agui3.test.ts @@ -4,9 +4,18 @@ import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; -import type { AgentRunView } from "~/api/agent.ts"; +import type { AgentRunView, GoalReport } from "~/api/agent.ts"; import { qk } from "~/lib/query-keys.ts"; -import { AgentConversation, contextLabel, DeniedLines, ResultLine, StepSummary } from "./agent-conversation.tsx"; +import { + AgentConversation, + claimedCounts, + contextLabel, + DeniedLines, + ResultLine, + reportMismatch, + resultFacts, + StepSummary, +} from "./agent-conversation.tsx"; import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx"; import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx"; import { arrangeHistory } from "./agent-session-list.tsx"; @@ -442,3 +451,91 @@ describe("会话统计与本轮状态(AG-4)", () => { expect(t).toContain("工具调用 1 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03"); }); }); + +describe("实际结果行(FIX-9):只列系统核实的事实", () => { + const ID = (n: number) => `00000000-0000-4000-8000-00000000010${n}`; + const report = (o: Partial): GoalReport => ({ + state: "no_actions", + summary: "本次没有提交下载或整理,结论见模型汇报。", + submitted: 0, + imported: 0, + inProgress: 0, + failed: 0, + plans: [], + downloads: [], + organization: [], + modelText: null, + generatedAt: AT, + ...o, + }); + const done = report({ + state: "completed", + summary: "已提交下载 2 个", + submitted: 2, + imported: 1, + inProgress: 1, + downloads: [ + { downloadTaskId: ID(1), planId: null, status: "completed", name: "A" }, + { downloadTaskId: ID(2), planId: null, status: "downloading", name: "B" }, + ], + organization: [ + { recordId: ID(3), status: "succeeded", downloadTaskId: ID(1), mediaEntityId: null, targetPath: "/m/A" }, + ], + }); + const withText = (t: string) => + liveOf([ + ev(0, { type: "run_started", goal: "g", resumed: false }), + ev(1, { type: "text_delta", delta: t }), + ev(2, { type: "run_completed", finalText: "", usage: { costUsd: 0.01 } }), + ]); + + it("没有动作:一行小字 + 时长 + 费用,不再有「说明」「汇总」「未经核查」", () => { + const html = render( + createElement(ResultLine, { + run: { ...RUN, usage: { costUsd: 0.01 } }, + report: report({}), + live: withText("都正常。"), + }), + ); + const t = text(html); + expect(t).toContain("没有提交下载或整理 · 41 秒 · 约 $0.01"); + expect(t).not.toContain("说明"); + expect(t).not.toContain("汇总"); + expect(t).not.toContain("未经核查"); + expect(t).not.toContain("实际结果:"); + expect(t).not.toContain("结论见模型汇报"); + }); + it("统计行已显示费用时不重复", () => { + const t = text( + render( + createElement(ResultLine, { + run: { ...RUN, usage: { costUsd: 0.01 } }, + report: report({}), + live: withText("x"), + showCost: false, + }), + ), + ); + expect(t).toContain("没有提交下载或整理 · 41 秒"); + expect(t).not.toContain("$0.01"); + }); + it("有动作:列事实,单条记录链到记录本身,多条链到列表", () => { + const html = render( + createElement(ResultLine, { run: RUN, report: done, live: withText("提交了 2 个下载,整理了 1 个。") }), + ); + expect(text(html).replace(/\s*、\s*/g, "、")).toContain("提交了 2 个下载、完成 1 次整理、1 个还在进行 · 41 秒"); + expect(html).toContain(`href="/console/organize/${ID(3)}"`); + expect(html).toContain('href="/console/downloads"'); + expect(html).not.toContain("与汇报不一致"); + }); + it("数字与汇报不一致:行尾加标记,悬停和技术信息里说明", () => { + const html = render(createElement(ResultLine, { run: RUN, report: done, live: withText("已提交了 3 个下载。") })); + expect(html).toContain('data-slot="report-mismatch"'); + expect(html).toContain("汇报说提交了 3 个下载,系统记录是 2 个"); + }); + it("汇报没写数字时不判不一致", () => { + expect(reportMismatch(done, "下载都安排好了。")).toEqual([]); + expect(claimedCounts("整理了 4 部电影")).toEqual({ organized: 4 }); + expect(resultFacts(null)).toEqual([{ text: "汇总中…" }]); + }); +}); From c5987a9db3f2ae8b4622ede6ba7c2d512d4d93d0 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:27:43 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(web):=20=E4=BC=9A=E8=AF=9D=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E8=A1=8C=E6=97=B6=E9=95=BF=E4=BB=8E=E6=9C=AC=E8=BD=AE?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E7=AE=97=E5=88=B0=E7=BB=93=E8=AE=BA=E6=AD=A3?= =?UTF-8?q?=E6=96=87,=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA=200=20=E7=A7=92(?= =?UTF-8?q?FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 28 ++++++++++++--- apps/web/app/components/agent/agui3.test.ts | 34 ++++++++++++++++++- .../components/agent/conversation-model.ts | 24 +++++++++---- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 7b4a5b87..68218be0 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -54,6 +54,7 @@ import { activitySummary, authDenial, buildConversation, + type ConvBlock, callCounts, callCountText, capabilityTitle, @@ -261,10 +262,16 @@ export function StepSummary({ activity, running, auditByCall, + since, + until, }: { activity: ActivityItem[]; running: boolean; auditByCall?: Map; + /** 这一轮开始的时间(会话开始 / 用户消息);只给回合里的第一段 */ + since?: string | null; + /** 结论正文开始的时间 */ + until?: string | null; }) { const id = useId(); const [open, setOpen] = useState(false); @@ -273,7 +280,7 @@ export function StepSummary({ const done = activitySummary(calls.filter((c) => c.status !== "running")); const denied = calls.filter(isDenied).length; const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length; - const span = activitySpan(activity); + const span = activitySpan(activity, { since, until }); const counted = running ? "" : callCountText(callCounts(calls), "调用"); const thinking = activity.some((a) => a.kind === "thinking"); const head = current @@ -510,12 +517,12 @@ export function AgentConversation({ return (
- {blocks.map((b) => + {blocks.map((b, bi) => b.kind === "user" ? ( ) : (
- {b.parts.map((p) => { + {b.parts.map((p, pi) => { const fresh = freshKeys.has(p.key.slice(1)) || freshKeys.has(p.key); switch (p.kind) { case "segment": { @@ -525,7 +532,13 @@ export function AgentConversation({ return (
{p.activity.length ? ( - + ) : null} {p.body.map((t) => { const streaming = streamingText && t.key === lastKey; @@ -586,6 +599,13 @@ export function AgentConversation({ ); } +/** 回合开始的时间:前一条用户消息;第一轮是会话开始 */ +function turnSince(blocks: ConvBlock[], index: number, run: AgentRunView): string | null { + const prev = blocks[index - 1]; + if (prev?.kind === "user") return prev.at; + return index === 0 ? run.startedAt : null; +} + // ── 实际结果 ── type ResultFact = { text: string; to?: string; tone?: "warning" }; diff --git a/apps/web/app/components/agent/agui3.test.ts b/apps/web/app/components/agent/agui3.test.ts index bda0c88f..df5e6380 100644 --- a/apps/web/app/components/agent/agui3.test.ts +++ b/apps/web/app/components/agent/agui3.test.ts @@ -19,7 +19,13 @@ import { import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx"; import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx"; import { arrangeHistory } from "./agent-session-list.tsx"; -import { type ActivityItem, activitySummary, buildConversation, deniedReason } from "./conversation-model.ts"; +import { + type ActivityItem, + activitySpan, + activitySummary, + buildConversation, + deniedReason, +} from "./conversation-model.ts"; import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts"; import { type AgentStreamState, @@ -539,3 +545,29 @@ describe("实际结果行(FIX-9):只列系统核实的事实", () => { expect(resultFacts(null)).toEqual([{ text: "汇总中…" }]); }); }); + +describe("进度行时长(FIX-9):从这一轮开始算到结论正文", () => { + const quick: ActivityItem[] = [ + { kind: "tool", key: "c1", call: call({ capabilityId: "site.list", startedAt: at(6), endedAt: at(6) }) }, + ]; + it("只有一个瞬时工具调用时不再是 0 秒", () => { + expect(activitySpan(quick)).toBe(0); + expect(activitySpan(quick, { since: at(0), until: at(7) })).toBe(7000); + const t = text(render(createElement(StepSummary, { activity: quick, running: false, since: at(0), until: at(7) }))); + expect(t).toContain("· 7 秒"); + }); + it("没有工具调用、只有思考时也有时长;没有步骤为 null", () => { + expect(activitySpan([{ kind: "thinking", key: "k", text: "想", at: at(2) }], { since: at(0) })).toBe(2000); + expect(activitySpan([])).toBeNull(); + }); + it("整段会话:第一轮从会话开始算", () => { + const events = [ + ev(0, { type: "run_started", goal: "g", resumed: false }), + ...tool(5, "t1", "site.list", '{"items":[]}'), + ev(7, { type: "text_delta", delta: "都正常。" }), + ev(8, { type: "run_completed", finalText: "", usage: {} }), + ]; + const t = text(render(createElement(AgentConversation, { run: RUN, live: liveOf(events) }))); + expect(t).toMatch(/· 7 秒/); + }); +}); diff --git a/apps/web/app/components/agent/conversation-model.ts b/apps/web/app/components/agent/conversation-model.ts index babd2d12..73e47080 100644 --- a/apps/web/app/components/agent/conversation-model.ts +++ b/apps/web/app/components/agent/conversation-model.ts @@ -291,13 +291,23 @@ export function buildConversation( return out; } -/** 一段进度的起止时间(毫秒);没有工具调用为 null */ -export function activitySpan(activity: ActivityItem[]): number | null { - const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])); - if (!calls.length) return null; - const start = Math.min(...calls.map((c) => Date.parse(c.startedAt))); - const end = Math.max(...calls.map((c) => Date.parse(c.endedAt ?? c.startedAt))); - return Number.isFinite(start) && Number.isFinite(end) ? end - start : null; +/** + * 一段进度的时长(毫秒):从这一轮开始(since,如会话开始 / 用户消息的时间)或首个步骤, + * 到最后一个步骤结束或结论正文开始(until)。只算工具起止会漏掉模型思考的时间(整轮 7 秒显示成 0 秒)。 + * 没有任何步骤为 null。 + */ +export function activitySpan( + activity: ActivityItem[], + bounds: { since?: string | null; until?: string | null } = {}, +): number | null { + if (!activity.length) return null; + const starts = activity.map((a) => Date.parse(a.kind === "tool" ? a.call.startedAt : a.at)); + const ends = activity.map((a) => Date.parse(a.kind === "tool" ? (a.call.endedAt ?? a.call.startedAt) : a.at)); + const since = bounds.since ? Date.parse(bounds.since) : Number.NaN; + const until = bounds.until ? Date.parse(bounds.until) : Number.NaN; + const start = Math.min(...starts.filter(Number.isFinite), ...(Number.isFinite(since) ? [since] : [])); + const end = Math.max(...ends.filter(Number.isFinite), ...(Number.isFinite(until) ? [until] : [])); + return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null; } /** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */ From 65a2ed7d498103056e6c74419df59a000b2679c7 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:28:51 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(web):=20=E4=BC=9A=E8=AF=9D=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E6=98=8E=E7=BB=86=E9=87=8C=E7=9A=84=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=20ID=20=E6=8D=A2=E6=88=90=E8=83=BD=E5=8A=9B=E6=A0=87=E9=A2=98,?= =?UTF-8?q?=E4=B8=8E=E6=9C=80=E7=BB=88=E6=B1=87=E6=8A=A5=E4=B8=80=E8=87=B4?= =?UTF-8?q?(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 6 ++++-- apps/web/app/components/agent/agui3.test.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 68218be0..2f9d6512 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -196,7 +196,9 @@ function ToolRow({ call, audit }: { call: ToolCallView; audit?: InvocationAudit const [open, setOpen] = useState(false); const meta = capabilityMeta(call.capabilityId); const denied = isDenied(call); - const summary = denied ? deniedReason(call).text : toolSummary(call); + const raw = denied ? deniedReason(call).text : toolSummary(call); + // 旧会话的错误文字里带能力 ID / 原因码:和最终汇报一样换成能力标题与人话 + const summary = raw ? humanizeReport(raw).text : raw; return (
  • ), )} diff --git a/apps/web/app/components/agent/agui3.test.ts b/apps/web/app/components/agent/agui3.test.ts index df5e6380..18bac1ba 100644 --- a/apps/web/app/components/agent/agui3.test.ts +++ b/apps/web/app/components/agent/agui3.test.ts @@ -571,3 +571,24 @@ describe("进度行时长(FIX-9):从这一轮开始算到结论正文", () => { expect(t).toMatch(/· 7 秒/); }); }); + +describe("步骤明细(FIX-9):能力 ID 换成能力标题", () => { + it("失败原因与工具之间的话里的能力 ID 走能力标题", () => { + const activity: ActivityItem[] = [ + { kind: "note", key: "n1", text: "site.health_check 被拒,改用 site.list。", at: AT }, + { + kind: "tool", + key: "c1", + call: call({ + capabilityId: "download.inspect", + status: "failed", + outputSummary: '{"error":{"code":"x","message":"site.health_check 调用失败"}}', + }), + }, + ]; + const t = text(render(createElement(StepSummary, { activity, running: false }))); + expect(t).not.toContain("site.health_check"); + expect(t).not.toContain("site.list"); + expect(t).toContain("检测站点 被拒"); + }); +}); From 3ec2a10993d582d40a198b8794e7cc38b301e7cf Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:29:54 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs(agent):=20=E5=B7=A1=E6=A3=80=20Skill?= =?UTF-8?q?=20=E8=A6=81=E6=B1=82=E9=9C=80=E8=A6=81=E6=89=B9=E5=87=86?= =?UTF-8?q?=E7=9A=84=E6=A3=80=E6=9F=A5=E5=AE=9E=E9=99=85=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E4=B8=80=E6=AC=A1,=E8=A1=A5=20e16=20=E6=96=AD=E8=A8=80(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/server/test/e2e/e16-agent-prompt-eval.test.ts | 5 +++++ packages/agent/skills/patrol/SKILL.md | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/server/test/e2e/e16-agent-prompt-eval.test.ts b/apps/server/test/e2e/e16-agent-prompt-eval.test.ts index b7d8619e..83f85e02 100644 --- a/apps/server/test/e2e/e16-agent-prompt-eval.test.ts +++ b/apps/server/test/e2e/e16-agent-prompt-eval.test.ts @@ -90,6 +90,11 @@ describe.skipIf(!hasPg)("E16 智能体提示评测(fake 模型)", () => { .filter((x) => x.type === "tool_call_start") .map((x) => (x.payload as { capabilityId?: string }).capabilityId); expect(calls).toEqual(c.calls); + // 巡检那条:需要批准的检测实际调用一次(界面才有审批卡 /「批准并重试」),汇报里写「没做:…,需要你批准」 + if (id === "health") { + expect(calls.filter((x) => x === "site.health_check")).toHaveLength(1); + expect(done.get(id)!.text).toMatch(/没做:.*需要你批准/); + } // 只读调用都成功;需要批准的被拒绝 const r = done.get(id)!; diff --git a/packages/agent/skills/patrol/SKILL.md b/packages/agent/skills/patrol/SKILL.md index 4b80383a..4c741760 100644 --- a/packages/agent/skills/patrol/SKILL.md +++ b/packages/agent/skills/patrol/SKILL.md @@ -38,6 +38,7 @@ sessions: [chat, patrol, investigate, child] ## 需要批准 - site.health_check(实时检测站点)、site.refresh_login(重新登录)。没有授权时会被拒绝:汇报里写一句「没做:实时检测「站点名」,需要你批准」,界面会给批准按钮。 +- 需要批准的检查(如检测站点)不要只在汇报里写「需要你批准」:先实际调用一次,让审批卡 /「批准并重试」按钮出现在界面上,再在汇报里写一句「没做:…,需要你批准」。同一个检查只调用一次,被拒绝后不要重复调用。 - 其余都是只读,不需要批准。 ## 汇报模板 From 6f450ed3296d193af9ad1a23bb26933a8ddeb392 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:30:12 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(web):=20=E8=BF=90=E8=A1=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=A1=B5=E8=BF=9B=E7=A8=8B=E5=90=8D=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=85=A8=E8=A7=92=E6=8B=AC=E5=8F=B7,io=20=E6=94=B9=E7=A7=B0?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/web/app/routes/console/status.tsx | 6 +++--- docs/design/glossary.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx index f6db602d..9ca79b74 100644 --- a/apps/web/app/routes/console/status.tsx +++ b/apps/web/app/routes/console/status.tsx @@ -43,15 +43,15 @@ const LEVEL: Record = { const KIND_LABEL: Record = { api: "API", - worker: "Worker(io)", + worker: "Worker(后台任务)", "worker:agent": "Worker(智能体)", - all: "单进程(all)", + all: "单进程(all)", }; /** 横幅告警里的进程称呼 */ const KIND_NAME: Record = { api: "API", - worker: "io worker", + worker: "后台任务 worker", "worker:agent": "智能体 worker", all: "单进程", }; diff --git a/docs/design/glossary.md b/docs/design/glossary.md index 4665a1d3..041a6644 100644 --- a/docs/design/glossary.md +++ b/docs/design/glossary.md @@ -54,7 +54,7 @@ | agent.propose_options | 方案;动作叫"选定",都不要叫"退回" | 选项、提案 | | scope.proposeFirst | 输入框指令 `/先给方案`(只作用于这一次,AGUI-3 起不再是开关);会话列表标签"先给方案" | 只要方案、只给方案、plan_only 模式、先看方案再执行(开关) | | 工作流引擎的一次执行 | 运行 | run | -| 正在写心跳的进程(process_heartbeat 当前行) | 当前实例;横幅按角色称呼:API / io worker / 智能体 worker / 单进程 | 活进程、在线实例 | +| 正在写心跳的进程(process_heartbeat 当前行) | 当前实例;横幅按角色称呼:API / 后台任务 worker / 智能体 worker / 单进程;列表里写 Worker(后台任务)/ Worker(智能体)(全角括号,不写 io) | 活进程、在线实例 | | 已退出、容器已重建或被同容器新进程替换的心跳行 | 已下线实例;正常退出的标"已退出" | 历史实例、僵尸进程、死进程 | ## 文案规则 From fe6649f17db7ef9939f947cc461f4c5c2a7c3f32 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:32:32 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(web):=20=E6=96=87=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=9B=AE=E5=BD=95=E6=A0=91=E4=B8=8E=E4=B8=BB=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=85=B1=E7=94=A8=E5=AE=9E=E5=BA=95=20slab=20?= =?UTF-8?q?=E4=B8=8E=E8=A1=A8=E5=A4=B4=20token(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两块面板垫同一层底色(pg-slab-solid),背后光斑不再让目录树更亮更透; 列表粘性表头改用与卡片同色系的 pg-slab-head,不再是一条纯黑。 Co-Authored-By: Claude Opus 5.5 --- apps/web/app/app.css | 15 +++++++++++++++ apps/web/app/components/files/file-list.tsx | 2 +- apps/web/app/routes/console/files.tsx | 4 ++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 760c9882..4df6287e 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -311,6 +311,9 @@ --mat-slab-border: rgba(255, 255, 255, 0.7); --mat-slab-blur: blur(20px) saturate(160%); --mat-slab-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85), 0 0 0 1px rgba(20, 22, 28, 0.04), 0 1px 2px rgba(20, 22, 28, 0.05), 0 12px 28px -18px rgba(20, 22, 28, 0.16); + /* 满高工作面板(文件管理的目录树 + 主面板)的实底与粘性表头:两块共用,背后的光斑不再让一块比另一块亮 */ + --mat-slab-base: color-mix(in oklab, var(--background) 35%, transparent); + --mat-slab-head: color-mix(in oklab, var(--background) 25%, white); --mat-slab-reflection: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.14) 36%, rgba(255, 255, 255, 0.3) 44%, rgba(255, 255, 255, 0.07) 51%, transparent 62%); --mat-spotlight: rgba(255, 255, 255, 0.6); /* 卡片投影:日间轻,夜间沿用 @theme 里的深投影 */ @@ -458,6 +461,8 @@ --mat-slab-bg: linear-gradient(155deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 30%, rgba(255, 255, 255, 0.03) 75%, rgba(255, 255, 255, 0.04) 100%); --mat-slab-border: rgba(255, 255, 255, 0.1); --mat-slab-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 28px -18px rgba(0, 0, 0, 0.4); + --mat-slab-base: color-mix(in oklab, var(--background) 78%, transparent); + --mat-slab-head: color-mix(in oklab, var(--background) 94%, white); --mat-slab-reflection: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.006) 36%, rgba(255, 255, 255, 0.013) 44%, rgba(255, 255, 255, 0.003) 51%, transparent 62%); --mat-spotlight: rgba(255, 255, 255, 0.07); --shadow-card: 0 18px 40px -24px rgba(0, 0, 0, 0.9); @@ -859,6 +864,16 @@ box-shadow: var(--mat-slab-shadow); } + /* 实底 slab:同一套玻璃面,下面垫一层底色,只让光斑淡淡渗进来(满高工作面板用,几块并排时明暗一致) */ + .pg-slab-solid { + background: var(--mat-slab-bg), var(--mat-slab-base); + } + + /* slab 里的粘性表头:与卡片同色系的实底,不是一条纯黑 / 纯白的条 */ + .pg-slab-head { + background: var(--mat-slab-head); + } + .pg-slab::before { content: ""; position: absolute; diff --git a/apps/web/app/components/files/file-list.tsx b/apps/web/app/components/files/file-list.tsx index 135c1c73..300878b0 100644 --- a/apps/web/app/components/files/file-list.tsx +++ b/apps/web/app/components/files/file-list.tsx @@ -363,7 +363,7 @@ export function FileList({
    diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 746179fe..681555d7 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -891,7 +891,7 @@ export default function FilesPage() { style={{ width: treeOpen ? treeW : 52 }} aria-label="登记目录与目录树" > -
    +
    {treeOpen ? ( <> {/* 头部一行与主面板工具栏第一行等高(同 px-3 py-2.5 + 按钮高度) */} @@ -956,7 +956,7 @@ export default function FilesPage() { ) : null}
    {/* 工具栏 */}