feat(agent): add read-only download.list capability

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 18:47:15 +08:00
1 parent 0023872b9b
commit 0f20c19b7c
6 files changed
+294 -7

No files matched your search

+138
View File
@@ -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;
}
+44
View File
@@ -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>;
+14
View File
@@ -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("等", "子会话"),