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/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/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx
index 3bb8e1d8..2f9d6512 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,
@@ -195,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 (
),
)}
@@ -510,12 +519,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 +534,13 @@ export function AgentConversation({
return (
{p.activity.length ? (
-
+
) : null}
{p.body.map((t) => {
const streaming = streamingText && t.key === lastKey;
@@ -586,103 +601,143 @@ 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;
+}
+
// ── 实际结果 ──
-/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
-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..18bac1ba 100644
--- a/apps/web/app/components/agent/agui3.test.ts
+++ b/apps/web/app/components/agent/agui3.test.ts
@@ -4,13 +4,28 @@ 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";
-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,
@@ -442,3 +457,138 @@ 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: "汇总中…" }]);
+ });
+});
+
+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 秒/);
+ });
+});
+
+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("检测站点 被拒");
+ });
+});
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 字按长文处理(收起时限高) */
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/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}
-
-
-