diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index b63aaa69..0125a1c9 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -1,5 +1,12 @@ -import { CapabilityError, type CapabilityRegistry } from "@umt/agent"; -import { capabilitySpec } from "@umt/contracts"; +import { + CapabilityError, + type CapabilityRegistry, + downloadStatusName, + humanBytes, + humanPercent, + humanRelative, +} from "@umt/agent"; +import { capabilitySpec, type DownloadStatus } from "@umt/contracts"; import { sql } from "@umt/db"; import { normalizeTitle, transition } from "@umt/domain"; import { coveringGrant, type PlanBinding, planBinding } from "../agent/authorize.ts"; @@ -10,7 +17,7 @@ import { type FrozenPlan, planImport } from "./services/organize.ts"; import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts"; // 本模块领域的能力处理器(规格一律取 contracts): -// inventory.query / downloader.list / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply +// inventory.query / downloader.list / download.list / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply // 授权(grant/approval)、幂等键与操作账本由注册中心统一处理(modules/agent/capability-registry.ts); // 这里的处理器只调本模块服务,并保证自身幂等(崩溃后同幂等键重跑用于对账)。 @@ -210,6 +217,90 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => }; }); + // AG-5:下载任务列表(先列再看):按状态 / 作品 / 名称筛选,附按状态计数;大小、进度、时间给人话,不给原始字节数与 hash + registry.register(capabilitySpec("download.list"), async (input) => { + const db = deps().ctx.db; + const now = deps().ports.now(); + const esc = (v: string) => v.replace(/[\\%_]/g, (m) => `\\${m}`); + const q = input.query; + const filtered = db + .selectFrom("download_task as dt") + .leftJoin("acquisition_plan as p", "p.id", "dt.acquisition_plan_id") + .leftJoin("acquisition_slot as sl", "sl.id", "p.slot_id") + .leftJoin("media_entity as e", (j) => + j.on((eb) => eb("e.id", "=", eb.fn.coalesce("dt.media_entity_id", "sl.media_entity_id"))), + ) + .leftJoin("downloader_instance as i", "i.id", "dt.instance_id") + .where("dt.removed_at", "is", null) + .$if(!input.includeExternal, (b) => b.where("dt.managed", "=", true)) + .$if(!!input.mediaEntityId, (b) => b.where("e.id", "=", input.mediaEntityId!)) + .$if(!!q, (b) => + b.where((eb) => + eb.or([ + eb("dt.name", "ilike", `%${esc(q!)}%`), + eb("e.title", "ilike", `%${esc(q!)}%`), + eb("e.original_title", "ilike", `%${esc(q!)}%`), + eb(sql`replace(e.title, ' ', '')`, "ilike", `%${esc(q!.replace(/\s+/g, ""))}%`), + ]), + ), + ); + const [groups, rows] = await Promise.all([ + filtered + .select(["dt.status", (eb) => eb.fn.countAll().as("n")]) + .groupBy("dt.status") + .execute(), + filtered + .$if(!!input.status?.length, (b) => b.where("dt.status", "in", input.status!)) + .select([ + "dt.id", + "dt.name", + "dt.status", + "dt.progress", + "dt.size_bytes", + "dt.managed", + "dt.last_progress_at", + "dt.no_progress_since", + "dt.completed_at", + "dt.last_error", + "e.title as title", + "i.name as downloader", + ]) + .orderBy("dt.updated_at", "desc") + .limit(input.limit) + .execute(), + ]); + const byStatus = groups + .map((g) => ({ status: g.status as DownloadStatus, count: Number(g.n) })) + .filter((g) => !input.status?.length || input.status.includes(g.status)) + .sort((a, b) => b.count - a.count) + .map((g) => ({ ...g, label: downloadStatusName(g.status) })); + const rel = (d: Date | null) => (d ? humanRelative(d.toISOString(), now) : null); + const errText = (e: unknown): string | null => { + if (!e || typeof e !== "object") return null; + const m = (e as { message?: unknown }).message; + return typeof m === "string" ? m.slice(0, 200) : null; + }; + return { + total: byStatus.reduce((n, g) => n + g.count, 0), + byStatus, + items: rows.map((r) => ({ + id: r.id, + name: r.name, + title: r.title ?? null, + status: r.status as DownloadStatus, + statusLabel: downloadStatusName(r.status), + progress: humanPercent(Number(r.progress)) ?? "0%", + size: humanBytes(r.size_bytes === null ? null : Number(r.size_bytes)), + downloader: r.downloader ?? null, + managed: r.managed, + lastProgress: rel(r.last_progress_at), + noProgressSince: rel(r.no_progress_since), + completed: rel(r.completed_at), + error: errText(r.last_error), + })), + }; + }); + // AG-3:下载任务详情(按 id 或 hash);关联计划 / 资源 / 业务任务 / 整理记录 / 待处理事项 registry.register(capabilitySpec("download.inspect"), async (input) => { const db = deps().ctx.db; diff --git a/apps/server/test/e2e/e2-agent-delegate.test.ts b/apps/server/test/e2e/e2-agent-delegate.test.ts index abb318b8..e7f87c4c 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("启动断言:40 个能力全部注册,并在能力目录里可见", async () => { - expect(e.booted.ctx.capabilities.list()).toHaveLength(40); + it("启动断言:41 个能力全部注册,并在能力目录里可见", async () => { + expect(e.booted.ctx.capabilities.list()).toHaveLength(41); 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 bb6ade85..dc3ce896 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 + 4 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 40);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(40); + it("进入首批能力目录(17 + 5 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 41);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(41); 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/packages/agent/src/prompt/format.ts b/packages/agent/src/prompt/format.ts new file mode 100644 index 00000000..6bfab7bb --- /dev/null +++ b/packages/agent/src/prompt/format.ts @@ -0,0 +1,138 @@ +// 给模型看的人话格式(与控制台 apps/web/app/lib/utils.ts formatBytes、labels.ts formatRelative 同口径): +// 环境层渲染和只读能力的输出(download.list 的 size / progress)共用,避免把原始字节数、ISO 时间交给模型转述。 + +/** 字节 → "79.4 GB"(1024 进制,与控制台一致);null → null */ +export function humanBytes(bytes: number | null | undefined, decimals = 1): string | null { + if (bytes === null || bytes === undefined || !Number.isFinite(bytes)) return null; + if (bytes <= 0) return "0 B"; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizes.length - 1); + return `${Number.parseFloat((bytes / 1024 ** i).toFixed(i === 0 ? 0 : decimals))} ${sizes[i]}`; +} + +/** 0..1 → "37%" */ +export function humanPercent(ratio: number | null | undefined): string | null { + if (ratio === null || ratio === undefined || !Number.isFinite(ratio)) return null; + return `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`; +} + +/** 本地日期时间 "09-29 16:12"(按给定时区) */ +function clock(d: Date, timeZone?: string): { day: string; time: string } { + const f = new Intl.DateTimeFormat("zh-CN", { + ...(timeZone ? { timeZone } : {}), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const p = Object.fromEntries(f.formatToParts(d).map((x) => [x.type, x.value])); + return { day: `${p.year}-${p.month}-${p.day}`, time: `${p.hour === "24" ? "00" : p.hour}:${p.minute}` }; +} + +/** 当前时间 "2026-09-30 17:45" */ +export function humanNow(now: Date, timeZone?: string): string { + const c = clock(now, timeZone); + return `${c.day} ${c.time}`; +} + +/** + * 相对时间:刚刚 / N 分钟前 / N 小时前(当天)/ 昨天 16:12 / N 天前;未来时间写"N 小时后"。 + * 无效或空 → null。 + */ +export function humanRelative(iso: string | null | undefined, now: Date, timeZone?: string): string | null { + if (!iso) return null; + const t = new Date(iso); + if (Number.isNaN(t.getTime())) return null; + const diff = (now.getTime() - t.getTime()) / 1000; + const abs = Math.abs(diff); + const suffix = diff >= 0 ? "前" : "后"; + if (abs < 45) return diff >= 0 ? "刚刚" : "马上"; + if (abs < 3600) return `${Math.round(abs / 60)} 分钟${suffix}`; + const a = clock(t, timeZone); + const b = clock(now, timeZone); + if (a.day === b.day) return `${Math.round(abs / 3600)} 小时${suffix}`; + const yesterday = clock(new Date(now.getTime() - 86400_000), timeZone).day; + if (diff > 0 && a.day === yesterday) return `昨天 ${a.time}`; + if (abs < 86400 * 2) return `${Math.round(abs / 3600)} 小时${suffix}`; + return `${Math.round(abs / 86400)} 天${suffix}`; +} + +const KIND_NAMES: Record = { + qbittorrent: "qBittorrent", + transmission: "Transmission", + deluge: "Deluge", + aria2: "Aria2", + jellyfin: "Jellyfin", + emby: "Emby", + plex: "Plex", + pt: "PT 站", + public_bt: "公开 BT 站", + rss_feed: "自定义 RSS 源", + torznab: "Torznab 索引器", +}; + +/** 类型代码 → 界面名称(qbittorrent → qBittorrent,pt → PT 站);未知原样返回 */ +export function kindName(kind: string): string { + return KIND_NAMES[kind] ?? kind; +} + +const HEALTH_NAMES: Record = { + healthy: "正常", + degraded: "异常", + offline: "连不上", + unknown: "还没检查过", + failing: "连续失败", + disabled: "已停用", +}; + +/** 健康状态 → 人话 */ +export function healthName(h: string | null | undefined): string { + return HEALTH_NAMES[h ?? "unknown"] ?? h ?? "还没检查过"; +} + +const DOWNLOAD_STATUS: Record = { + queued: "排队中", + submitting: "提交中", + downloading: "下载中", + paused: "已暂停", + stalled: "无进展", + completed: "已完成待整理", + importing: "整理中", + imported: "已入库", + failed: "失败", + removed: "已移除", + missing: "文件缺失", +}; + +const SUBSCRIPTION_STATUS: Record = { + active: "追更中", + paused: "已暂停", + pending: "待开始", + completed: "已完成", + cancelled: "已取消", +}; + +/** 下载任务状态 → 界面用词;未知原样返回 */ +export function downloadStatusName(s: string): string { + return DOWNLOAD_STATUS[s] ?? s; +} + +/** 订阅状态 → 界面用词;未知原样返回 */ +export function subscriptionStatusName(s: string): string { + return SUBSCRIPTION_STATUS[s] ?? s; +} + +const ACCOUNT_NAMES: Record = { + active: "登录正常", + invalid: "凭证无效", + expired: "登录已失效", + challenge: "需要人工验证", + unknown: "登录状态未知", +}; + +/** 站点账号状态 → 人话 */ +export function accountName(s: string): string { + return ACCOUNT_NAMES[s] ?? s; +} diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index fc53576f..41383cf6 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -322,6 +322,47 @@ export const DownloadInspectOutput = z.object({ otherMatches: z.array(Id), }); +/** download.list(AG-5):列出下载任务(按状态 / 作品 / 名称筛选),给模型"先列再看",不用向用户要 ID 或 hash */ +export const DownloadListInput = z.object({ + /** 只列这些状态;缺省全部(不含已移除) */ + status: z.array(DownloadStatus).max(11).optional(), + /** 按下载任务名或作品名模糊匹配(至少 2 个字符) */ + query: z.string().trim().min(2).max(200).optional(), + mediaEntityId: Id.optional(), + /** 也列外部任务(不是本工具提交的);缺省只列本工具管理的 */ + includeExternal: z.boolean().default(false), + limit: z.number().int().min(1).max(50).default(20), +}); +export const DownloadListOutput = z.object({ + total: NonNegInt, + /** 按状态计数(筛选后、limit 前) */ + byStatus: z.array(z.object({ status: DownloadStatus, label: z.string(), count: NonNegInt })), + items: z.array( + z.object({ + /** 下载任务 ID,交给 download.inspect / download.recheck */ + id: Id, + name: z.string(), + /** 关联作品名 */ + title: z.string().nullable(), + status: DownloadStatus, + /** 界面用词:下载中 / 已入库 / 失败… */ + statusLabel: z.string(), + /** "37%" */ + progress: z.string(), + /** "12.3 GB";未知为 null */ + size: z.string().nullable(), + downloader: z.string().nullable(), + managed: z.boolean(), + /** 相对时间:"3 小时前" */ + lastProgress: z.string().nullable(), + noProgressSince: z.string().nullable(), + completed: z.string().nullable(), + /** 最近一次错误的一句话 */ + error: z.string().nullable(), + }), + ), +}); + /** downloader.list(FIX-7):已配置的下载器与媒体服务器(只读本地记录,不含地址和凭证) */ export const DownloaderListInput = z.object({ includeDisabled: z.boolean().default(true), @@ -879,6 +920,7 @@ export const CAPABILITY_IO = { "subscription.list": { input: SubscriptionListInput, output: SubscriptionListOutput }, "decision.list": { input: DecisionListInput, output: DecisionListOutput }, "download.inspect": { input: DownloadInspectInput, output: DownloadInspectOutput }, + "download.list": { input: DownloadListInput, output: DownloadListOutput }, "downloader.list": { input: DownloaderListInput, output: DownloaderListOutput }, "agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput }, "agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput }, @@ -956,6 +998,8 @@ export type DecisionListInput = z.infer; export type DecisionListOutput = z.infer; export type DownloadInspectInput = z.infer; export type DownloadInspectOutput = z.infer; +export type DownloadListInput = z.infer; +export type DownloadListOutput = z.infer; export type DownloaderListInput = z.infer; export type DownloaderListOutput = z.infer; export type AgentSpawnInput = z.infer; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index 2b66d4e1..b56086d9 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", + "download.list", "downloader.list", "agent.spawn", "agent.await", @@ -330,6 +331,18 @@ export const CAPABILITY_CATALOG: Record true, false, ), + // AG-5:先列再看。反例里模型要用户给下载任务 ID / 种子 hash,其实自己能列 + "download.list": d( + "download.list", + "列出下载任务", + "按状态、作品或名称列出下载任务(名称、作品、状态、进度、体积、下载器、最近进展与错误),附按状态计数;缺省只列本工具管理的。问下载进度、找失败或卡住的下载、要下载任务 ID 时先用它,再用 download.inspect 看其中一个;不要向用户要 ID 或 hash", + "read", + "DownloadList", + "read", + "low", + true, + false, + ), // FIX-7:智能体原先只能从站点列表推断下载器是否接入(site.list 不含下载器),汇报成"下载器不在系统内" "downloader.list": d( "downloader.list", @@ -627,6 +640,7 @@ export const CAPABILITY_ACTIVITY: Record "subscription.list": act("查", "订阅"), "decision.list": act("查", "待处理事项"), "download.inspect": act("查", "下载任务", "个"), + "download.list": act("列", "下载任务"), "downloader.list": act("查", "下载器"), "agent.spawn": act("派出", "子会话", "个"), "agent.await": act("等", "子会话"),