From 1b22bb5131be3a800d49d71ab491c70a6e9cad59 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 23:59:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(agent):=20downloader.list=20=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E4=B8=8E=E6=B1=87=E6=8A=A5=E8=AE=A1=E6=95=B0=E8=A7=84?= =?UTF-8?q?=E5=88=99(FIX-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 智能体原先只能从 site.list 推断下载器,会把没看到汇报成下载器不在系统内。 新增只读能力 downloader.list(下载器与媒体服务器、连通状态、管理的任务数); 原因码表补能力错误、下载盘判定、站点登录与待处理类型,带分类前缀的码按后半段查; 汇报格式加「数量以工具返回为准」。 Co-Authored-By: Claude Opus 5.5 --- .../acquisition-organize/capabilities.ts | 53 ++++++++++++++++++- apps/server/src/modules/agent/runner.ts | 1 + .../server/test/e2e/e15-agent-quality.test.ts | 13 ++++- .../server/test/e2e/e2-agent-delegate.test.ts | 4 +- .../site-capabilities.test.ts | 4 +- docs/runbook/agent.md | 3 +- packages/agent/src/system-prompt.ts | 2 + packages/agent/test/system-prompt.test.ts | 3 +- packages/contracts/src/capabilities-io.ts | 34 ++++++++++++ packages/contracts/src/capability.ts | 14 +++++ packages/contracts/src/reasons.ts | 32 ++++++++++- packages/contracts/test/reasons.test.ts | 9 ++++ 12 files changed, 163 insertions(+), 9 deletions(-) diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index f5e1da62..c37e1a25 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -10,7 +10,7 @@ import { type FrozenPlan, planImport } from "./services/organize.ts"; import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts"; // 本模块领域的能力处理器(规格一律取 contracts): -// inventory.query / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply +// inventory.query / downloader.list / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply // 授权(grant/approval)、幂等键与操作账本由注册中心统一处理(modules/agent/capability-registry.ts); // 这里的处理器只调本模块服务,并保证自身幂等(崩溃后同幂等键重跑用于对账)。 @@ -159,6 +159,57 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => } as never; }); + // FIX-7:已配置的下载器与媒体服务器(只读本地记录:启用、最近连通状态、管理的任务数;不含地址与凭证) + registry.register(capabilitySpec("downloader.list"), async (input) => { + const db = deps().ctx.db; + const [downloaders, counts, servers] = await Promise.all([ + db + .selectFrom("downloader_instance") + .select(["id", "name", "kind", "enabled", "health_status", "last_seen_at", "version_info"]) + .$if(!input.includeDisabled, (qb) => qb.where("enabled", "=", true)) + .orderBy("name") + .execute(), + sql<{ instance_id: string; total: number; active: number; failed: number }>` + select instance_id, count(*)::int as total, + count(*) filter (where status in ('queued', 'submitting', 'downloading', 'paused', 'stalled', 'importing'))::int as active, + count(*) filter (where status in ('failed', 'missing'))::int as failed + from download_task + where managed and removed_at is null + group by instance_id`.execute(db), + db + .selectFrom("media_server_instance") + .select(["id", "name", "kind", "enabled", "health_status", "last_seen_at"]) + .$if(!input.includeDisabled, (qb) => qb.where("enabled", "=", true)) + .orderBy("name") + .execute(), + ]); + const iso = (d: Date | null) => (d ? d.toISOString() : null); + return { + downloaders: downloaders.map((r) => { + const c = counts.rows.find((x) => x.instance_id === r.id); + const v = (r.version_info ?? {}) as { app?: unknown }; + return { + id: r.id, + name: r.name, + kind: r.kind, + enabled: r.enabled, + healthStatus: r.health_status as never, + lastSeenAt: iso(r.last_seen_at), + version: typeof v.app === "string" ? v.app : null, + tasks: { total: c?.total ?? 0, active: c?.active ?? 0, failed: c?.failed ?? 0 }, + }; + }), + mediaServers: servers.map((r) => ({ + id: r.id, + name: r.name, + kind: r.kind, + enabled: r.enabled, + healthStatus: r.health_status as never, + lastSeenAt: iso(r.last_seen_at), + })), + }; + }); + // AG-3:下载任务详情(按 id 或 hash);关联计划 / 资源 / 业务任务 / 整理记录 / 待处理事项 registry.register(capabilitySpec("download.inspect"), async (input) => { const db = deps().ctx.db; diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index d0ab3663..85aed158 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -60,6 +60,7 @@ export const INVESTIGATE_CAPABILITIES = [ "inventory.query", "task.inspect", "download.inspect", + "downloader.list", "decision.list", "subscription.list", "identity.resolve", diff --git a/apps/server/test/e2e/e15-agent-quality.test.ts b/apps/server/test/e2e/e15-agent-quality.test.ts index 449fbf87..7485fb35 100644 --- a/apps/server/test/e2e/e15-agent-quality.test.ts +++ b/apps/server/test/e2e/e15-agent-quality.test.ts @@ -4,7 +4,7 @@ import { resolveBusiness } from "../../src/modules/auth-settings/engine-business import { createE2E, type E2E, pgAvailable, runEvents, runRow, seedBase, seedMovieTarget, waitFor } from "./harness.ts"; // E15 Agent 质量(AG-3,来自 AGENT-REAL 真实上游验证): -// - 只读摘要能力 subscription.list / decision.list / download.inspect(按 id 或 hash);task.inspect 收到下载任务 ID +// - 只读摘要能力 subscription.list / decision.list / download.inspect(按 id 或 hash)/ downloader.list;task.inspect 收到下载任务 ID // 时明确报错并指向 download.inspect; // - download.plan 的 requiresApproval 按授权范围判定(覆盖本作品的持续授权 → false;范围外 / 没有 → true); // - 超预算:run = budget_exceeded,任务页(引擎 run 的 business.outcome)显示超出预算而不是完成; @@ -23,10 +23,12 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { let e: E2E; let target: Awaited>; let downloadTaskId: string; + let instanceId: string; beforeAll(async () => { e = await createE2E("e15", { fakeScripts: scripts }); const base = await seedBase(e); + instanceId = base.instanceId; target = await seedMovieTarget(e, base.siteId); downloadTaskId = ( await e.db @@ -64,6 +66,7 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { text: "按 hash 查下载。", toolCalls: [{ capability: "download.inspect", input: { hash: HASH.toUpperCase() } }], }, + { text: "看下载器。", toolCalls: [{ capability: "downloader.list", input: {} }] }, // 常见误用:把下载任务 ID 当业务任务 ID { text: "看任务时间线。", toolCalls: [{ capability: "task.inspect", input: { taskId: downloadTaskId } }] }, ], @@ -124,6 +127,14 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { decisions: [{ kind: "download_failed" }], otherMatches: [], }); + // FIX-7:下载器是否接入由 downloader.list 回答(site.list 不含下载器),任务按实例计数,不含地址与凭证 + expect(byCap("downloader.list")).toMatchObject({ isError: false }); + expect(byCap("downloader.list").json).toMatchObject({ + // 种下的失败任务在假 qB 里不存在,同步后标 removed(见上面 download.inspect),已移除的不计入 + downloaders: [{ id: instanceId, kind: "qbittorrent", enabled: true, tasks: { total: 0, active: 0, failed: 0 } }], + mediaServers: [], + }); + expect(byCap("downloader.list").text).not.toMatch(/baseUrl|base_url|password|secret/i); expect(byCap("task.inspect")).toMatchObject({ isError: true }); expect(byCap("task.inspect").text).toContain("download.inspect"); diff --git a/apps/server/test/e2e/e2-agent-delegate.test.ts b/apps/server/test/e2e/e2-agent-delegate.test.ts index d4f0b1f8..abb318b8 100644 --- a/apps/server/test/e2e/e2-agent-delegate.test.ts +++ b/apps/server/test/e2e/e2-agent-delegate.test.ts @@ -45,8 +45,8 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权 return r.body.data.id as string; }; - it("启动断言:39 个能力全部注册,并在能力目录里可见", async () => { - expect(e.booted.ctx.capabilities.list()).toHaveLength(39); + it("启动断言:40 个能力全部注册,并在能力目录里可见", async () => { + expect(e.booted.ctx.capabilities.list()).toHaveLength(40); const r = await e.api("GET", "/capabilities"); expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual( e.booted.ctx.capabilities diff --git a/apps/server/test/sources-identity/site-capabilities.test.ts b/apps/server/test/sources-identity/site-capabilities.test.ts index 3e2ef548..bb6ade85 100644 --- a/apps/server/test/sources-identity/site-capabilities.test.ts +++ b/apps/server/test/sources-identity/site-capabilities.test.ts @@ -15,8 +15,8 @@ const TiB = 1024 ** 4; const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const; describe("站点能力规格", () => { - it("进入首批能力目录(17 + 3 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 39);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(39); + it("进入首批能力目录(17 + 4 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 40);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(40); for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id); expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" }); expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" }); diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index b52b4193..9f894caa 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -10,7 +10,7 @@ | 分组 | 能力 | |---|---| -| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`task.inspect`、`site.list`、`site.user_data` | +| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`downloader.list`、`task.inspect`、`site.list`、`site.user_data` | | 计划(preview / propose) | `identity.resolve`、`download.plan`、`organize.plan`、`policy.preview` | | 执行(execute) | `download.submit`、`download.recheck`、`organize.execute`、`task.retry`、`task.cancel`、`policy.apply`、`site.health_check`、`site.refresh_login` | | 原站浏览器 | `browser.snapshot`、`browser.navigate`、`browser.interact`、`browser.download`、`browser.screenshot`、`browser.click_at`、`browser.type_at`、`browser.scroll`、`browser.wait`、`browser.solve_challenge`(见 [browser.md](browser.md)) | @@ -23,6 +23,7 @@ AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白 - `subscription.list {status?, mediaKind?, query?, groupBy=status|mediaKind|none, limit≤200}`:订阅分组计数 + 条目(作品、模式、状态、优先级、版本目标总数 / 已满足数、上次 / 下次搜索)。查单个作品的文件与版本目标仍用 `inventory.query`。 - `decision.list {status?, kind?, limit≤100}`:待处理事项摘要,缺省只列 open / investigating / awaiting_user,按严重度排序;每条带可选处理 `options`。只读,处理仍由用户在待处理中心选。 - `download.inspect {downloadTaskId? | hash?}`:按下载任务 ID 或种子 hash(v1 / v2,大小写均可)读下载任务、关联计划、资源、业务任务(提交下载 / 核验入库,ID 可交给 `task.inspect` / `task.retry`)、整理记录与待处理事项;同 hash 在多个下载器实例时其余 ID 放 `otherMatches`。 +- `downloader.list {includeDisabled?}`(FIX-7):已配置的下载器与媒体服务器——名称、类型、启用、最近连通状态(`health_status` / `last_seen_at`)、版本,下载器附本工具管理的任务数(总数 / 进行中 / 失败)。只读本地记录,不连下载器,不含地址与凭证。此前智能体只能看 `site.list`(不含下载器),会把"没看到"汇报成"下载器不在系统内"。 - `task.inspect` 只接受业务任务(`task` 表)ID;传入下载任务 ID 时返回 `invalid_input`:"这是下载任务 ID…用 download.inspect 查看"。 检索词(`catalog.lookup` / `release.search` / `inventory.query` / `subscription.list` 的 `query`)去掉首尾空白后不能为空、不能是单个英文字母 / 数字 / 符号、不能只有通配符与标点(`invalid_input`:"检索词太短…");中文单字片名允许。`catalog.lookup.externalId.externalId` 一律是字符串。 diff --git a/packages/agent/src/system-prompt.ts b/packages/agent/src/system-prompt.ts index 9ea09039..e29d49cb 100644 --- a/packages/agent/src/system-prompt.ts +++ b/packages/agent/src/system-prompt.ts @@ -57,6 +57,7 @@ export function interactionRules(i: NonNullable { "## 汇报格式 - 第一句写结论:做成了什么、没做成什么、为什么。 - 对象用名字:作品名、站点名、资源标题、文件名;不写函数名、工具名、字段名、参数、UUID 或 hash。 + - 数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。 - 不超过 10 行;不复述过程,不写下一步打算,除非需要用户做什么。 - 只有列举多个资源(3 个以上的资源、任务或文件)时才用表格,其余用短句或列表。 - 可以用 Markdown 列表、表格和粗体,不写标题,不写 HTML。 @@ -40,7 +41,7 @@ describe("汇报格式(AGUI-3)", () => { expect(p).toContain("写出查到的对象名称和关键数值"); // 旧的"最后用简短中文汇报…可以用 Markdown(标题…)"已换成汇报格式一节 expect(p).not.toContain("最后用简短中文汇报"); - expect(REPORT_FORMAT.length).toBe(5); + expect(REPORT_FORMAT.length).toBe(6); }); it("子会话 / 视觉子流程汇报给父会话:不加用户汇报格式", () => { diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index a17e948a..d98e3e7e 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -320,6 +320,37 @@ export const DownloadInspectOutput = z.object({ otherMatches: z.array(Id), }); +/** downloader.list(FIX-7):已配置的下载器与媒体服务器(只读本地记录,不含地址和凭证) */ +export const DownloaderListInput = z.object({ + includeDisabled: z.boolean().default(true), +}); +const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]); +export const DownloaderListOutput = z.object({ + downloaders: z.array( + z.object({ + id: Id, + name: z.string(), + kind: z.string(), + enabled: z.boolean(), + healthStatus: InstanceHealth, + lastSeenAt: z.string().nullable(), + version: z.string().nullable(), + /** 本工具管理、未移除的下载任务 */ + tasks: z.object({ total: NonNegInt, active: NonNegInt, failed: NonNegInt }), + }), + ), + mediaServers: z.array( + z.object({ + id: Id, + name: z.string(), + kind: z.string(), + enabled: z.boolean(), + healthStatus: InstanceHealth, + lastSeenAt: z.string().nullable(), + }), + ), +}); + // ── 子代理(W7-B) ── /** 子会话预算:缺省按父会话剩余预算的一半划拨;超过剩余预算直接拒绝 */ @@ -842,6 +873,7 @@ export const CAPABILITY_IO = { "subscription.list": { input: SubscriptionListInput, output: SubscriptionListOutput }, "decision.list": { input: DecisionListInput, output: DecisionListOutput }, "download.inspect": { input: DownloadInspectInput, output: DownloadInspectOutput }, + "downloader.list": { input: DownloaderListInput, output: DownloaderListOutput }, "agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput }, "agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput }, "site.list": { input: SiteListInput, output: SiteListOutput }, @@ -918,6 +950,8 @@ export type DecisionListInput = z.infer; export type DecisionListOutput = z.infer; export type DownloadInspectInput = z.infer; export type DownloadInspectOutput = z.infer; +export type DownloaderListInput = z.infer; +export type DownloaderListOutput = z.infer; export type AgentSpawnInput = z.infer; export type AgentSpawnOutput = z.infer; export type AgentAwaitInput = z.infer; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index d63165c3..6d3b60c2 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -69,6 +69,7 @@ export const CAPABILITY_IDS = [ "subscription.list", "decision.list", "download.inspect", + "downloader.list", "agent.spawn", "agent.await", "site.list", @@ -329,6 +330,18 @@ export const CAPABILITY_CATALOG: Record true, false, ), + // FIX-7:智能体原先只能从站点列表推断下载器是否接入(site.list 不含下载器),汇报成"下载器不在系统内" + "downloader.list": d( + "downloader.list", + "查看下载器", + "列出已配置的下载器和媒体服务器:名称、类型、是否启用、最近一次连通状态与时间、本工具管理的下载任务数(按进行中 / 失败计)。只读本地记录,不连下载器;问下载器或媒体服务器是否接入、是否正常时先用它,某个下载任务用 download.inspect", + "read", + "DownloaderList", + "read", + "low", + true, + false, + ), // 子代理(设置 pi.subagents 开启后才提供给模型):类别 propose——只建有界子会话,不产生外部副作用, // "先给方案"的会话也能派生(子会话同样只给方案);授权不高于父会话,由服务端校验 "agent.spawn": d( @@ -612,6 +625,7 @@ export const CAPABILITY_ACTIVITY: Record "subscription.list": act("查", "订阅"), "decision.list": act("查", "待处理事项"), "download.inspect": act("查", "下载任务", "个"), + "downloader.list": act("查", "下载器"), "agent.spawn": act("派出", "子会话", "个"), "agent.await": act("等", "子会话"), "site.list": act("查", "站点"), diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index e4200a28..9d9f2f01 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -48,6 +48,35 @@ const REASONS: Record = { not_imported_kind: { text: "这类文件不入库" }, target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" }, download_removed: { text: "下载任务已从下载器删除" }, + // FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型) + invalid_input: { text: "参数不对" }, + invalid_output: { text: "返回结果格式不对" }, + unauthorized: { text: "需要你批准" }, + forbidden: { text: "没有权限" }, + not_found: { text: "没找到" }, + precondition: { text: "前置条件不满足" }, + internal: { text: "内部错误" }, + volume_state_unknown: { text: "下载盘用量未知", action: "在下载面板检查下载盘" }, + hard_threshold_reached: { text: "下载盘已到暂停线" }, + projected_over_threshold: { text: "下完会超过下载盘暂停线" }, + awaiting_resume_watermark: { text: "等下载盘降到恢复线" }, + request_size_unknown: { text: "资源大小未知" }, + inflight_size_unknown: { text: "在下的任务大小未知" }, + budget_exceeded: { text: "超出下载额度" }, + budget_unknown_size: { text: "资源大小未知,无法核对下载额度" }, + no_downloader_instance: { text: "没有可用的下载器", action: "在设置 → 下载器里添加" }, + download_save_path_unmapped: { text: "下载目录没有路径映射", action: "在设置 → 下载器里添加路径映射" }, + site_out_of_scope: { text: "站点不在这次的范围内" }, + slot_changed_or_reserved: { text: "收录版本已变化或被占用" }, + auth_missing: { text: "站点没有配置 Cookie", action: "在站点账号里填写" }, + auth_expired: { text: "站点登录已过期", action: "刷新站点登录" }, + auth_invalid: { text: "站点登录无效", action: "检查站点账号" }, + source_unreachable: { text: "站点连不上" }, + download_failed: { text: "下载失败" }, + download_stalled: { text: "下载无进展" }, + space_insufficient: { text: "空间不足" }, + unidentifiable: { text: "没识别出作品" }, + user_deleted: { text: "已删除" }, }; /** @@ -69,7 +98,8 @@ export function reasonText(raw: string | null | undefined): ReasonText | null { ...(parts[0]?.action ? { action: parts[0].action } : {}), }; } - return REASONS[s] ?? REASONS[head] ?? null; + // 带分类前缀的码(`cache:volume_state_unknown`、`deferred:no_downloader_instance`)按冒号后的码查 + return REASONS[s] ?? REASONS[head] ?? (rest ? (REASONS[rest.trim()] ?? null) : null); } /** 只要一句话:"没识别出作品。指定作品后重新整理";不认识的码返回 fallback */ diff --git a/packages/contracts/test/reasons.test.ts b/packages/contracts/test/reasons.test.ts index 937c1589..6b87b593 100644 --- a/packages/contracts/test/reasons.test.ts +++ b/packages/contracts/test/reasons.test.ts @@ -18,6 +18,15 @@ describe("reasonText(DEAI-2 原因码人话化)", () => { for (const c of ["blocked_by_conflict", "main_failed", "not_imported_kind"]) expect(reasonText(c)).not.toBeNull(); }); + it("智能体汇报里的码(FIX-7):能力错误、带分类前缀的空间判定、站点登录", () => { + expect(reasonText("invalid_input")?.text).toBe("参数不对"); + expect(reasonText("cache:volume_state_unknown")?.text).toBe("下载盘用量未知"); + expect(reasonText("deferred:no_downloader_instance")?.text).toBe("没有可用的下载器"); + expect(reasonText("auth_missing")?.text).toContain("Cookie"); + expect(reasonText("unidentifiable")?.text).toBe("没识别出作品"); + expect(reasonText("cache:some_new_code")).toBeNull(); + }); + it("不认识的码返回 null;句子兜底不把码原样给人看", () => { expect(reasonText("some_new_code")).toBeNull(); expect(reasonSentence("some_new_code")).toBe("处理失败"); From 417af9f25325fdf4fcad1f47757f9cc6f4b25a3a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 00:00:03 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(web):=20=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E6=B1=87=E6=8A=A5=E5=B1=95=E7=A4=BA=E5=B1=82=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E4=B8=8E=E8=BF=9B=E5=BA=A6=E5=8F=A3=E5=BE=84(FIX-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 汇报正文里的 UUID、能力 ID、原因码展示时换成名称和人话,原文收进「正文原文」,历史不改 - 步骤进度统一 stepProgress:分子只数完成,跳过单独计(「4/6 · 跳过 2」) - 进度行不再把失败的调用算进动作计数(3 次被拒的派发曾写成「派出了 5 个子会话」); 实际结果行按会话树显示派出的子会话数 - 会话标题里的 ISO 时间与 download_failed 等码转人话 Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 44 +++++- .../components/agent/agent-helpers.test.ts | 12 ++ .../components/agent/agent-interaction.tsx | 15 +- .../app/components/agent/agent-run-panel.tsx | 30 ++-- apps/web/app/components/agent/agent-store.ts | 18 ++- .../components/agent/conversation-model.ts | 33 +++- .../components/agent/report-humanize.test.ts | 113 +++++++++++++ .../app/components/agent/report-humanize.ts | 148 ++++++++++++++++++ apps/web/app/components/agentui/todo-list.tsx | 7 +- 9 files changed, 393 insertions(+), 27 deletions(-) create mode 100644 apps/web/app/components/agent/report-humanize.test.ts create mode 100644 apps/web/app/components/agent/report-humanize.ts diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index bed5752e..4f5d71e0 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -60,6 +60,7 @@ import { runningText, toolSummary, } from "./conversation-model.ts"; +import { collectNames, humanizeReport } from "./report-humanize.ts"; import { ToolCallIo } from "./tool-call-card-io.tsx"; import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; @@ -439,6 +440,7 @@ export function AgentConversation({ userMessages = [], audit, after, + knownNames, }: { run: AgentRunView; live: AgentStreamState; @@ -446,6 +448,8 @@ export function AgentConversation({ audit?: InvocationAudit[]; /** 流末尾追加的内容(其它审批卡、实际结果行) */ after?: ReactNode; + /** 工具结果之外已知的「ID → 名字」(子会话),汇报兜底换名用 */ + knownNames?: [string, string][]; }) { const active = isRunActive(run.status) && !live.finished; const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued"; @@ -460,6 +464,7 @@ export function AgentConversation({ const lastKey = live.items.at(-1)?.key; const tail = live.items.at(-1); const blocks = buildConversation(live.items, userMessages); + const names = collectNames(live.items, knownNames); const anim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none"; const lastBlock = blocks.at(-1); @@ -501,18 +506,21 @@ export function AgentConversation({ ) : null} {p.body.map((t) => { const streaming = streamingText && t.key === lastKey; + // 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息 + const human = streaming ? null : humanizeReport(t.text, names); return (
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} - {streaming ? t.text : } + {human ? : t.text} + {human?.replaced.length ? : null}
); })} @@ -556,6 +564,32 @@ export function AgentConversation({ ); } +/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */ +function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) { + const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()]; + return ( + + {uniq.map((r) => ( +
  • + {r.from} → {r.to || "(省略)"} +
  • + ))} + + ), + wide: true, + }, + ]} + /> + ); +} + // ── 实际结果 ── /** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */ @@ -592,10 +626,13 @@ export function ResultLine({ run, report, live, + subagents = 0, }: { run: AgentRunView; report: GoalReport | null; live: AgentStreamState; + /** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */ + subagents?: number; }) { const u = { ...run.usage, ...(live.usage ?? {}) }; const durationMs = @@ -623,6 +660,7 @@ export function ResultLine({

    {report ? null : } 实际结果:{resultText(report)} + {subagents ? · 派出 {subagents} 个子会话 : null} {durationMs ? · {durationLabel(durationMs)} : null} {u.costUsd ? · 约 {formatUsd(u.costUsd)} : null}

    @@ -643,7 +681,7 @@ export function ResultLine({ items={[ { label: "说明", - value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务和整理记录。", + value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。", wide: true, mono: false, }, diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index 2a59cf51..f5f868cb 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -155,4 +155,16 @@ describe("shortGoalTitle(会话短标题)", () => { expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`); expect(shortGoalTitle(" ")).toBe("未命名会话"); }); + it("ISO 时间与待处理类型码转人话(FIX-7)", () => { + const iso = "2026-09-28T18:53:50.468Z"; + const d = new Date(iso); + const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + expect(shortGoalTitle(`巡检待处理(${iso}):有 1 条未关闭的待处理事项。逐条查看`)).toBe( + `巡检待处理(${d.getMonth() + 1}月${d.getDate()}日 ${hm}):有 1 条未关闭的待处理事项`, + ); + const t = shortGoalTitle( + "调查待处理事项 728960d1-b892-4ae7-8de3-baf61f8abbd7(download_failed):下载异常:Dune.Part.Two。摘要", + ); + expect(t).toBe("调查待处理事项(下载失败):下载异常:Dune.Part.Two"); + }); }); diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx index df091959..99329205 100644 --- a/apps/web/app/components/agent/agent-interaction.tsx +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -17,6 +17,7 @@ import { Button } from "~/components/ui/button.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; +import { stepProgress } from "./conversation-model.ts"; import type { ApprovalView, OptionsView } from "./use-agent-stream.ts"; // 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。 @@ -54,11 +55,10 @@ export function RunPlan({ fill?: boolean; }) { if (!plan?.steps.length) return null; - const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length; - const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending"); - const failed = plan.steps.some((s) => s.status === "failed"); + const progress = stepProgress(plan.steps); + const failed = progress.failed > 0; if (compact) { - const pct = Math.round((done / plan.steps.length) * 100); + const pct = progress.pct; return (
    @@ -67,11 +67,9 @@ export function RunPlan({ className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")} /> - 步骤 {done}/{plan.steps.length} - - - {current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"} + 步骤 {progress.done}/{progress.total} + {progress.status}
    s.status === "done" || s.status === "skipped").length; - const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending"); - const failed = steps.find((s) => s.status === "failed"); + const progress = stepProgress(steps); + const failed = progress.failedStep; const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent; if (!steps.length && !family && !isRunActive(status)) return null; const u = { ...r.usage, ...(live.usage ?? {}) }; @@ -177,11 +178,9 @@ export function RunOverviewBar({ className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")} /> - 步骤 {done}/{steps.length} - - - {failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"} + 步骤 {progress.done}/{progress.total} + {progress.status} ) : ( @@ -197,7 +196,7 @@ export function RunOverviewBar({ "h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none", failed ? "bg-destructive" : "bg-foreground/60", )} - style={{ width: `${Math.round((done / steps.length) * 100)}%` }} + style={{ width: `${progress.pct}%` }} />
    ) : null} @@ -384,6 +383,12 @@ export function AgentRunPanel({ [c.id, shortGoalTitle(c.goal, 24)] as [string, string]), + ...(run.data.parent + ? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]] + : []), + ]} audit={audit.data} userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} after={ @@ -393,7 +398,14 @@ export function AgentRunPanel({ .map((a) => ( ))} - {finished ? : null} + {finished ? ( + c.parentRunId === r.id).length} + /> + ) : null} } /> diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index e9df70d3..78919117 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -1,4 +1,4 @@ -import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts"; +import { CAPABILITY_CATALOG, type KnownCapabilityId, reasonText } from "@umt/contracts"; import { useSyncExternalStore } from "react"; // Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。 @@ -159,6 +159,16 @@ const capabilityTitle = (id: string): string | null => const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi; const CAP_RE = /\b[a-z][a-z_]*(?:\.[a-z_]+)+\b/g; +const ISO_RE = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?/g; +const CODE_RE = /\b[a-z]+(?:_[a-z]+)+\b/g; + +/** 目标里的 ISO 时间(巡检模板的 {{now}})→「9月28日 18:53」,按浏览器时区 */ +function shortTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} /** * 会话标题:从目标生成短标题(DEAI-2)。去掉上下文前缀、UUID、JSON 参数,能力 ID 换成中文名, @@ -170,10 +180,14 @@ export function shortGoalTitle(goal: string, max = 40): string { t = t.replace(UUID_RE, ""); t = t.replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{0,4})*…?/gi, ""); // 截断的短 ID t = t.replace(CAP_RE, (id) => capabilityTitle(id) ?? id); + t = t.replace(ISO_RE, shortTime); + // 待处理类型、原因码(download_failed)换成人话 + t = t.replace(CODE_RE, (c) => reasonText(c)?.text ?? c); + t = t.replace(/[((]\s*([^()()]*[\p{Script=Han}][^()()]*)\s*[))]/gu, "($1)"); t = t.split(/[\n。!?!?]/)[0] ?? ""; t = t .replace(/\s+/g, " ") - .replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、)])/gu, "") + .replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、()])/gu, "") .replace(/[,,::;;、\s]*(?:参数|args?)?[::]?\s*$/i, "") .trim(); if (!t) return "未命名会话"; diff --git a/apps/web/app/components/agent/conversation-model.ts b/apps/web/app/components/agent/conversation-model.ts index 139bab68..286a643d 100644 --- a/apps/web/app/components/agent/conversation-model.ts +++ b/apps/web/app/components/agent/conversation-model.ts @@ -62,14 +62,15 @@ const joinNouns = (xs: string[]) => /** * 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数, - * 同一动作的对象合并,动作按第一次出现排序;被拒绝和隐藏的调用不计入。超过 4 种动作只写前 3 种。 + * 同一动作的对象合并,动作按第一次出现排序;被拒绝、失败和隐藏的调用不计入(失败的在进度行另计「N 项失败」, + * 否则 3 次被预算拒绝的派发 + 2 次成功会写成「派出了 5 个子会话」)。超过 4 种动作只写前 3 种。 */ export function activitySummary(calls: Pick[]): string { const groups: Group[] = []; for (const c of calls) { const a = activityOf(c.capabilityId); if (a.hidden) continue; - if (c.status === "denied" || authDenial(c)) continue; + if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue; let g = groups.find((x) => x.verb === a.verb); if (!g) { g = { verb: a.verb, nouns: [] }; @@ -85,6 +86,34 @@ export function activitySummary(calls: Pick s.status === "done").length; + const skipped = steps.filter((s) => s.status === "skipped").length; + const failedStep = steps.find((s) => s.status === "failed") ?? null; + const failed = steps.filter((s) => s.status === "failed").length; + const total = steps.length; + const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending") ?? null; + const count = `${done}/${total}${skipped ? ` · 跳过 ${skipped}` : ""}`; + const status = failedStep + ? `失败:${failedStep.title}` + : current + ? `当前:${current.title}` + : skipped + ? `完成 ${done} · 跳过 ${skipped}` + : "全部完成"; + const pct = total ? Math.round(((done + skipped + failed) / total) * 100) : 0; + return { done, skipped, failed, total, current, failedStep, count, status, pct }; +} + /** 正在进行的那一步:「正在检测站点」 */ export function runningText(call: Pick): string { const a = activityOf(call.capabilityId); diff --git a/apps/web/app/components/agent/report-humanize.test.ts b/apps/web/app/components/agent/report-humanize.test.ts new file mode 100644 index 00000000..812db7f2 --- /dev/null +++ b/apps/web/app/components/agent/report-humanize.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { activitySummary, stepProgress } from "./conversation-model.ts"; +import { collectNames, humanizeReport } from "./report-humanize.ts"; +import type { StreamItem, ToolCallView } from "./use-agent-stream.ts"; + +// FIX-7:旧会话汇报的展示层兜底 + 进度口径。样本取自用户库会话 82338768 / a855fb03 的最终汇报(只读核对)。 + +const ENTITY = "74beab51-45e5-4177-832d-199beb8c75fa"; +const SITE = "2ff30e4b-4578-4338-bece-047ef9f6e9cc"; +const VOLUME = "be751d0b-152c-4f72-8182-9797452fbe68"; + +const call = (capabilityId: string, outputSummary: string | null, status: ToolCallView["status"] = "succeeded") => + ({ + kind: "tool", + key: `x${Math.random()}`, + call: { + toolCallId: `c${Math.random()}`, + capabilityId, + status, + inputSummary: "{}", + outputSummary, + reason: null, + startedAt: "2026-09-28T17:00:00.000Z", + endedAt: "2026-09-28T17:00:01.000Z", + }, + }) as unknown as StreamItem; + +describe("humanizeReport(汇报兜底)", () => { + const names = collectNames([ + call( + "catalog.lookup", + `{"local":[{"entity":{"id":"${ENTITY}","createdAt":"2026-09-28T16:35:21.283Z","kind":"movie","title":"沙丘2","year":2024`, + ), + call("site.list", `{"items":[{"id":"${SITE}","key":"audience","name":"观众(mock)","enabled":true}]}`), + ]); + + it("从工具结果收集 ID → 名字,截断的 JSON 也能配对", () => { + expect(names.get(ENTITY)).toBe("沙丘2"); + expect(names.get(SITE)).toBe("观众(mock)"); + }); + + it("UUID 换名字;认不出的缩成「…」;原文进 replaced", () => { + const r = humanizeReport(`目录定位:实体 ID \`${ENTITY}\`,缓存卷 \`instance:${VOLUME}\` 使用率未知`, names); + expect(r.text).toBe("目录定位:实体 ID 「沙丘2」,缓存卷 … 使用率未知"); + expect(r.replaced.map((x) => x.from)).toEqual([ENTITY, `instance:${VOLUME}`]); + }); + + it("名字已写在前面时不重复", () => { + const r = humanizeReport(`站点「观众(mock)」\`${SITE}\` 登录失效`, names); + expect(r.text).toBe("站点「观众(mock)」 登录失效"); + }); + + it("能力 ID(带参数)换成能力标题,原因码换成人话", () => { + const r = humanizeReport( + "`site.health_check` 被拒;`download.inspect` 无法调用(返回 `invalid_input`);`download.plan(slot 37746be7, release 0bd93cb0)` 返回 plan=null,拒绝原因 `cache:volume_state_unknown`", + names, + ); + expect(r.text).toBe( + "检测站点 被拒;查看下载任务 无法调用(返回 参数不对);生成下载计划 返回 plan=null,拒绝原因 下载盘用量未知", + ); + expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/); + expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试"); + }); + + it("截断的短 ID 缩成「…」;不是 ID 的 8 位十六进制与普通英文不动", () => { + const r = humanizeReport( + `仅 2 条候选——\`e6d79af3-…\` 720p;哈希 deadbeef;${ENTITY.slice(0, 8)} 已找到;Dune Part Two`, + names, + ); + expect(r.text).toBe("仅 2 条候选——… 720p;哈希 deadbeef;「沙丘2」 已找到;Dune Part Two"); + }); + + it("代码块与链接地址原样保留;不认识的码不动", () => { + const text = `看[详情](/console/library/${ENTITY})\n\`\`\`\n${ENTITY}\n\`\`\`\nsome_new_code`; + const r = humanizeReport(text, names); + expect(r.text).toBe(text); + expect(r.replaced).toEqual([]); + }); +}); + +describe("activitySummary(系统事实计数)", () => { + it("失败的派发不计入「派出了 N 个子会话」", () => { + const calls = [ + ...Array.from({ length: 3 }, () => ({ + capabilityId: "agent.spawn", + status: "failed" as const, + outputSummary: '{"error":{"code":"budget","message":"超过剩余预算"}}', + })), + ...Array.from({ length: 2 }, () => ({ + capabilityId: "agent.spawn", + status: "succeeded" as const, + outputSummary: "{}", + })), + ]; + expect(activitySummary(calls)).toBe("派出了 2 个子会话"); + }); +}); + +describe("stepProgress(步骤口径)", () => { + const s = (status: "pending" | "running" | "done" | "skipped" | "failed", title = "步骤") => ({ status, title }); + it("跳过的单独计:4 完成 + 2 跳过不再写「6/6 全部完成」", () => { + const p = stepProgress([s("done"), s("done"), s("done"), s("done"), s("skipped"), s("skipped")]); + expect(p.count).toBe("4/6 · 跳过 2"); + expect(p.status).toBe("完成 4 · 跳过 2"); + expect(p.pct).toBe(100); + }); + it("没有跳过时照旧", () => { + const p = stepProgress([s("done"), s("running", "核对下载任务"), s("pending")]); + expect(p.count).toBe("1/3"); + expect(p.status).toBe("当前:核对下载任务"); + expect(stepProgress([s("done"), s("done")]).status).toBe("全部完成"); + }); +}); diff --git a/apps/web/app/components/agent/report-humanize.ts b/apps/web/app/components/agent/report-humanize.ts new file mode 100644 index 00000000..1536b14e --- /dev/null +++ b/apps/web/app/components/agent/report-humanize.ts @@ -0,0 +1,148 @@ +import { CAPABILITY_CATALOG, CAPABILITY_IDS, reasonText } from "@umt/contracts"; +import type { StreamItem } from "./use-agent-stream.ts"; + +// 智能体汇报的展示层兜底(FIX-7):旧会话或不守汇报格式的模型会在正文里写 UUID、能力 ID(site.health_check)、 +// 原因码(invalid_input、cache:volume_state_unknown)。展示时把它们换成名称 / 人话,历史数据不改; +// 换掉的原文交给「技术信息」。能认出名字的 UUID 换成名字,认不出的缩成「…」。 + +const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const CAP_IDS = [...CAPABILITY_IDS].sort((a, b) => b.length - a.length).map(escapeRe); + +/** 能力 ID(可带调用参数 `(…)`) */ +const CAP_RE = new RegExp(`(?; + +export type HumanizeResult = { + text: string; + /** 被替换的原文 → 显示的文字 */ + replaced: { from: string; to: string }[]; +}; + +const capTitle = (id: string) => (CAPABILITY_CATALOG as Record)[id]?.title ?? id; + +function lookupShort(prefix: string, names: NameIndex, seen: Set): { name: string | null; known: boolean } { + const p = prefix.toLowerCase(); + for (const [id, name] of names) if (id.startsWith(p)) return { name, known: true }; + for (const id of seen) if (id.startsWith(p)) return { name: null, known: true }; + return { name: null, known: false }; +} + +/** 一段不含代码块 / 行内代码的文字 */ +function humanizePlain( + text: string, + names: NameIndex, + seen: Set, + replaced: HumanizeResult["replaced"], + /** 前文(判断名字是否已经写在前面) */ + prior = "", +): string { + const note = (from: string, to: string) => { + replaced.push({ from, to }); + return to; + }; + // 名字已经写在前面(「站点「观众」2ff30e4b-…」)时,ID 直接去掉 + const nameOrDrop = (from: string, name: string, before: string) => + note(from, (prior + before).slice(-name.length - 4).includes(name) ? "" : `「${name}」`); + + let out = text.replace(CAP_RE, (m, id: string) => note(m, capTitle(id))); + out = out.replace(UUID_RE, (m, id: string, offset: number, all: string) => { + const name = names.get(id.toLowerCase()); + return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…"); + }); + out = out.replace(SHORT_TRUNC_RE, (m, p: string, offset: number, all: string) => { + const { name } = lookupShort(p, names, seen); + return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…"); + }); + out = out.replace(SHORT_RE, (m, p: string, offset: number, all: string) => { + const { name, known } = lookupShort(p, names, seen); + if (!known) return m; + return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…"); + }); + out = out.replace(CODE_RE, (m, code: string) => { + const r = reasonText(code); + return r ? note(m, r.text) : m; + }); + return out; +} + +/** + * 纯函数(单测覆盖):汇报正文里的 UUID / 能力 ID / 原因码 → 名称 / 人话。 + * 代码块原样保留;行内代码里的内容被替换时去掉反引号,没有可换的保持原样;链接地址不动。 + */ +export function humanizeReport(text: string, names: NameIndex = new Map()): HumanizeResult { + const replaced: HumanizeResult["replaced"] = []; + const seen = new Set([...text.matchAll(new RegExp(UUID, "gi"))].map((m) => m[0].toLowerCase())); + for (const id of names.keys()) seen.add(id); + const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`|\]\([^)\s]*\))/g); + const out: string[] = []; + parts.forEach((part, i) => { + const prior = out.join(""); + if (i % 2 === 0) out.push(humanizePlain(part, names, seen, replaced, prior)); + else if (part.startsWith("```") || part.startsWith("](")) out.push(part); + else { + const n = replaced.length; + const h = humanizePlain(part.slice(1, -1), names, seen, replaced, prior); + out.push(replaced.length > n ? h : part); + } + }); + const cleaned = out + .join("") + // 换掉 ID 后留下的空括号、重复省略号 + .replace(/[((]\s*[))]/g, "") + .replace(/…(?:\s*[、,,]\s*…)+/g, "…"); + return { text: cleaned, replaced }; +} + +const NAME_KEYS = "title|name|label|displayName"; +/** `"id":"" … "title":"…"`(同一层对象里、下一个 id 之前) */ +const PAIR_RE = new RegExp( + `"(?:id|runId|accountId)"\\s*:\\s*"(${UUID})"(?:(?!"(?:id|runId)"\\s*:)[^{}])*?"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, + "gi", +); +/** `"title":"…" … "id":""`(名字在前) */ +const PAIR_REV_RE = new RegExp( + `"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"[^{}]*?"id"\\s*:\\s*"(${UUID})"`, + "gi", +); + +const unescapeJson = (s: string) => { + try { + return JSON.parse(`"${s}"`) as string; + } catch { + return s; + } +}; + +/** + * 从会话里的工具输入输出收集「ID → 名字」(作品、站点、订阅、待处理事项、资源…), + * 截断的 JSON 也能按字面配对。extra(子会话等)优先。 + */ +export function collectNames(items: StreamItem[], extra: [string, string][] = []): NameIndex { + const names: NameIndex = new Map(); + const add = (id: string, name: string) => { + const k = id.toLowerCase(); + const v = unescapeJson(name).trim(); + if (v && !names.has(k) && v.length <= 80) names.set(k, v); + }; + for (const [id, name] of extra) add(id, name); + for (const it of items) { + if (it.kind !== "tool") continue; + for (const raw of [it.call.outputSummary, it.call.inputSummary]) { + if (!raw) continue; + for (const m of raw.matchAll(PAIR_RE)) if (m[1] && m[2]) add(m[1], m[2]); + for (const m of raw.matchAll(PAIR_REV_RE)) if (m[1] && m[2]) add(m[2], m[1]); + } + } + return names; +} diff --git a/apps/web/app/components/agentui/todo-list.tsx b/apps/web/app/components/agentui/todo-list.tsx index f81dafc1..e929db60 100644 --- a/apps/web/app/components/agentui/todo-list.tsx +++ b/apps/web/app/components/agentui/todo-list.tsx @@ -99,6 +99,8 @@ export interface TodoListProps { maxHeightClassName?: string; emptyText?: ReactNode; className?: string; + /** 表头计数(缺省「完成数/总数」);智能体步骤清单传「4/6 · 跳过 2」,与概况条同一口径 */ + count?: string; } export function TodoList({ @@ -109,6 +111,7 @@ export function TodoList({ maxHeightClassName = "max-h-64", emptyText = "还没有步骤", className, + count, }: TodoListProps) { const id = useId(); const [open, setOpen] = useState(defaultOpen); @@ -142,9 +145,7 @@ export function TodoList({ 已完成 {done} / 共 {items.length} - - {done}/{items.length} - + {count ?? `${done}/${items.length}`} From 23bd8cfe45947ff6ed7c9f7e182b2828532040c6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 00:00:03 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(web):=20=E5=B7=A1=E6=A3=80=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=A8=A1=E6=9D=BF=E7=9A=84=E5=8D=A0=E4=BD=8D=E7=AC=A6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=88=90=E5=90=8D=E7=A7=B0(FIX-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 执行时 renderGoal 已正确替换(补单测);界面显示的是模板本身,{{now}} 等改成「当前时间」标签。 Co-Authored-By: Claude Opus 5.5 --- .../server/test/schedules/render-goal.test.ts | 26 ++++++++++++++++++ .../components/schedules/agent-check-card.tsx | 27 ++++++++++++++++--- .../schedules/agent-check-dialog.tsx | 6 +++-- .../app/components/schedules/labels.test.ts | 19 ++++++++++++- apps/web/app/components/schedules/labels.ts | 25 +++++++++++++++++ 5 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 apps/server/test/schedules/render-goal.test.ts diff --git a/apps/server/test/schedules/render-goal.test.ts b/apps/server/test/schedules/render-goal.test.ts new file mode 100644 index 00000000..8d17ba22 --- /dev/null +++ b/apps/server/test/schedules/render-goal.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_AGENT_CHECKS, renderGoal } from "../../src/modules/schedules/agent-checks.ts"; + +// FIX-7:界面上看到的 {{now}} / {{openDecisions}} 是模板本身;执行时必须全部替换成实际值。 + +describe("renderGoal(巡检目标模板)", () => { + const now = new Date("2026-09-28T18:53:50.468Z"); + const facts = { openDecisions: 3, stalledDownloads: 1, activeSubscriptions: 5 }; + + it("内置巡检的模板执行时不留占位符", () => { + for (const c of BUILTIN_AGENT_CHECKS) { + const goal = renderGoal(c.config.goalTemplate, now, facts); + expect(goal).not.toMatch(/\{\{/); + } + const pending = BUILTIN_AGENT_CHECKS.find((c) => c.key === "check.pending"); + expect(renderGoal(pending?.config.goalTemplate ?? "", now, facts)).toContain( + "巡检待处理(2026-09-28T18:53:50.468Z):有 3 条未关闭的待处理事项", + ); + }); + + it("容许空格;不认识的占位符原样保留", () => { + expect(renderGoal("{{ date }} 待处理 {{openDecisions}} 条 {{other}}", now, facts)).toBe( + "2026-09-28 待处理 3 条 {{other}}", + ); + }); +}); diff --git a/apps/web/app/components/schedules/agent-check-card.tsx b/apps/web/app/components/schedules/agent-check-card.tsx index f605743c..26d9c9f8 100644 --- a/apps/web/app/components/schedules/agent-check-card.tsx +++ b/apps/web/app/components/schedules/agent-check-card.tsx @@ -15,7 +15,7 @@ import { agentRunLabel, formatRelative } from "~/lib/labels.ts"; import { cn } from "~/lib/utils.ts"; import { NextRun, useJobActions } from "./auto-task-list.tsx"; import { IntervalSelect } from "./interval-select.tsx"; -import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts"; +import { CHECK_TRIGGER_LABEL, formatUsd, goalTemplateParts, RUN_STATUS } from "./labels.ts"; // 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。 // 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。 @@ -43,6 +43,27 @@ 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"); +/** 目标模板:占位符显示成名称标签(执行时替换成实际值),不露 `{{now}}` */ +function GoalTemplate({ template }: { template: string }) { + return ( + <> + {goalTemplateParts(template).map((p, i) => + "text" in p ? ( + {p.text} + ) : ( + + {p.label} + + ), + )} + + ); +} + function Field({ label, children }: { label: string; children: ReactNode }) { return ( <> @@ -151,8 +172,8 @@ export function AgentCheckCard({
    - - {c.goalTemplate ?? "—"} + + {c.goalTemplate ? : "—"} diff --git a/apps/web/app/components/schedules/agent-check-dialog.tsx b/apps/web/app/components/schedules/agent-check-dialog.tsx index 0563b8eb..cd80cdb5 100644 --- a/apps/web/app/components/schedules/agent-check-dialog.tsx +++ b/apps/web/app/components/schedules/agent-check-dialog.tsx @@ -25,13 +25,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~ import { Switch } from "~/components/ui/switch.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { CHECK_TRIGGER_LABEL, splitInterval, UNIT_MS } from "./labels.ts"; +import { CHECK_TRIGGER_LABEL, GOAL_PLACEHOLDER_LABEL, splitInterval, UNIT_MS } from "./labels.ts"; /** 巡检只开放只读 / 预览 / 提案类能力 */ const READONLY_CAPS = Object.values(CAPABILITY_CATALOG).filter((c) => (["read", "preview", "propose"] as string[]).includes(c.kind), ); -const PLACEHOLDERS = "{{now}} {{date}} {{openDecisions}} {{stalledDownloads}} {{activeSubscriptions}}"; +const PLACEHOLDERS = Object.entries(GOAL_PLACEHOLDER_LABEL) + .map(([k, v]) => `{{${k}}} ${v}`) + .join("、"); type Unit = "m" | "h"; type Draft = { diff --git a/apps/web/app/components/schedules/labels.test.ts b/apps/web/app/components/schedules/labels.test.ts index e4f24173..16745b2a 100644 --- a/apps/web/app/components/schedules/labels.test.ts +++ b/apps/web/app/components/schedules/labels.test.ts @@ -1,7 +1,24 @@ import { describe, expect, it } from "vitest"; -import { describeSchedule, formatUsd, INTERVAL_PRESETS, planPath, scheduleStreamPath } from "./labels.ts"; +import { + describeSchedule, + formatUsd, + goalTemplateParts, + INTERVAL_PRESETS, + planPath, + scheduleStreamPath, +} from "./labels.ts"; describe("计划页标签", () => { + it("巡检目标模板:占位符显示成名称(FIX-7)", () => { + expect(goalTemplateParts("巡检待处理({{now}}):有 {{ openDecisions }} 条,{{unknown}}")).toEqual([ + { text: "巡检待处理(" }, + { placeholder: "now", label: "当前时间" }, + { text: "):有 " }, + { placeholder: "openDecisions", label: "待处理事项数" }, + { text: " 条,{{unknown}}" }, + ]); + }); + it("planPath:节锚点与 ?job= 高亮", () => { expect(planPath()).toBe("/console/schedules"); expect(planPath({ section: "auto" })).toBe("/console/schedules#auto"); diff --git a/apps/web/app/components/schedules/labels.ts b/apps/web/app/components/schedules/labels.ts index 1b675515..4b552589 100644 --- a/apps/web/app/components/schedules/labels.ts +++ b/apps/web/app/components/schedules/labels.ts @@ -129,3 +129,28 @@ export function formatClock(iso: string | null | undefined): string { second: "2-digit", }); } + +/** 巡检目标模板的占位符(服务端 renderGoal 执行时替换);界面上显示成名称,不露 `{{now}}` */ +export const GOAL_PLACEHOLDER_LABEL: Record = { + now: "当前时间", + date: "今天日期", + openDecisions: "待处理事项数", + stalledDownloads: "异常下载数", + activeSubscriptions: "追踪中的订阅数", +}; + +/** 目标模板拆成文字与占位符两段(纯函数,单测覆盖);不认识的占位符原样当文字 */ +export function goalTemplateParts(template: string): ({ text: string } | { placeholder: string; label: string })[] { + const out: ({ text: string } | { placeholder: string; label: string })[] = []; + let last = 0; + for (const m of template.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) { + const key = m[1] ?? ""; + const label = GOAL_PLACEHOLDER_LABEL[key]; + if (!label) continue; + if (m.index > last) out.push({ text: template.slice(last, m.index) }); + out.push({ placeholder: key, label }); + last = m.index + m[0].length; + } + if (last < template.length) out.push({ text: template.slice(last) }); + return out; +} From 3c17fa3356bf9d67ccffd87b0d89933aa693eb84 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 00:00:03 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E6=96=87=E4=BB=B6=E4=B8=8E=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E9=A1=B5=E7=9A=84=E4=BD=8E=E4=BC=98=E5=85=88=E7=BA=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98(FIX-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件列表「主文件」只认视频扩展名(字幕自成资产时位置 is_primary 也为真) - 行内悬浮操作只在鼠标悬浮时出现,选中行不再常驻遮挡大小/时间 - 种子详情按识别状态说人话,解析结果 JSON 跟随技术信息默认收起 - 作品页空收录版本写「没设收录要求」,版本行写「库里有 N 个版本」 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/file-manager/list.ts | 3 ++- apps/server/test/file-manager/browse.test.ts | 23 +++++++++++++++++++ apps/web/app/components/files/file-list.tsx | 13 ++++------- apps/web/app/components/library/versions.tsx | 4 +++- .../workbench/release-detail-sheet.tsx | 15 ++++++++++-- apps/web/app/routes/console/library.$id.tsx | 4 ++-- 6 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/server/src/modules/file-manager/list.ts b/apps/server/src/modules/file-manager/list.ts index bef1264f..3452b90a 100644 --- a/apps/server/src/modules/file-manager/list.ts +++ b/apps/server/src/modules/file-manager/list.ts @@ -375,7 +375,8 @@ export async function annotate( fileKind, status, escape: r.escape, - asset: a?.link ?? null, + // is_primary 是"资产的主位置",字幕等附属文件自成资产时也为 true;「主文件」只认视频扩展名(FIX-7) + asset: a ? { ...a.link, isPrimary: a.link.isPrimary && fileKind === "video" } : null, download: dl ? { taskId: dl.taskId, status: dl.status, progress: dl.progress, hnr: dl.hnr } : null, media: a?.media ?? null, dirSummary: null, diff --git a/apps/server/test/file-manager/browse.test.ts b/apps/server/test/file-manager/browse.test.ts index 4dea049a..0199f73d 100644 --- a/apps/server/test/file-manager/browse.test.ts +++ b/apps/server/test/file-manager/browse.test.ts @@ -78,6 +78,29 @@ describe.skipIf(skip)("files 浏览与守卫", () => { expect(dir.dirSummary).toMatchObject({ videoCount: 2, registeredCount: 1 }); }); + it("主文件只认视频:整理登记成独立资产的字幕(is_primary = true)不标主文件(FIX-7)", async () => { + await mkdir(join(h.lib, "Sub (2014)")); + await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.mkv"), "video"); + await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.chi.zh-cn.srt"), "1\n"); + const e = await seedEntity(h.db, { kind: "movie", title: "Sub", year: 2014 }); + const v = await seedAsset(h.db, h, { entityId: e, rel: "Sub (2014)/Sub (2014) - 1080p.mkv" }); + const srt = await seedAsset(h.db, h, { + entityId: e, + rel: "Sub (2014)/Sub (2014) - 1080p.chi.zh-cn.srt", + kind: "subtitle", + }); + // 与 organize.ts 一致:附属文件的资产位置也是该资产的主位置,并挂在视频位置下 + await h.db + .updateTable("asset_location") + .set({ is_primary: true, sidecar_of_location_id: v.locationId }) + .where("id", "=", srt.locationId) + .execute(); + const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Sub (2014)" })}`); + const byName = Object.fromEntries(r.body.data.entries.map((x: { name: string }) => [x.name, x])); + expect(byName["Sub (2014) - 1080p.mkv"].asset).toMatchObject({ isPrimary: true }); + expect(byName["Sub (2014) - 1080p.chi.zh-cn.srt"].asset).toMatchObject({ isPrimary: false }); + }); + it("下载根:纳管下载中的内容标 downloading", async () => { const r = await h.api("GET", `/files?${q({ root: h.dlRoot })}`); const e = r.body.data.entries.find((x: { name: string }) => x.name === "Show.S01.1080p"); diff --git a/apps/web/app/components/files/file-list.tsx b/apps/web/app/components/files/file-list.tsx index 30646513..135c1c73 100644 --- a/apps/web/app/components/files/file-list.tsx +++ b/apps/web/app/components/files/file-list.tsx @@ -515,9 +515,7 @@ export function FileList({ {sizeCell(e)} {relTime(e.mtime)} - {onAction && !e.escape ? ( - - ) : null} + {onAction && !e.escape ? : null} {menuBtn}
    @@ -561,15 +559,13 @@ export const DRAG_MIME = "application/x-umt-files"; const DESKTOP_COLS = "grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_6.5rem_32px]"; -/** 行内 hover / 聚焦时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */ +/** 行内 hover 时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */ function HoverOps({ e, - focused, onAction, opState, }: { e: FileEntry; - focused: boolean; onAction: (e: FileEntry, op: MenuKey) => void; opState?: (e: FileEntry, op: MenuKey) => string | null; }) { @@ -577,9 +573,10 @@ function HoverOps({ return ( {ops.map((op) => { diff --git a/apps/web/app/components/library/versions.tsx b/apps/web/app/components/library/versions.tsx index 677221d6..38ed06e8 100644 --- a/apps/web/app/components/library/versions.tsx +++ b/apps/web/app/components/library/versions.tsx @@ -254,7 +254,9 @@ export function EpisodeVersionList({ v={lead} aside={ versions.length > 1 ? ( - {versions.length} 个版本 + + 库里有 {versions.length} 个版本 + ) : null } /> diff --git a/apps/web/app/components/workbench/release-detail-sheet.tsx b/apps/web/app/components/workbench/release-detail-sheet.tsx index cb55b918..3d555fd6 100644 --- a/apps/web/app/components/workbench/release-detail-sheet.tsx +++ b/apps/web/app/components/workbench/release-detail-sheet.tsx @@ -13,6 +13,7 @@ import { KvList } from "~/components/console/kv-list.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; +import { useUiPrefs } from "~/lib/ui-prefs.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { DownloadDialog } from "./download-dialog.tsx"; import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts"; @@ -28,13 +29,21 @@ const DECISION_LABEL = { deferred: { label: "延后:已有计划在途", tone: "warning" }, } as const; +/** 没关联作品时按识别状态说一句(原码不上屏) */ +const UNLINKED_TEXT: Record = { + unresolved: "还没识别作品。", + ambiguous: "有多个可能的作品,没有自动关联。", + unidentifiable: "没识别出作品。", + rejected: "识别结果已被排除。", +}; + /** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry: InventoryEntry | undefined }) { const detail = useEntityDetail(release.entity?.id); if (!release.entity) { return (

    - 未关联本地作品({release.resolutionStatus})。 + {UNLINKED_TEXT[release.resolutionStatus] ?? "未关联本地作品。"} 去指定作品 @@ -123,6 +132,7 @@ export function ReleaseDetailSheet({ const refresh = useRefreshReleaseDetail(); const entity = useEntityDetail(release?.entity?.id); const [dl, setDl] = useState(false); + const techOpen = useUiPrefs().techInfoExpanded; const r = detail.data?.release ?? release; // 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次) @@ -296,7 +306,8 @@ export function ReleaseDetailSheet({ ) : null} - + {/* 原始解析 JSON 属于技术信息:默认收起,跟随设置 → 界面「技术信息默认展开」 */} + )} {r ? : null} diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index e974fd00..256974cc 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -513,8 +513,8 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn 去订阅 From faf15526076ca349e0b91416d35bea242167925c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 00:00:03 +0800 Subject: [PATCH 5/6] =?UTF-8?q?style(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=E6=94=B9=E6=88=90=E4=B8=8E?= =?UTF-8?q?=E4=B8=BB=E9=9D=A2=E6=9D=BF=E5=90=8C=E6=AC=BE=E5=8D=A1=E7=89=87?= =?UTF-8?q?(FIX-7)?= 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/components/files/dir-tree.tsx | 2 +- apps/web/app/routes/console/files.tsx | 129 ++++++++++----------- 2 files changed, 62 insertions(+), 69 deletions(-) diff --git a/apps/web/app/components/files/dir-tree.tsx b/apps/web/app/components/files/dir-tree.tsx index 886afdc4..b9bc7174 100644 --- a/apps/web/app/components/files/dir-tree.tsx +++ b/apps/web/app/components/files/dir-tree.tsx @@ -61,7 +61,7 @@ export function DirTree({