Merge branch 'fix9'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-30 19:44:35 +08:00
commit fd9df0a68d
13 files changed
+517 -217

No files matched your search

@@ -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)!;
+15
View File
@@ -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;
@@ -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 (
<li className="min-w-0">
<button
@@ -261,10 +264,16 @@ export function StepSummary({
activity,
running,
auditByCall,
since,
until,
}: {
activity: ActivityItem[];
running: boolean;
auditByCall?: Map<string, InvocationAudit>;
/** 这一轮开始的时间(会话开始 / 用户消息);只给回合里的第一段 */
since?: string | null;
/** 结论正文开始的时间 */
until?: string | null;
}) {
const id = useId();
const [open, setOpen] = useState(false);
@@ -273,7 +282,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
@@ -312,7 +321,7 @@ export function StepSummary({
key={a.key}
className="line-clamp-3 px-1 py-1 text-meta break-words whitespace-pre-wrap text-muted-foreground"
>
{a.text.trim()}
{humanizeReport(a.text.trim()).text}
</li>
),
)}
@@ -510,12 +519,12 @@ export function AgentConversation({
return (
<div className="flex flex-col gap-5 px-1 py-2">
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
{blocks.map((b) =>
{blocks.map((b, bi) =>
b.kind === "user" ? (
<UserMessage key={b.key} text={b.text} note={b.pending ? "已送达,等待智能体读取" : undefined} animateIn />
) : (
<section key={b.key} aria-label="智能体回合" className="flex w-full min-w-0 flex-col items-start gap-2">
{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 (
<div key={p.key} className="flex w-full min-w-0 flex-col gap-1.5">
{p.activity.length ? (
<StepSummary activity={p.activity} running={running || thinkingNow} auditByCall={auditByCall} />
<StepSummary
activity={p.activity}
running={running || thinkingNow}
auditByCall={auditByCall}
since={pi === 0 ? turnSince(blocks, bi, run) : null}
until={p.body[0]?.at ?? null}
/>
) : 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 (
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-meta text-muted-foreground">
{report ? null : <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />}
<span className={cn("text-foreground/85", bad && "text-warning")}>实际结果:{resultText(report)}</span>
<span className="min-w-0">
{facts.map((f, i) => (
<span key={f.text}>
{i ? "、" : ""}
{f.to ? (
<Link
to={f.to}
className={cn(
"text-foreground/85 underline-offset-2 hover:underline",
f.tone === "warning" && "text-warning",
)}
>
{f.text}
</Link>
) : (
<span className={cn("text-foreground/85", f.tone === "warning" && "text-warning")}>{f.text}</span>
)}
</span>
))}
</span>
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
{u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
{showCost && u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
{mismatch.length ? (
<span
data-slot="report-mismatch"
title={mismatch.join("\n")}
className="rounded-tight border border-warning/40 px-1 text-warning"
>
与汇报不一致
</span>
) : null}
</p>
{links.length ? (
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-0.5 text-meta">
{links.slice(0, 4).map((l) => (
<li key={l.id} className="min-w-0 max-w-full">
<Link to={l.to} className="block truncate text-foreground/85 underline-offset-2 hover:underline">
{l.label}
</Link>
</li>
))}
{links.length > 4 ? <li className="text-muted-foreground">等 {links.length} 项</li> : null}
</ul>
) : null}
{err ? <p className="text-caption break-words text-destructive">{err}</p> : null}
<TechInfo
items={[
{
label: "说明",
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
wide: true,
mono: false,
},
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
{
label: "token",
@@ -697,7 +752,7 @@ export function ResultLine({
},
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
{ label: "汇总", value: report?.summary ?? null, wide: true, mono: false },
{ label: "与汇报不一致", value: mismatch.length ? mismatch.join(";") : null, wide: true, mono: false },
// 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里
{ label: "原文", value: rawReport, wide: true, mono: false },
{ label: "会话 ID", value: run.id, wide: true },
@@ -447,6 +447,7 @@ export function AgentRunPanel({
report={report}
live={live}
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
showCost={false}
/>
) : null}
</>
+153 -3
View File
@@ -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>): 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("检测站点 被拒");
});
});
@@ -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 字按长文处理(收起时限高) */
+1 -1
View File
@@ -363,7 +363,7 @@ export function FileList({
<div
role="row"
className={cn(
"sticky top-0 z-10 grid items-center gap-3 border-b border-border bg-background/95 px-3 py-2 text-meta font-medium text-muted-foreground backdrop-blur",
"pg-slab-head sticky top-0 z-10 grid items-center gap-3 border-b border-border px-3 py-2 text-meta font-medium text-muted-foreground",
DESKTOP_COLS,
)}
>
@@ -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<AgentCheckConfig>;
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 (
<article
ref={ref}
id={`job-${job.id}`}
aria-label={job.name}
data-slot="agent-check-card"
className={cn(
"pg-slab grid min-w-0 scroll-mt-20 grid-cols-[minmax(0,1fr)] content-start gap-3 rounded-xl px-4 py-3.5",
"pg-slab flex h-full min-w-0 scroll-mt-20 flex-col gap-3 rounded-xl px-4 py-3.5",
highlight && "ring-2 ring-ring/50",
)}
>
@@ -125,9 +124,9 @@ export function AgentCheckCard({
<h3 className="truncate text-lead font-medium text-foreground" title={job.name}>
{job.name}
</h3>
{job.description ? (
<p className="mt-0.5 line-clamp-2 text-meta text-muted-foreground">{job.description}</p>
) : null}
<p className="mt-0.5 h-[1lh] truncate text-meta text-muted-foreground" title={job.description ?? undefined}>
{job.description}
</p>
</div>
<Switch
aria-label={`${job.enabled ? "停用" : "启用"}${job.name}`}
@@ -137,7 +136,7 @@ export function AgentCheckCard({
/>
</header>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-meta text-muted-foreground">
<div className="flex min-w-0 flex-nowrap items-center gap-x-4 text-meta whitespace-nowrap text-muted-foreground">
<IntervalSelect
label={`${job.name}间隔`}
schedule={job.schedule}
@@ -145,123 +144,59 @@ export function AgentCheckCard({
pending={a.update.isPending}
onSave={a.setSchedule}
/>
<span className="inline-flex items-center gap-1">
<span className="inline-flex min-w-0 items-center gap-1 truncate">
下次 <NextRun job={job} now={now} />
</span>
</div>
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
{last ? (
job.lastStatus === "ok" ? (
<span>上次成功</span>
<div className="grid min-w-0 gap-1.5">
<p className="flex min-w-0 items-center gap-2 text-meta whitespace-nowrap text-muted-foreground">
{last ? (
job.lastStatus === "ok" ? (
<span>上次成功</span>
) : (
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
)
) : (
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
)
) : (
<span>还没运行过</span>
)}
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
{stats?.count ? <span className="tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
</p>
<span>还没运行过</span>
)}
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
</p>
<p className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="shrink-0 text-meta text-muted-foreground">目标</span>
<span
className="min-w-0 truncate text-foreground"
title={c.goalTemplate ? goalPlain(c.goalTemplate) : undefined}
>
{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}
</span>
</p>
<p
className={cn(
"line-clamp-2 h-[2lh] text-meta break-words",
failed ? "text-destructive" : "text-muted-foreground",
)}
title={failed ? (job.lastError ?? undefined) : (latest?.summary ?? undefined)}
>
{failed ? `失败原因:${job.lastError}` : latest ? plainSummary(latest.summary ?? latest.goal) : null}
</p>
<p className="flex min-w-0 items-center gap-2 text-meta whitespace-nowrap text-muted-foreground">
<span className="min-w-0 truncate" title={stats?.lastModel ? modelsTitle(stats.models) : undefined}>
<ModelText stats={stats} upstream={upstream} />
</span>
{stats?.count ? <span className="shrink-0 tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
<button
type="button"
onClick={() => setDetailOpen(true)}
className="ml-auto inline-flex shrink-0 items-center gap-0.5 rounded-md py-0.5 text-meta text-muted-foreground transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
>
详情
<ChevronRight className="size-3.5" aria-hidden />
</button>
</p>
</div>
{job.lastStatus === "failed" && job.lastError ? (
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
<summary className="cursor-pointer truncate group-open:whitespace-normal">失败原因:{job.lastError}</summary>
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
</details>
) : null}
<TechInfo title="详情" columns={1}>
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
<Field label="目标">
<span className="line-clamp-4 break-words">
{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}
</span>
</Field>
<Field label="能力">
{allow.length ? (
<span className="flex flex-wrap gap-1">
{allow.slice(0, MAX_CAPS).map((id) => (
<span
key={id}
title={id}
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
>
{capTitle(id)}
</span>
))}
{allow.length > MAX_CAPS ? (
<span
className="px-1 text-meta text-muted-foreground"
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
>
+{allow.length - MAX_CAPS}
</span>
) : null}
</span>
) : (
"全部只读能力"
)}
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
</Field>
<Field label="触发条件">
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="模型">
{stats?.lastModel ? (
<span className="break-words" title={modelsTitle(stats.models)}>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
) : null}
</span>
) : upstream ? (
<span className="break-words">
{upstream.name} · {upstream.model ?? "模型未指定"}
</span>
) : (
<span className="text-muted-foreground">未配置模型服务</span>
)}
</Field>
{stats?.count ? (
<Field label="费用">
<span className="tabular-nums">
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</span>
</Field>
) : null}
</dl>
{runs.length ? (
<section aria-label="最近会话" className="grid min-w-0 gap-1">
<h4 className="text-meta text-muted-foreground">最近会话</h4>
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
{runs.slice(0, 3).map((r) => {
const st = agentRunLabel(r.status);
return (
<li key={r.id} className="min-w-0">
<Link
to={consolePath("agent", r.id)}
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
>
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
{plainSummary(r.summary ?? r.goal)}
</span>
<span className="shrink-0" title={r.createdAt}>
{formatRelative(r.finishedAt ?? r.createdAt)}
</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
</TechInfo>
<footer className="flex flex-wrap items-center gap-1.5">
<footer className="mt-auto flex flex-wrap items-center gap-1.5">
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
{a.runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
立即执行
@@ -314,6 +249,134 @@ export function AgentCheckCard({
) : null}
</span>
</footer>
<AgentCheckDetail job={job} upstream={upstream} open={detailOpen} onOpenChange={setDetailOpen} />
</article>
);
}
function ModelText({ stats, upstream }: { stats: ScheduledJob["agentRuns"]; upstream: DefaultUpstream }) {
if (stats?.lastModel) {
return (
<>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
) : null}
</>
);
}
if (upstream) {
return (
<>
{upstream.name} · {upstream.model ?? "模型未指定"}
</>
);
}
return <span className="text-muted-foreground">未配置模型服务</span>;
}
/** 「详情」抽屉:卡片上省略掉的内容全在这里(目标全文、能力、触发条件、预算、模型、费用、失败原因、最近会话) */
function AgentCheckDetail({
job,
upstream,
open,
onOpenChange,
}: {
job: ScheduledJob;
upstream: DefaultUpstream;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const c = job.config as Partial<AgentCheckConfig>;
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 (
<DetailSheet
open={open}
onOpenChange={onOpenChange}
title={`${job.name} · 详情`}
description={job.description ?? undefined}
>
{job.lastStatus === "failed" && job.lastError ? (
<p className="rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta break-words whitespace-pre-wrap text-destructive">
失败原因:{job.lastError}
</p>
) : null}
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
<Field label="目标">
<span className="break-words">{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}</span>
</Field>
<Field label="能力">
{allow.length ? (
<span className="flex flex-wrap gap-1">
{allow.map((id) => (
<span
key={id}
title={id}
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
>
{capTitle(id)}
</span>
))}
</span>
) : (
"全部只读能力"
)}
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
</Field>
<Field label="触发条件">
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="模型">
<span className="break-words" title={stats?.lastModel ? modelsTitle(stats.models) : undefined}>
<ModelText stats={stats} upstream={upstream} />
</span>
</Field>
{stats?.count ? (
<Field label="费用">
<span className="tabular-nums">
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</span>
</Field>
) : null}
</dl>
{runs.length ? (
<section aria-label="最近会话" className="grid min-w-0 gap-1">
<h4 className="text-meta text-muted-foreground">最近会话</h4>
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
{runs.slice(0, 3).map((r) => {
const st = agentRunLabel(r.status);
return (
<li key={r.id} className="min-w-0">
<Link
to={consolePath("agent", r.id)}
className="grid min-w-0 gap-1 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
>
<span className="flex items-center gap-2">
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
<span title={r.createdAt}>{formatRelative(r.finishedAt ?? r.createdAt)}</span>
</span>
<span className="break-words text-foreground">{plainSummary(r.summary ?? r.goal)}</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
</DetailSheet>
);
}
@@ -169,7 +169,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
{checks.length === 0 ? (
<EmptyState icon={Bot} title="还没有巡检" description="新建一个巡检,到点由智能体按目标检查并汇报。" />
) : (
<div className="grid items-start gap-4 md:grid-cols-2 2xl:grid-cols-3">
<div className="grid auto-rows-fr gap-4 md:grid-cols-2 2xl:grid-cols-3">
{checks.map((j) => (
<AgentCheckCard
key={j.id}
+2 -2
View File
@@ -891,7 +891,7 @@ export default function FilesPage() {
style={{ width: treeOpen ? treeW : 52 }}
aria-label="登记目录与目录树"
>
<div className="pg-slab flex min-h-0 w-full flex-col overflow-hidden rounded-2xl">
<div className="pg-slab pg-slab-solid flex min-h-0 w-full flex-col overflow-hidden rounded-2xl">
{treeOpen ? (
<>
{/* 头部一行与主面板工具栏第一行等高(同 px-3 py-2.5 + 按钮高度) */}
@@ -956,7 +956,7 @@ export default function FilesPage() {
) : null}
<section
className="pg-slab relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
aria-label="目录内容"
>
{/* 工具栏 */}
+3 -3
View File
@@ -43,15 +43,15 @@ const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
const KIND_LABEL: Record<string, string> = {
api: "API",
worker: "Worker(io)",
worker: "Worker(后台任务)",
"worker:agent": "Worker(智能体)",
all: "单进程(all)",
all: "单进程(all)",
};
/** 横幅告警里的进程称呼 */
const KIND_NAME: Record<string, string> = {
api: "API",
worker: "io worker",
worker: "后台任务 worker",
"worker:agent": "智能体 worker",
all: "单进程",
};
+1 -1
View File
@@ -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) | 活进程、在线实例 |
| 已退出、容器已重建或被同容器新进程替换的心跳行 | 已下线实例;正常退出的标"已退出" | 历史实例、僵尸进程、死进程 |
## 文案规则
+1
View File
@@ -38,6 +38,7 @@ sessions: [chat, patrol, investigate, child]
## 需要批准
- site.health_check(实时检测站点)、site.refresh_login(重新登录)。没有授权时会被拒绝:汇报里写一句「没做:实时检测「站点名」,需要你批准」,界面会给批准按钮。
- 需要批准的检查(如检测站点)不要只在汇报里写「需要你批准」:先实际调用一次,让审批卡 /「批准并重试」按钮出现在界面上,再在汇报里写一句「没做:…,需要你批准」。同一个检查只调用一次,被拒绝后不要重复调用。
- 其余都是只读,不需要批准。
## 汇报模板