feat(agent): add read-only download.list capability
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0023872b9b
commit
0f20c19b7c
6 files changed
+294
-7
No files matched your search
@@ -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<number>().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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
healthy: "正常",
|
||||
degraded: "异常",
|
||||
offline: "连不上",
|
||||
unknown: "还没检查过",
|
||||
failing: "连续失败",
|
||||
disabled: "已停用",
|
||||
};
|
||||
|
||||
/** 健康状态 → 人话 */
|
||||
export function healthName(h: string | null | undefined): string {
|
||||
return HEALTH_NAMES[h ?? "unknown"] ?? h ?? "还没检查过";
|
||||
}
|
||||
|
||||
const DOWNLOAD_STATUS: Record<string, string> = {
|
||||
queued: "排队中",
|
||||
submitting: "提交中",
|
||||
downloading: "下载中",
|
||||
paused: "已暂停",
|
||||
stalled: "无进展",
|
||||
completed: "已完成待整理",
|
||||
importing: "整理中",
|
||||
imported: "已入库",
|
||||
failed: "失败",
|
||||
removed: "已移除",
|
||||
missing: "文件缺失",
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_STATUS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
active: "登录正常",
|
||||
invalid: "凭证无效",
|
||||
expired: "登录已失效",
|
||||
challenge: "需要人工验证",
|
||||
unknown: "登录状态未知",
|
||||
};
|
||||
|
||||
/** 站点账号状态 → 人话 */
|
||||
export function accountName(s: string): string {
|
||||
return ACCOUNT_NAMES[s] ?? s;
|
||||
}
|
||||
@@ -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<typeof DecisionListInput>;
|
||||
export type DecisionListOutput = z.infer<typeof DecisionListOutput>;
|
||||
export type DownloadInspectInput = z.infer<typeof DownloadInspectInput>;
|
||||
export type DownloadInspectOutput = z.infer<typeof DownloadInspectOutput>;
|
||||
export type DownloadListInput = z.infer<typeof DownloadListInput>;
|
||||
export type DownloadListOutput = z.infer<typeof DownloadListOutput>;
|
||||
export type DownloaderListInput = z.infer<typeof DownloaderListInput>;
|
||||
export type DownloaderListOutput = z.infer<typeof DownloaderListOutput>;
|
||||
export type AgentSpawnInput = z.infer<typeof AgentSpawnInput>;
|
||||
|
||||
@@ -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<KnownCapabilityId, CapabilityDescriptor>
|
||||
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<KnownCapabilityId, CapabilityActivity>
|
||||
"subscription.list": act("查", "订阅"),
|
||||
"decision.list": act("查", "待处理事项"),
|
||||
"download.inspect": act("查", "下载任务", "个"),
|
||||
"download.list": act("列", "下载任务"),
|
||||
"downloader.list": act("查", "下载器"),
|
||||
"agent.spawn": act("派出", "子会话", "个"),
|
||||
"agent.await": act("等", "子会话"),
|
||||
|
||||
Reference in new issue
Block a user