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} -
    - -