style(copy): fix remaining punctuation violations and make check:copy strict

Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 21:34:51 +08:00
1 parent 0fccfe257d
commit fb84a71f80
76 files changed
+582 -570

No files matched your search

+2 -2
View File
@@ -183,7 +183,7 @@ export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
if (!isPublic && WRITE_METHODS.has(c.req.method)) { if (!isPublic && WRITE_METHODS.has(c.req.method)) {
const provided = c.req.header(CSRF_HEADER); const provided = c.req.header(CSRF_HEADER);
if (!provided || !safeEqual(provided, deriveCsrfToken(token))) { if (!provided || !safeEqual(provided, deriveCsrfToken(token))) {
throw new ApiException("forbidden", "CSRF 校验失败,刷新页面后重试"); throw new ApiException("forbidden", "CSRF 校验失败,刷新页面后重试");
} }
} }
setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", { setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", {
@@ -197,7 +197,7 @@ export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
} }
} }
if (isPublic) return next(); if (isPublic) return next();
throw new ApiException("unauthorized", token ? "会话已过期或已注销,重新登录" : "需要登录或 Bearer API Key"); throw new ApiException("unauthorized", token ? "会话已过期或已注销,重新登录" : "需要登录或 Bearer API Key");
}; };
} }
+1 -1
View File
@@ -113,7 +113,7 @@ async function runOneShot(r: OneShotRole): Promise<number> {
switch (r) { switch (r) {
case "rotate-master-key": { case "rotate-master-key": {
if (!config.SECRET_MASTER_KEY_NEW) { if (!config.SECRET_MASTER_KEY_NEW) {
logger.error("缺少环境变量 SECRET_MASTER_KEY_NEW(新主密钥);SECRET_MASTER_KEY 保持旧密钥"); logger.error("缺少环境变量 SECRET_MASTER_KEY_NEW(新主密钥);SECRET_MASTER_KEY 保持旧密钥");
return 2; return 2;
} }
const { rotateMasterKey } = await import("./modules/auth-settings/provided.ts"); const { rotateMasterKey } = await import("./modules/auth-settings/provided.ts");
@@ -60,7 +60,7 @@ export async function queryMediaServer(
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => { const timeout = new Promise<never>((_, reject) => {
timer = setTimeout( timer = setTimeout(
() => reject(new Error(`timeout: 媒体服务器 ${limits.timeoutMs / 1000}s 内未响应`)), () => reject(new Error(`timeout:媒体服务器 ${limits.timeoutMs / 1000}s 内未响应`)),
limits.timeoutMs, limits.timeoutMs,
); );
}); });
@@ -131,7 +131,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/library", path: "/library",
scope: "read", scope: "read",
summary: "媒体库列表(已登记资产或正在追踪的作品)", summary: "媒体库列表(已登记资产或正在追踪的作品)",
query: LibraryListQuery, query: LibraryListQuery,
response: cursorPage(LibraryItemSchema), response: cursorPage(LibraryItemSchema),
}, },
@@ -232,7 +232,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/library/{id}", path: "/library/{id}",
scope: "read", scope: "read",
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果", summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果",
params: IdParam, params: IdParam,
response: LibraryDetail, response: LibraryDetail,
}, },
@@ -349,7 +349,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/inventory", path: "/inventory",
scope: "read", scope: "read",
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中", summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中",
query: InventoryQuery, query: InventoryQuery,
response: InventoryResponse, response: InventoryResponse,
}, },
@@ -469,7 +469,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/inventory/media-servers", path: "/inventory/media-servers",
scope: "read", scope: "read",
summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)", summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)",
query: MediaServerInventoryQuery, query: MediaServerInventoryQuery,
response: MediaServerInventory, response: MediaServerInventory,
}, },
@@ -91,7 +91,7 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
.where("active", "=", true) .where("active", "=", true)
.executeTakeFirst(); .executeTakeFirst();
if (!sup) return; if (!sup) return;
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回"); if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
await liftSuppressions(trx, fingerprint, by); await liftSuppressions(trx, fingerprint, by);
} }
@@ -114,7 +114,7 @@ async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly stri
.execute(); .execute();
const have = new Set(rows.map((r) => r.id)); const have = new Set(rows.map((r) => r.id));
const missing = ids.filter((x) => !have.has(x)); const missing = ids.filter((x) => !have.has(x));
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`); if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
} }
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */ /** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
@@ -253,7 +253,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
Api.ruleAtoms.create, Api.ruleAtoms.create,
async (_c, { body }) => { async (_c, { body }) => {
const key = String(body.key).toUpperCase(); const key = String(body.key).toUpperCase();
if (BUILTIN_ATOM_MAP.has(key)) conflict(`${key} 是内置关键字,不能覆盖`); if (BUILTIN_ATOM_MAP.has(key)) conflict(`${key} 是内置关键字,不能覆盖`);
checkAtomPatterns(body.include); checkAtomPatterns(body.include);
checkAtomPatterns(body.exclude); checkAtomPatterns(body.exclude);
const r = await db() const r = await db()
@@ -318,7 +318,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
(e) => atomsOf(e).has(a!.key), (e) => atomsOf(e).has(a!.key),
), ),
); );
if (users.length) conflict(`自定义条件 ${a!.key} 被规则组引用:${users.map((u) => u.name).join("、")}`); if (users.length) conflict(`自定义条件 ${a!.key} 被规则组引用:${users.map((u) => u.name).join("、")}`);
await db().deleteFrom("rule_atom").where("id", "=", params.id).execute(); await db().deleteFrom("rule_atom").where("id", "=", params.id).execute();
return { ok: true as const }; return { ok: true as const };
}, },
@@ -434,7 +434,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
.executeTakeFirst(); .executeTakeFirst();
if (!cur) notFound("规则组"); if (!cur) notFound("规则组");
if (b.expectedRevision !== undefined && b.expectedRevision !== cur!.revision) if (b.expectedRevision !== undefined && b.expectedRevision !== cur!.revision)
conflict(`revision 不匹配(当前 ${cur!.revision})`); conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (b.name !== undefined && b.name !== cur!.name) { if (b.name !== undefined && b.name !== cur!.name) {
const dupName = await trx const dupName = await trx
.selectFrom("rule_group") .selectFrom("rule_group")
@@ -458,7 +458,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
ladderRequired: b.ladderRequired ?? cur!.ladder_required, ladderRequired: b.ladderRequired ?? cur!.ladder_required,
}; };
if (b.kind !== undefined && b.kind !== cur!.kind && cur!.default_order !== null) if (b.kind !== undefined && b.kind !== cur!.kind && cur!.default_order !== null)
conflict("该规则组在默认链里,先从默认值移出再改类型"); conflict("该规则组在默认链里,先从默认值移出再改类型");
// 偏好标签迁来的组沿用旧指纹口径,规则没变就不重算 // 偏好标签迁来的组沿用旧指纹口径,规则没变就不重算
const semanticChanged = const semanticChanged =
b.kind !== undefined || b.kind !== undefined ||
@@ -528,7 +528,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
.executeTakeFirst(); .executeTakeFirst();
if (!cur) notFound("规则组"); if (!cur) notFound("规则组");
const refs = await referenceCount(trx, cur!.id); const refs = await referenceCount(trx, cur!.id);
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`); if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
const row = await trx const row = await trx
.updateTable("rule_group") .updateTable("rule_group")
.set({ .set({
@@ -103,7 +103,7 @@ async function assertNoDuplicate(
q = input.seasonUnitId ? q.where("season_unit_id", "=", input.seasonUnitId) : q.where("season_unit_id", "is", null); q = input.seasonUnitId ? q.where("season_unit_id", "=", input.seasonUnitId) : q.where("season_unit_id", "is", null);
if (input.excludeId) q = q.where("id", "!=", input.excludeId); if (input.excludeId) q = q.where("id", "!=", input.excludeId);
const hit = await q.executeTakeFirst(); const hit = await q.executeTakeFirst();
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`); if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
} }
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */ /** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
@@ -497,12 +497,12 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
const pause = async (sub: SubscriptionRow) => { const pause = async (sub: SubscriptionRow) => {
if (sub.status === "paused") return sub; if (sub.status === "paused") return sub;
if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status))
conflict(`订阅状态为 ${sub.status},不能暂停`); conflict(`订阅状态为 ${sub.status},不能暂停`);
return setStatus(sub, "paused", "user_paused"); return setStatus(sub, "paused", "user_paused");
}; };
const resume = async (sub: SubscriptionRow) => { const resume = async (sub: SubscriptionRow) => {
if ((TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) return sub; if ((TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) return sub;
if (sub.status !== "paused") conflict(`订阅状态为 ${sub.status},已完成/已取消的订阅用"重新订阅"`); if (sub.status !== "paused") conflict(`订阅状态为 ${sub.status},已完成/已取消的订阅用"重新订阅"`);
const ready = await tmdbReady(db(), sub.media_entity_id, sub.media_kind); const ready = await tmdbReady(db(), sub.media_entity_id, sub.media_kind);
const r = await setStatus(sub, ready ? "active" : "pending", ready ? "resumed" : "metadata_pending", { const r = await setStatus(sub, ready ? "active" : "pending", ready ? "resumed" : "metadata_pending", {
nextSearchNow: true, nextSearchNow: true,
@@ -511,11 +511,11 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
return r; return r;
}; };
const search = async (sub: SubscriptionRow) => { const search = async (sub: SubscriptionRow) => {
if (!sub.media_entity_id) throw new ApiException("bad_request", "订阅未关联作品,无法搜索"); if (!sub.media_entity_id) throw new ApiException("bad_request", "订阅未关联作品,无法搜索");
try { try {
return await enqueueSubscriptionSearch(d, sub.id, "manual"); return await enqueueSubscriptionSearch(d, sub.id, "manual");
} catch (err) { } catch (err) {
throw new ApiException("upstream_unavailable", `搜索任务入队失败:${errMessage(err)}`); throw new ApiException("upstream_unavailable", `搜索任务入队失败:${errMessage(err)}`);
} }
}; };
@@ -623,7 +623,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
async (_c, { params, body }) => { async (_c, { params, body }) => {
const sub = await load(params.id); const sub = await load(params.id);
if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status))
conflict("订阅不在追踪中(暂停/已完成/已取消),先恢复或重新订阅"); conflict("订阅不在追踪中(暂停/已完成/已取消),先恢复或重新订阅");
const slots = sub.media_entity_id const slots = sub.media_entity_id
? await db() ? await db()
.selectFrom("acquisition_slot") .selectFrom("acquisition_slot")
@@ -641,7 +641,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
{ idempotencyKey: `fill:${params.slotId}:${d.ports.now().getTime()}`, traceId: params.slotId }, { idempotencyKey: `fill:${params.slotId}:${d.ports.now().getTime()}`, traceId: params.slotId },
); );
} catch (err) { } catch (err) {
throw new ApiException("upstream_unavailable", `补全任务入队失败:${errMessage(err)}`); throw new ApiException("upstream_unavailable", `补全任务入队失败:${errMessage(err)}`);
} }
}, },
{ tag: TAG, status: 202 }, { tag: TAG, status: 202 },
@@ -801,7 +801,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
}).then( }).then(
({ taskId }) => db.updateTable("acquisition_plan").set({ task_id: taskId }).where("id", "=", planId).execute(), ({ taskId }) => db.updateTable("acquisition_plan").set({ task_id: taskId }).where("id", "=", planId).execute(),
(err) => (err) =>
d.ctx.logger.warn({ err: { message: (err as Error).message }, planId }, "submitDownload 入队失败,留给巡检补投"), d.ctx.logger.warn({ err: { message: (err as Error).message }, planId }, "submitDownload 入队失败,留给巡检补投"),
); );
return { planId, deferred: false }; return { planId, deferred: false };
} }
@@ -932,7 +932,7 @@ async function gates(
reasons: [b.decision === "unknown" ? "budget_unknown_size" : "budget_exceeded"], reasons: [b.decision === "unknown" ? "budget_unknown_size" : "budget_exceeded"],
rejected: b.violations.map((v) => ({ rejected: b.violations.map((v) => ({
code: `budget.${v.kind}`, code: `budget.${v.kind}`,
message: `${v.kind}: 上限 ${v.limit},当前 ${v.current},请求 ${v.requested}`, message: `${v.kind}:上限 ${v.limit},当前 ${v.current},请求 ${v.requested}`,
})), })),
}; };
} }
@@ -949,7 +949,7 @@ async function gates(
rejected: [ rejected: [
{ {
code: `cache.${dv.decision}`, code: `cache.${dv.decision}`,
message: `缓存卷 ${vol.volumeKey} 使用率 ${dv.usedPct?.toFixed(1) ?? "未知"}%,预测 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`, message: `缓存卷 ${vol.volumeKey} 使用率 ${dv.usedPct?.toFixed(1) ?? "未知"}%,预测 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`,
}, },
], ],
}; };
@@ -151,7 +151,7 @@ export async function applySlotEvent(
return false; return false;
} }
if (!next) { if (!next) {
d.ctx.logger.warn({ slotId, event: event.type, from: slot.status }, "Slot 状态迁移被拒绝(已被人工处理?)"); d.ctx.logger.warn({ slotId, event: event.type, from: slot.status }, "Slot 状态迁移被拒绝(已被人工处理?)");
return false; return false;
} }
const ended = PLAN_EVENTS.has(event.type) && stageAfter(event.type) === null; const ended = PLAN_EVENTS.has(event.type) && stageAfter(event.type) === null;
@@ -288,7 +288,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] }; return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] };
} }
case "accepted_unconfirmed": case "accepted_unconfirmed":
if (!out.hash) throw new Error("qB 已收单但未能确认任务(无 hash),稍后按探针标签对账"); if (!out.hash) throw new Error("qB 已收单但未能确认任务(无 hash),稍后按探针标签对账");
return { return {
kind: "accepted_unconfirmed", kind: "accepted_unconfirmed",
hash: out.hash, hash: out.hash,
@@ -351,7 +351,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
// 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果 // 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果
if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) { if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) {
await selectPackFiles(d, client, hash, plan, rel).catch((err) => await selectPackFiles(d, client, hash, plan, rel).catch((err) =>
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"), d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
); );
} }
if (!dtId) { if (!dtId) {
@@ -754,7 +754,7 @@ async function observeLocked(
// 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2) // 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2)
await upsertDecision(d, trx, { await upsertDecision(d, trx, {
kind: "download_stalled", kind: "download_stalled",
title: `下载无进展:${t.name}`, title: `下载无进展:${t.name}`,
summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`, summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`,
subjectType: "download_task", subjectType: "download_task",
subjectId: row.id, subjectId: row.id,
@@ -771,7 +771,7 @@ async function observeLocked(
if ((merged.status === "failed" || merged.status === "missing") && changed) { if ((merged.status === "failed" || merged.status === "missing") && changed) {
await upsertDecision(d, trx, { await upsertDecision(d, trx, {
kind: "download_failed", kind: "download_failed",
title: `下载异常:${t.name}`, title: `下载异常:${t.name}`,
summary: `qB 状态 ${t.rawState}`, summary: `qB 状态 ${t.rawState}`,
subjectType: "download_task", subjectType: "download_task",
subjectId: row.id, subjectId: row.id,
@@ -1133,7 +1133,7 @@ export async function cleanupDownloadCache(
kind: "space_insufficient", kind: "space_insufficient",
severity: "critical", severity: "critical",
title: `下载缓存卷 ${v.volumeKey} 空间不足`, title: `下载缓存卷 ${v.volumeKey} 空间不足`,
summary: `使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停`, summary: `使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停`,
subjectType: "cache_volume", subjectType: "cache_volume",
dedupeKey: `space_insufficient:${v.volumeKey}`, dedupeKey: `space_insufficient:${v.volumeKey}`,
evidence: { usedPercent: pct, locked: entry.skipped }, evidence: { usedPercent: pct, locked: entry.skipped },
@@ -1146,7 +1146,7 @@ export async function cleanupDownloadCache(
/** qB 删种 + 删数据:必经 assertSafeToDeleteData(本机视图与下载器视图各查一次) */ /** qB 删种 + 删数据:必经 assertSafeToDeleteData(本机视图与下载器视图各查一次) */
async function removeWithData(d: AoDeps, cand: CleanupCandidate, libraryRootsLocal: string[]): Promise<boolean> { async function removeWithData(d: AoDeps, cand: CleanupCandidate, libraryRootsLocal: string[]): Promise<boolean> {
const { row, topo } = cand; const { row, topo } = cand;
if (!row.content_path) throw new Error("content_path 未知,不能删数据"); if (!row.content_path) throw new Error("content_path 未知,不能删数据");
const localContent = hostPathOf(topo, row.content_path); const localContent = hostPathOf(topo, row.content_path);
if (!localContent) throw new Error("content_path 无法映射到本机路径"); if (!localContent) throw new Error("content_path 无法映射到本机路径");
assertSafeToDeleteData( assertSafeToDeleteData(
@@ -217,14 +217,14 @@ export function ruleFingerprint(g: {
} }
const fmtErrors = (errs: readonly RuleParseError[]) => const fmtErrors = (errs: readonly RuleParseError[]) =>
errs.map((e) => `${e.message}(第 ${e.layer + 1} 层,位置 ${e.position})`).join(";"); errs.map((e) => `${e.message}(第 ${e.layer + 1} 层,位置 ${e.position})`).join(";");
/** 表达式里的原子都要存在(内置且支持,或自定义) */ /** 表达式里的原子都要存在(内置且支持,或自定义) */
function checkAtoms(e: RuleExpr, custom: ReadonlySet<string>): string[] { function checkAtoms(e: RuleExpr, custom: ReadonlySet<string>): string[] {
const bad: string[] = []; const bad: string[] = [];
for (const id of atomsOf(e)) { for (const id of atomsOf(e)) {
const b = BUILTIN_ATOM_MAP.get(id); const b = BUILTIN_ATOM_MAP.get(id);
if (b ? !b.supported : !custom.has(id)) bad.push(b ? `${id}(不支持)` : `${id}(不存在)`); if (b ? !b.supported : !custom.has(id)) bad.push(b ? `${id}(不支持)` : `${id}(不存在)`);
} }
return bad; return bad;
} }
@@ -240,7 +240,7 @@ export function normalizeGroupInput(
if (body.ruleString.trim() === "") out.layers = []; if (body.ruleString.trim() === "") out.layers = [];
else { else {
const p = parseRuleString(body.ruleString, { customAtoms: custom }); const p = parseRuleString(body.ruleString, { customAtoms: custom });
if (p.errors.length) throw new ApiException("validation_failed", `规则串解析失败:${fmtErrors(p.errors)}`); if (p.errors.length) throw new ApiException("validation_failed", `规则串解析失败:${fmtErrors(p.errors)}`);
out.layers = p.layers; out.layers = p.layers;
} }
} else if (body.layers !== undefined) { } else if (body.layers !== undefined) {
@@ -252,16 +252,16 @@ export function normalizeGroupInput(
if (body.hardExcludes !== undefined) { if (body.hardExcludes !== undefined) {
out.hardExcludes = body.hardExcludes.map((x: string | ApiRuleExpr, i: number) => { out.hardExcludes = body.hardExcludes.map((x: string | ApiRuleExpr, i: number) => {
if (typeof x !== "string") return normalizeExpr(x as RuleExpr); if (typeof x !== "string") return normalizeExpr(x as RuleExpr);
if (x.includes(">")) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条不能分层(去掉 >)`); if (x.includes(">")) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条不能分层(去掉 >)`);
const p = parseExpr(x, { customAtoms: custom }); const p = parseExpr(x, { customAtoms: custom });
if (!p.expr) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条解析失败:${fmtErrors(p.errors)}`); if (!p.expr) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条解析失败:${fmtErrors(p.errors)}`);
return p.expr; return p.expr;
}); });
} }
const bad = [...(out.layers ?? []).map((l) => l.expr), ...(out.hardExcludes ?? [])].flatMap((e) => const bad = [...(out.layers ?? []).map((l) => l.expr), ...(out.hardExcludes ?? [])].flatMap((e) =>
checkAtoms(e, custom), checkAtoms(e, custom),
); );
if (bad.length) throw new ApiException("validation_failed", `条件无效:${[...new Set(bad)].join("、")}`); if (bad.length) throw new ApiException("validation_failed", `条件无效:${[...new Set(bad)].join("、")}`);
return out; return out;
} }
@@ -83,7 +83,7 @@ async function idsByKey(
.execute(); .execute();
return values.map((v) => { return values.map((v) => {
const hit = rows.find((r) => r.id === v || r.k === v); const hit = rows.find((r) => r.id === v || r.k === v);
if (!hit) bad(`${label}不存在:${v}`); if (!hit) bad(`${label}不存在:${v}`);
return hit!.id; return hit!.id;
}); });
} }
@@ -454,14 +454,14 @@ export async function normalizeSubscriptionParams(
const parsed = SubscriptionParams.safeParse(src); const parsed = SubscriptionParams.safeParse(src);
if (!parsed.success) { if (!parsed.success) {
const i = parsed.error.issues[0]; const i = parsed.error.issues[0];
bad(`params.${i?.path.join(".") ?? ""} 无效:${i?.message ?? ""}`); bad(`params.${i?.path.join(".") ?? ""} 无效:${i?.message ?? ""}`);
} }
const p = parsed.data as SubscriptionParams; const p = parsed.data as SubscriptionParams;
// ── 库根存在性与正则白名单 ── // ── 库根存在性与正则白名单 ──
if (p.libraryRootId) { if (p.libraryRootId) {
const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst(); const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst();
if (!r) bad(`库根不存在:${p.libraryRootId}`); if (!r) bad(`库根不存在:${p.libraryRootId}`);
} }
for (const k of ["include", "exclude"] as const) for (const k of ["include", "exclude"] as const)
for (const pat of p[k] ?? []) { for (const pat of p[k] ?? []) {
@@ -470,7 +470,7 @@ export async function normalizeSubscriptionParams(
try { try {
new RegExp(pat, "i"); new RegExp(pat, "i");
} catch { } catch {
bad(`${k} 正则语法错误:${pat}`); bad(`${k} 正则语法错误:${pat}`);
} }
} }
if (p.stopAfter && Number.isNaN(Date.parse(p.stopAfter))) bad("stopAfter 不是有效时间"); if (p.stopAfter && Number.isNaN(Date.parse(p.stopAfter))) bad("stopAfter 不是有效时间");
@@ -474,7 +474,7 @@ export async function afterSlotVerified(
kind: "other", kind: "other",
severity: "info", severity: "info",
title: "疑似缺集", title: "疑似缺集",
summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500), summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
subjectType: "acquisition_slot", subjectType: "acquisition_slot",
subjectId: slot.id, subjectId: slot.id,
dedupeKey: `suspected_missing:${slot.id}`, dedupeKey: `suspected_missing:${slot.id}`,
@@ -549,7 +549,7 @@ export async function completeSubscription(
completed_at: d.ports.now(), completed_at: d.ports.now(),
}) })
.execute(); .execute();
d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史"); d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史");
return true; return true;
} }
+4 -4
View File
@@ -111,7 +111,7 @@ export function createAuthorizer(deps: {
// 自动化总开关 / 只读对账:自动来源(agent / 后台任务)一律不执行写操作 // 自动化总开关 / 只读对账:自动来源(agent / 后台任务)一律不执行写操作
if (ctx.source !== "web" && d.kind === "execute") { if (ctx.source !== "web" && d.kind === "execute") {
const a = await deps.settings.get("automation"); const a = await deps.settings.get("automation");
if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,不自动执行" }; if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,不自动执行" };
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式下不自动执行" }; if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式下不自动执行" };
} }
if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true }; if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true };
@@ -154,9 +154,9 @@ export function createAuthorizer(deps: {
if (a.status !== "approved") return { allowed: false, reason: `审批 ${approvalId} 状态为 ${a.status}` }; if (a.status !== "approved") return { allowed: false, reason: `审批 ${approvalId} 状态为 ${a.status}` };
if (a.plan_type !== ap.planType || a.plan_id !== planId) if (a.plan_type !== ap.planType || a.plan_id !== planId)
return { allowed: false, reason: "审批绑定的计划与本次调用不一致" }; return { allowed: false, reason: "审批绑定的计划与本次调用不一致" };
if (a.payload_hash !== target.payloadHash) return { allowed: false, reason: "计划内容已变化,旧审批失效" }; if (a.payload_hash !== target.payloadHash) return { allowed: false, reason: "计划内容已变化,旧审批失效" };
if (a.expected_revision !== target.revision) if (a.expected_revision !== target.revision)
return { allowed: false, reason: "计划 revision 已变化,旧审批失效" }; return { allowed: false, reason: "计划 revision 已变化,旧审批失效" };
if (a.expires_at && a.expires_at.getTime() <= now().getTime()) return { allowed: false, reason: "审批已过期" }; if (a.expires_at && a.expires_at.getTime() <= now().getTime()) return { allowed: false, reason: "审批已过期" };
return { allowed: true, approvalId: a.id }; return { allowed: true, approvalId: a.id };
} }
@@ -227,7 +227,7 @@ export function createAuthorizer(deps: {
} }
return { return {
allowed: false, allowed: false,
reason: reasons.length > 0 ? `没有可用的授权:${reasons.join(";")}` : `缺少 ${d.id} 的持续授权或有效的单次审批`, reason: reasons.length > 0 ? `没有可用的授权:${reasons.join(";")}` : `缺少 ${d.id} 的持续授权或有效的单次审批`,
}; };
}; };
} }
@@ -39,7 +39,7 @@ async function checkGrantScope(ctx: ServerContext, grantId: string, planId: stri
.where("p.id", "=", planId) .where("p.id", "=", planId)
.executeTakeFirst() .executeTakeFirst()
: undefined; : undefined;
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`); if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id)) if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id))
throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`); throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`);
if (scope.mediaKinds?.length) { if (scope.mediaKinds?.length) {
@@ -87,7 +87,7 @@ export function registerDownloadRecoveryCapabilities(
if (input.action === "recheck") await client.recheck([dt.torrent_hash]); if (input.action === "recheck") await client.recheck([dt.torrent_hash]);
else await client.resume([dt.torrent_hash]); else await client.resume([dt.torrent_hash]);
} catch (err) { } catch (err) {
throw new CapabilityError("internal", `下载器请求失败:${(err as Error).message}`); throw new CapabilityError("internal", `下载器请求失败:${(err as Error).message}`);
} }
return { return {
downloadTaskId: dt.id, downloadTaskId: dt.id,
@@ -58,7 +58,7 @@ export async function taskTimeline(ctx: ServerContext, t: TaskRow) {
out.push({ out.push({
at: t.finished_at.toISOString(), at: t.finished_at.toISOString(),
type: `task.${t.status}`, type: `task.${t.status}`,
summary: t.status === "failed" ? `失败:${JSON.stringify(t.last_error ?? {}).slice(0, 200)}` : t.status, summary: t.status === "failed" ? `失败:${JSON.stringify(t.last_error ?? {}).slice(0, 200)}` : t.status,
}); });
if (t.subject_type === "acquisition_plan" && t.subject_id) { if (t.subject_type === "acquisition_plan" && t.subject_id) {
const p = await db const p = await db
@@ -82,7 +82,7 @@ export async function taskTimeline(ctx: ServerContext, t: TaskRow) {
out.push({ out.push({
at: dt.updated_at.toISOString(), at: dt.updated_at.toISOString(),
type: "download.status", type: "download.status",
summary: `下载任务 ${dt.status}(进度 ${Math.round(Number(dt.progress ?? 0) * 100)}%)`, summary: `下载任务 ${dt.status}(进度 ${Math.round(Number(dt.progress ?? 0) * 100)}%)`,
data: { downloadTaskId: dt.id, status: dt.status }, data: { downloadTaskId: dt.id, status: dt.status },
}); });
const recs = dts.length const recs = dts.length
@@ -125,7 +125,7 @@ export async function retryTask(ctx: ServerContext, taskId: string, by: string,
const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", taskId).executeTakeFirst(); const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", taskId).executeTakeFirst();
if (!t) throw new CapabilityError("not_found", "任务不存在"); if (!t) throw new CapabilityError("not_found", "任务不存在");
if (!["failed", "dead", "cancelled"].includes(t.status)) if (!["failed", "dead", "cancelled"].includes(t.status))
throw new CapabilityError("precondition", `只能重试失败/死信/已取消的任务(当前 ${t.status})`); throw new CapabilityError("precondition", `只能重试失败/死信/已取消的任务(当前 ${t.status})`);
const input = const input =
t.kind === "agentRun" ? { agentRunId: t.subject_id } : { ...((t.input ?? {}) as Record<string, unknown>), taskId }; t.kind === "agentRun" ? { agentRunId: t.subject_id } : { ...((t.input ?? {}) as Record<string, unknown>), taskId };
const { runId } = await ctx.engine.enqueue(engineTaskName(t.kind), input, { const { runId } = await ctx.engine.enqueue(engineTaskName(t.kind), input, {
@@ -79,7 +79,7 @@ export const DEMO_SCRIPTS: FakeScript[] = [
finalText: (results) => { finalText: (results) => {
const r = results.find((x) => x.capabilityId === "inventory.query"); const r = results.find((x) => x.capabilityId === "inventory.query");
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0; const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`; return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
}, },
}, },
]; ];
+13 -13
View File
@@ -289,7 +289,7 @@ async function planSummary(db: Kysely<DB>, planType: string, planId: string): Pr
} }
if (planType === "organize_plan") { if (planType === "organize_plan") {
const r = await db.selectFrom("artifact").select("title").where("id", "=", planId).executeTakeFirst(); const r = await db.selectFrom("artifact").select("title").where("id", "=", planId).executeTakeFirst();
if (r) return safe(`整理:${r.title}`, 300); if (r) return safe(`整理:${r.title}`, 300);
} }
if (planType === "policy_revision") { if (planType === "policy_revision") {
const r = await db.selectFrom("policy_revision").select("revision").where("id", "=", planId).executeTakeFirst(); const r = await db.selectFrom("policy_revision").select("revision").where("id", "=", planId).executeTakeFirst();
@@ -353,8 +353,8 @@ export async function inlineApproval(
return { allowed: false, reason: "审批绑定的计划与本次调用不一致" }; return { allowed: false, reason: "审批绑定的计划与本次调用不一致" };
} }
if (existing.payload_hash !== b.payloadHash || existing.expected_revision !== b.revision) { if (existing.payload_hash !== b.payloadHash || existing.expected_revision !== b.revision) {
await resolved("invalid", "计划在审批期间变化,旧审批失效"); await resolved("invalid", "计划在审批期间变化,旧审批失效");
return { allowed: false, reason: "计划在审批期间变化,旧审批失效;重新生成计划后再提交" }; return { allowed: false, reason: "计划在审批期间变化,旧审批失效;重新生成计划后再提交" };
} }
if (existing.expires_at && existing.expires_at.getTime() <= now.getTime()) { if (existing.expires_at && existing.expires_at.getTime() <= now.getTime()) {
await resolved("expired", null); await resolved("expired", null);
@@ -383,7 +383,7 @@ export async function inlineApproval(
if (existing.status === "expired" || (existing.expires_at && existing.expires_at.getTime() <= now.getTime())) { if (existing.status === "expired" || (existing.expires_at && existing.expires_at.getTime() <= now.getTime())) {
await expireApproval(sctx, existing.id); await expireApproval(sctx, existing.id);
await resolved("expired", null); await resolved("expired", null);
return { allowed: false, reason: "审批已过期,没有执行" }; return { allowed: false, reason: "审批已过期,没有执行" };
} }
// 仍在等待:重新挂起(崩溃重放 / 兜底轮询提前醒来) // 仍在等待:重新挂起(崩溃重放 / 兜底轮询提前醒来)
if (live?.suspend && !inv.startsWith("reconcile:")) if (live?.suspend && !inv.startsWith("reconcile:"))
@@ -405,7 +405,7 @@ export async function inlineApproval(
const summary = await planSummary(db, plan.planType, plan.planId); const summary = await planSummary(db, plan.planType, plan.planId);
const decision = await insertDecision(sctx, { const decision = await insertDecision(sctx, {
kind: "approval_required", kind: "approval_required",
title: `Agent 请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`, title: `Agent 请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
summary, summary,
runId, runId,
dedupeKey: `${APPROVAL_KEY}:${runId}:${inv}`, dedupeKey: `${APPROVAL_KEY}:${runId}:${inv}`,
@@ -450,7 +450,7 @@ export async function inlineApproval(
deadline: expiresAt.toISOString(), deadline: expiresAt.toISOString(),
approvalId: a.id, approvalId: a.id,
}); });
return { allowed: false, reason: "已在步骤处发起审批,等待管理员批准" }; return { allowed: false, reason: "已在步骤处发起审批,等待管理员批准" };
} }
async function expireApproval(ctx: ServerContext, approvalId: string) { async function expireApproval(ctx: ServerContext, approvalId: string) {
@@ -524,7 +524,7 @@ export async function proposeFirstBlock(db: Kysely<DB>, runId: string): Promise<
.where("dedupe_key", "like", `${OPTIONS_KEY}:%`) .where("dedupe_key", "like", `${OPTIONS_KEY}:%`)
.where("status", "=", "resolved") .where("status", "=", "resolved")
.executeTakeFirst(); .executeTakeFirst();
return chosen ? null : "本会话要求先看方案再执行:先用 agent.propose_options 给出方案,用户选定后才能执行"; return chosen ? null : "本会话要求先看方案再执行:先用 agent.propose_options 给出方案,用户选定后才能执行";
} }
// ── 挂起唤醒条件(runAgent 的 waitForFact) ── // ── 挂起唤醒条件(runAgent 的 waitForFact) ──
@@ -569,7 +569,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
registry.register(capabilitySpec("agent.plan"), async (input, c) => { registry.register(capabilitySpec("agent.plan"), async (input, c) => {
const run = await runOf(ctx, c); const run = await runOf(ctx, c);
const live = liveRun(run.id); const live = liveRun(run.id);
if (!live) throw new CapabilityError("precondition", "会话不在运行中,不能更新步骤清单"); if (!live) throw new CapabilityError("precondition", "会话不在运行中,不能更新步骤清单");
const cur = await currentPlan(ctx, live, run.id); const cur = await currentPlan(ctx, live, run.id);
const byId = new Map(input.replace ? [] : cur.steps.map((s) => [s.id, s] as const)); const byId = new Map(input.replace ? [] : cur.steps.map((s) => [s.id, s] as const));
const order = input.replace ? [] : cur.steps.map((s) => s.id); const order = input.replace ? [] : cur.steps.map((s) => s.id);
@@ -583,7 +583,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
}); });
if (!order.includes(s.id)) order.push(s.id); if (!order.includes(s.id)) order.push(s.id);
} }
if (order.length > 30) throw new CapabilityError("invalid_input", "步骤最多 30 个;合并细碎步骤后再更新"); if (order.length > 30) throw new CapabilityError("invalid_input", "步骤最多 30 个;合并细碎步骤后再更新");
const next = { steps: order.map((id) => byId.get(id)!), revision: cur.revision + 1 }; const next = { steps: order.map((id) => byId.get(id)!), revision: cur.revision + 1 };
live.plan = next; live.plan = next;
live.handle.appendEvent({ type: "plan_updated", steps: next.steps, revision: next.revision }); live.handle.appendEvent({ type: "plan_updated", steps: next.steps, revision: next.revision });
@@ -594,7 +594,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
registry.register({ ...capabilitySpec("agent.propose_options"), replay: "safe" }, async (input, c) => { registry.register({ ...capabilitySpec("agent.propose_options"), replay: "safe" }, async (input, c) => {
const run = await runOf(ctx, c); const run = await runOf(ctx, c);
if (!canAskUser(run)) if (!canAskUser(run))
throw new CapabilityError("precondition", "子会话和后台会话不能让用户选方案:自己按证据决定,在汇报里说明取舍"); throw new CapabilityError("precondition", "子会话和后台会话不能让用户选方案:自己按证据决定,在汇报里说明取舍");
const live = liveRun(run.id); const live = liveRun(run.id);
const inv = c.invocationId; const inv = c.invocationId;
if (!live?.suspend || !inv) throw new CapabilityError("precondition", "会话不在运行中"); if (!live?.suspend || !inv) throw new CapabilityError("precondition", "会话不在运行中");
@@ -625,7 +625,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
const stepId = await currentStepId(ctx, live, run.id); const stepId = await currentStepId(ctx, live, run.id);
d = await insertDecision(ctx, { d = await insertDecision(ctx, {
kind: "other", kind: "other",
title: `Agent 等你选方案:${clip(input.question, 80)}`, title: `Agent 等你选方案:${clip(input.question, 80)}`,
summary: input.options.map((o) => `${o.id}. ${o.title}`).join(";"), summary: input.options.map((o) => `${o.id}. ${o.title}`).join(";"),
runId: run.id, runId: run.id,
dedupeKey, dedupeKey,
@@ -660,8 +660,8 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
const pick = ev.recommendedId ?? ev.options[0]?.id ?? null; const pick = ev.recommendedId ?? ev.options[0]?.id ?? null;
const res: OptionsResolution = const res: OptionsResolution =
ev.timeoutAction === "recommended" && pick ev.timeoutAction === "recommended" && pick
? { optionKey: pick, outcome: "chosen", reason: "超时未选,按设置自动选推荐项", timeout: true } ? { optionKey: pick, outcome: "chosen", reason: "超时未选,按设置自动选推荐项", timeout: true }
: { optionKey: null, outcome: "stop", reason: "超时未选,按设置停止", timeout: true }; : { optionKey: null, outcome: "stop", reason: "超时未选,按设置停止", timeout: true };
await closeDecision(ctx, d.id, res.outcome === "chosen" ? "resolved" : "expired", { ...res }, "system:timeout"); await closeDecision(ctx, d.id, res.outcome === "chosen" ? "resolved" : "expired", { ...res }, "system:timeout");
result = optionsResult(ev, res.outcome === "chosen" ? "resolved" : "expired", res, "timeout"); result = optionsResult(ev, res.outcome === "chosen" ? "resolved" : "expired", res, "timeout");
} }
+16 -16
View File
@@ -127,9 +127,9 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
function suggestionFor(d: DecisionRow, reason: string): string { function suggestionFor(d: DecisionRow, reason: string): string {
if (d.kind === "identity_ambiguous") if (d.kind === "identity_ambiguous")
return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`; return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`;
return ( return (
`${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + `${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
`换源、删除需人工决定。` `换源、删除需人工决定。`
); );
} }
@@ -144,7 +144,7 @@ async function addRecheckOption(ctx: ServerContext, d: DecisionRow) {
{ {
key: "recheck", key: "recheck",
label: "强制重新校验", label: "强制重新校验",
description: "让下载器重新校验已有文件(不换源、不删除)", description: "让下载器重新校验已有文件(不换源、不删除)",
capabilityId: "download.recheck", capabilityId: "download.recheck",
input: { downloadTaskId: d.subject_id, action: "recheck" }, input: { downloadTaskId: d.subject_id, action: "recheck" },
}, },
@@ -184,7 +184,7 @@ export async function autoInvestigate(
and (evidence->'autoRecover'->>'recoverAttempted')::boolean is true`.execute(ctx.db); and (evidence->'autoRecover'->>'recoverAttempted')::boolean is true`.execute(ctx.db);
attempt = (prior.rows[0]?.n ?? 0) + 1; attempt = (prior.rows[0]?.n ?? 0) + 1;
if (attempt > s.investigateMaxAttempts) { if (attempt > s.investigateMaxAttempts) {
const reason = `同一根因已自动恢复 ${attempt - 1} 次(上限 ${s.investigateMaxAttempts}),不再自动处理`; const reason = `同一根因已自动恢复 ${attempt - 1} 次(上限 ${s.investigateMaxAttempts}),不再自动处理`;
await mergeEvidence(ctx, d.id, { await mergeEvidence(ctx, d.id, {
mode: "investigate", mode: "investigate",
requestedMode: requested, requestedMode: requested,
@@ -229,14 +229,14 @@ export async function autoInvestigate(
const taskId = await relatedTask(ctx, d); const taskId = await relatedTask(ctx, d);
const goal = [ const goal = [
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`, `调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
d.summary ? `摘要:${d.summary}。` : "", d.summary ? `摘要:${d.summary}。` : "",
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "", d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "", dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
taskId ? `关联任务 ${taskId}。` : "", taskId ? `关联任务 ${taskId}。` : "",
mode === "recover" mode === "recover"
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" + ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。" "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。", : "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
].join(""); ].join("");
const { run, created } = await startRun(ctx, { const { run, created } = await startRun(ctx, {
goal, goal,
@@ -380,16 +380,16 @@ export async function applyRecoveryOutcome(
const what = done.map((a) => a.capabilityId).join(" + "); const what = done.map((a) => a.capabilityId).join(" + ");
const summary = const summary =
outcome === "recovered" outcome === "recovered"
? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。` ? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。`
: outcome === "failed" : outcome === "failed"
? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。` ? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。`
: outcome === "unverified" : outcome === "unverified"
? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。` ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。`
: outcome === "budget_exceeded" : outcome === "budget_exceeded"
? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。` ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。`
: outcome === "cancelled" : outcome === "cancelled"
? "未恢复:运行已取消。" ? "未恢复:运行已取消。"
: `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`; : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`;
const evidence: Partial<AutoRecoverEvidence> = { const evidence: Partial<AutoRecoverEvidence> = {
runId, runId,
outcome, outcome,
@@ -31,7 +31,7 @@ export function externalUpstream(capabilityId: string, input: unknown): Upstream
export class UpstreamBusyError extends Error { export class UpstreamBusyError extends Error {
constructor(readonly upstream: Upstream) { constructor(readonly upstream: Upstream) {
super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,稍后重试`); super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,稍后重试`);
} }
} }
+4 -4
View File
@@ -102,11 +102,11 @@ export async function buildGoalReport(
const done = records.filter((r) => r.status === "succeeded" || r.status === "partial"); const done = records.filter((r) => r.status === "succeeded" || r.status === "partial");
const summary = const summary =
state === "no_actions" state === "no_actions"
? "本次没有提交下载或整理,结论见模型汇报。" ? "本次没有提交下载或整理,结论见模型汇报。"
: [ : [
`已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`, `已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`,
state === "waiting" ? "已提交≠已入库:等待下载完成并核验入库。" : "", state === "waiting" ? "已提交≠已入库:等待下载完成并核验入库。" : "",
done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "", done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(""); .join("");
+7 -6
View File
@@ -224,7 +224,7 @@ export function registerAgentRoutes(
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst(); const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!run) throw new ApiException("not_found", "Agent 运行不存在"); if (!run) throw new ApiException("not_found", "Agent 运行不存在");
if (body.optionId === null && !body.reason?.trim()) if (body.optionId === null && !body.reason?.trim())
throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案"); throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案");
const r = await answerOptions(ctx, run.id, body, actorId); const r = await answerOptions(ctx, run.id, body, actorId);
if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择"); if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案"); if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
@@ -287,7 +287,7 @@ export function registerAgentRoutes(
const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst(); const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "授权不存在"); if (!cur) throw new ApiException("not_found", "授权不存在");
if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at) if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at)
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围需新建授权"); throw new ApiException("validation_failed", "只能缩短有效期;扩大范围需新建授权");
const r = await db() const r = await db()
.updateTable("automation_grant") .updateTable("automation_grant")
.set({ .set({
@@ -362,7 +362,7 @@ export function registerAgentRoutes(
.executeTakeFirst(); .executeTakeFirst();
if (!cur) throw new ApiException("not_found", "审批不存在"); if (!cur) throw new ApiException("not_found", "审批不存在");
if (cur.payload_hash !== body.payloadHash) if (cur.payload_hash !== body.payloadHash)
throw new ApiException("conflict", "计划已变化(payloadHash 不一致),重新申请审批"); throw new ApiException("conflict", "计划已变化(payloadHash 不一致),重新申请审批");
const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"]; const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"];
if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`); if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`);
return trx return trx
@@ -388,7 +388,7 @@ export function registerAgentRoutes(
})); }));
route(app, Api.capabilities.invoke, async ({ params, body, actor, actorId, c }) => { route(app, Api.capabilities.invoke, async ({ params, body, actor, actorId, c }) => {
const entry = ctx.capabilities.get(params.capabilityId); const entry = ctx.capabilities.get(params.capabilityId);
if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`); if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`);
const scopes = (c.get("principal")?.scopes ?? actor?.scopes ?? []) as string[]; const scopes = (c.get("principal")?.scopes ?? actor?.scopes ?? []) as string[];
if (!hasScope(scopes, entry.descriptor.scope)) if (!hasScope(scopes, entry.descriptor.scope))
throw new ApiException("forbidden", `调用 ${entry.descriptor.id} 需要 scope: ${entry.descriptor.scope}`); throw new ApiException("forbidden", `调用 ${entry.descriptor.id} 需要 scope: ${entry.descriptor.scope}`);
@@ -431,7 +431,8 @@ export function registerAgentRoutes(
slotIds = [s.id]; slotIds = [s.id];
} else { } else {
const entityId = body.entityId ?? rel.media_entity_id; const entityId = body.entityId ?? rel.media_entity_id;
if (!entityId) throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在“身份与元数据”指定作品"); if (!entityId)
throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在「身份与元数据」指定作品");
slotIds = ( slotIds = (
await db() await db()
.selectFrom("acquisition_slot") .selectFrom("acquisition_slot")
@@ -443,7 +444,7 @@ export function registerAgentRoutes(
} }
const rejected: { slotId: string | null; code: string; message: string }[] = []; const rejected: { slotId: string | null; code: string; message: string }[] = [];
if (slotIds.length === 0) if (slotIds.length === 0)
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" }); rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" });
const invokeCtx = { const invokeCtx = {
actorId, actorId,
taskId: `http:${c.get("requestId") ?? "req"}`, taskId: `http:${c.get("requestId") ?? "req"}`,
+13 -12
View File
@@ -86,7 +86,7 @@ export function scopeFor(mode: "execute" | "plan_only", allowlist?: readonly str
const d = CAPABILITY_CATALOG[id as KnownCapabilityId]; const d = CAPABILITY_CATALOG[id as KnownCapabilityId];
return !d || !PLAN_ONLY_KINDS.includes(d.kind); return !d || !PLAN_ONLY_KINDS.includes(d.kind);
}); });
if (bad.length) throw new Error(`plan_only 模式只允许 read/preview/propose 类能力,不允许:${bad.join(", ")}`); if (bad.length) throw new Error(`plan_only 模式只允许 read/preview/propose 类能力,不允许:${bad.join(", ")}`);
return { return {
capabilities: allowlist?.length capabilities: allowlist?.length
? [...allowlist] ? [...allowlist]
@@ -252,23 +252,24 @@ function systemPromptFor(
sub: { canSpawn: boolean; maxConcurrent: number; maxDepth: number }, sub: { canSpawn: boolean; maxConcurrent: number; maxDepth: number },
): string { ): string {
const constraints: string[] = [ const constraints: string[] = [
`可用能力:${scope.capabilities.join(", ")}`, `可用能力:${scope.capabilities.join(", ")}`,
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。", "有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
]; ];
if (run.mode === "plan_only") constraints.push("本次只读:只调查与规划,不提交下载、不改文件、不发布策略。"); if (run.mode === "plan_only") constraints.push("本次只读:只调查与规划,不提交下载、不改文件、不发布策略。");
if (sub.canSpawn) if (sub.canSpawn)
constraints.push( constraints.push(
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` + `可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。", "子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。",
); );
if (run.parent_run_id) constraints.push("你是子会话:只完成本目标,完成后简要汇报结论与证据(对象 ID),由父会话汇总。"); if (run.parent_run_id)
constraints.push("你是子会话:只完成本目标,完成后简要汇报结论与证据(对象 ID),由父会话汇总。");
if (run.trigger === "event" && run.mode === "execute") if (run.trigger === "event" && run.mode === "execute")
constraints.push( constraints.push(
"本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," + "本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
"每种动作最多一次;不换源、不删除、不清理;是否恢复由系统按业务状态核实,不要自行宣称已恢复。", "每种动作最多一次;不换源、不删除、不清理;是否恢复由系统按业务状态核实,不要自行宣称已恢复。",
); );
else if (run.trigger === "event") else if (run.trigger === "event")
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。"); constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
const opts = sessionOptions(run); const opts = sessionOptions(run);
return buildSystemPrompt({ return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent", role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
@@ -340,7 +341,7 @@ export async function drive(
let settings: AgentSettings; let settings: AgentSettings;
try { try {
const resolved = await resolveRunUpstream(ctx, run.upstream_id); const resolved = await resolveRunUpstream(ctx, run.upstream_id);
if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)"); if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
upstream = resolved; upstream = resolved;
fake = upstream.row.provider === "fake"; fake = upstream.row.provider === "fake";
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream); settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
@@ -373,7 +374,7 @@ export async function drive(
status: "failed", status: "failed",
reason: "error", reason: "error",
budget: null, budget: null,
message: `fakeScript 不合法:${(err as Error).message}`, message: `fakeScript 不合法:${(err as Error).message}`,
finalText: "", finalText: "",
}; };
} }
+9 -9
View File
@@ -142,7 +142,7 @@ async function enqueueRun(ctx: ServerContext, run: AgentRunRow, key: string) {
} catch (err) { } catch (err) {
await ctx.db await ctx.db
.updateTable("agent_run") .updateTable("agent_run")
.set({ status: "failed", error: JSON.stringify({ message: `入队失败:${(err as Error).message}` }) }) .set({ status: "failed", error: JSON.stringify({ message: `入队失败:${(err as Error).message}` }) })
.where("id", "=", run.id) .where("id", "=", run.id)
.execute(); .execute();
throw err; throw err;
@@ -237,13 +237,13 @@ export async function finalize(
let summary: string; let summary: string;
if (res.status === "cancelled" || run.cancel_requested_at) { if (res.status === "cancelled" || run.cancel_requested_at) {
status = "cancelled"; status = "cancelled";
summary = `已停止(后续步骤不再执行,已完成的动作不撤销)。${report.state === "no_actions" ? "" : report.summary}`; summary = `已停止(后续步骤不再执行,已完成的动作不撤销)。${report.state === "no_actions" ? "" : report.summary}`;
} else if (res.reason === "budget") { } else if (res.reason === "budget") {
status = "budget_exceeded"; status = "budget_exceeded";
summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`; summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`;
} else if (res.status === "failed") { } else if (res.status === "failed") {
status = "failed"; status = "failed";
summary = `运行失败:${res.message ?? ""}`; summary = `运行失败:${res.message ?? ""}`;
} else if (report.state === "waiting") { } else if (report.state === "waiting") {
status = "waiting"; status = "waiting";
summary = report.summary; summary = report.summary;
@@ -252,7 +252,7 @@ export async function finalize(
summary = report.summary; summary = report.summary;
} }
// 模型文本只作参考:附在业务核查结论之后,明确标注未经核查 // 模型文本只作参考:附在业务核查结论之后,明确标注未经核查
if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`; if (res.finalText && res.status !== "failed") summary += `\n 模型汇报(未经核查):${res.finalText}`;
// 子代理:父会话总预算含子会话用量 // 子代理:父会话总预算含子会话用量
const kids = await descendants(ctx, runId); const kids = await descendants(ctx, runId);
if (kids.length) { if (kids.length) {
@@ -264,7 +264,7 @@ export async function finalize(
.executeTakeFirst(); .executeTakeFirst();
const ownTokens = ((fresh?.usage ?? own) as { totalTokens?: number }).totalTokens ?? 0; const ownTokens = ((fresh?.usage ?? own) as { totalTokens?: number }).totalTokens ?? 0;
const ext = await descendantUsage(ctx, runId); const ext = await descendantUsage(ctx, runId);
summary += `\n子会话 ${kids.length} 个;会话树合计工具调用 ${(fresh?.tool_call_count ?? run.tool_call_count) + ext.toolCalls} 次、token ${ownTokens + ext.totalTokens}。`; summary += `\n 子会话 ${kids.length} 个;会话树合计工具调用 ${(fresh?.tool_call_count ?? run.tool_call_count) + ext.toolCalls} 次、token ${ownTokens + ext.totalTokens}。`;
} }
// 系统核实结论(授权内恢复)与终态同一次写入:观察者看到终态时汇总已是最终文本 // 系统核实结论(授权内恢复)与终态同一次写入:观察者看到终态时汇总已是最终文本
if (opts.summaryPrefix) summary = `${opts.summaryPrefix}\n${summary}`; if (opts.summaryPrefix) summary = `${opts.summaryPrefix}\n${summary}`;
@@ -391,9 +391,9 @@ export async function autoInvestigate(
if (!s.autoInvestigate) return { runId: null, created: false }; if (!s.autoInvestigate) return { runId: null, created: false };
const goal = [ const goal = [
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`, `调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
d.summary ? `摘要:${d.summary}。` : "", d.summary ? `摘要:${d.summary}。` : "",
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "", d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
"只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。", "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
].join(""); ].join("");
const { run, created } = await startRun(ctx, { const { run, created } = await startRun(ctx, {
goal, goal,
+14 -14
View File
@@ -75,7 +75,7 @@ const safe = (s: string | null | undefined, n = 1500) => clip(redactString(s ??
*/ */
export class AwaitSuspendedError extends CapabilityError { export class AwaitSuspendedError extends CapabilityError {
constructor() { constructor() {
super("precondition", "会话已挂起等待子会话结束,恢复后重放本调用"); super("precondition", "会话已挂起等待子会话结束,恢复后重放本调用");
} }
} }
@@ -321,7 +321,7 @@ export async function cascadeCancel(ctx: ServerContext, runId: string, reason: s
.updateTable("agent_run") .updateTable("agent_run")
.set({ .set({
cancel_requested_at: now, cancel_requested_at: now,
error: JSON.stringify({ abortReason: `父会话已取消:${reason}`, by: `agent:${runId}` }), error: JSON.stringify({ abortReason: `父会话已取消:${reason}`, by: `agent:${runId}` }),
}) })
.where("id", "in", ids) .where("id", "in", ids)
.where("cancel_requested_at", "is", null) .where("cancel_requested_at", "is", null)
@@ -330,7 +330,7 @@ export async function cascadeCancel(ctx: ServerContext, runId: string, reason: s
if (taskIds.length) if (taskIds.length)
await ctx.db await ctx.db
.updateTable("task") .updateTable("task")
.set({ cancel_requested_at: now, cancel_reason: `父会话已取消:${reason}` }) .set({ cancel_requested_at: now, cancel_reason: `父会话已取消:${reason}` })
.where("id", "in", taskIds) .where("id", "in", taskIds)
.where("cancel_requested_at", "is", null) .where("cancel_requested_at", "is", null)
.execute(); .execute();
@@ -489,7 +489,7 @@ export async function allocateBudget(
if (want !== undefined) out[k] = want; if (want !== undefined) out[k] = want;
return; return;
} }
if (left < min) throw new CapabilityError("budget", `本会话剩余${label}不足,不能再派生子会话(剩余 ${left})`); if (left < min) throw new CapabilityError("budget", `本会话剩余${label}不足,不能再派生子会话(剩余 ${left})`);
if (want !== undefined && want > left) if (want !== undefined && want > left)
throw new CapabilityError("budget", `子会话${label} ${want} 超过本会话剩余预算 ${left}`); throw new CapabilityError("budget", `子会话${label} ${want} 超过本会话剩余预算 ${left}`);
const v = want ?? (k === "maxCostUsd" ? left / 2 : Math.max(min, Math.floor(left / 2))); const v = want ?? (k === "maxCostUsd" ? left / 2 : Math.max(min, Math.floor(left / 2)));
@@ -498,7 +498,7 @@ export async function allocateBudget(
pick("maxToolCalls", remain.maxToolCalls, "工具调用次数", 1); pick("maxToolCalls", remain.maxToolCalls, "工具调用次数", 1);
pick("maxTokens", remain.maxTokens, " token", 1); pick("maxTokens", remain.maxTokens, " token", 1);
pick("maxCostUsd", remain.maxCostUsd, "费用", 0.0001); pick("maxCostUsd", remain.maxCostUsd, "费用", 0.0001);
pick("maxDurationSec", remain.maxDurationSec, "时长(秒)", 5); pick("maxDurationSec", remain.maxDurationSec, "时长(秒)", 5);
return out; return out;
} }
@@ -528,10 +528,10 @@ export function spawnScope(
const kindOf = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind; const kindOf = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind;
if (requested?.length) { if (requested?.length) {
const outside = requested.filter((id) => !parentCaps.includes(id)); const outside = requested.filter((id) => !parentCaps.includes(id));
if (outside.length) throw new CapabilityError("unauthorized", `子会话能力超出本会话白名单:${outside.join(", ")}`); if (outside.length) throw new CapabilityError("unauthorized", `子会话能力超出本会话白名单:${outside.join(", ")}`);
const tooStrong = requested.filter((id) => !kinds.includes(kindOf(id) as CapabilityKind)); const tooStrong = requested.filter((id) => !kinds.includes(kindOf(id) as CapabilityKind));
if (tooStrong.length) if (tooStrong.length)
throw new CapabilityError("unauthorized", `子会话模式/授权上限不允许这些能力:${tooStrong.join(", ")}`); throw new CapabilityError("unauthorized", `子会话模式/授权上限不允许这些能力:${tooStrong.join(", ")}`);
} }
const caps = (requested?.length ? [...new Set(requested)] : parentCaps).filter((id) => const caps = (requested?.length ? [...new Set(requested)] : parentCaps).filter((id) =>
kinds.includes(kindOf(id) as CapabilityKind), kinds.includes(kindOf(id) as CapabilityKind),
@@ -587,7 +587,7 @@ export async function awaitChildren(
const rows = await q.orderBy("created_at").execute(); const rows = await q.orderBy("created_at").execute();
if (ids?.length && rows.length !== new Set(ids).size) { if (ids?.length && rows.length !== new Set(ids).size) {
const missing = ids.filter((id) => !rows.some((r) => r.id === id)); const missing = ids.filter((id) => !rows.some((r) => r.id === id));
throw new CapabilityError("not_found", `不是本会话派生的子会话:${missing.join(", ")}`); throw new CapabilityError("not_found", `不是本会话派生的子会话:${missing.join(", ")}`);
} }
await liveRun(parentId)?.pump(); await liveRun(parentId)?.pump();
const allDone = rows.every((r) => SETTLED.includes(r.status)); const allDone = rows.every((r) => SETTLED.includes(r.status));
@@ -644,7 +644,7 @@ export async function wakeParent(ctx: ServerContext, childRunId: string): Promis
await ctx.engine.signal(w.engineRunId, w.fact, { childRunId }); await ctx.engine.signal(w.engineRunId, w.fact, { childRunId });
return true; return true;
} catch (err) { } catch (err) {
ctx.logger.warn({ childRunId, err: { message: (err as Error).message } }, "唤醒父会话失败(父会话会兜底轮询)"); ctx.logger.warn({ childRunId, err: { message: (err as Error).message } }, "唤醒父会话失败(父会话会兜底轮询)");
return false; return false;
} }
} }
@@ -662,7 +662,7 @@ export async function wakeRun(
await ctx.engine.signal(w.engineRunId, w.fact, payload); await ctx.engine.signal(w.engineRunId, w.fact, payload);
return true; return true;
} catch (err) { } catch (err) {
ctx.logger.warn({ runId, err: { message: (err as Error).message } }, "唤醒会话失败(会话会兜底轮询)"); ctx.logger.warn({ runId, err: { message: (err as Error).message } }, "唤醒会话失败(会话会兜底轮询)");
return false; return false;
} }
} }
@@ -719,9 +719,9 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
const parent = await parentOf(ctx, c); const parent = await parentOf(ctx, c);
const s = await ctx.settings.all(); const s = await ctx.settings.all();
const cfg = subagentSettings(s); const cfg = subagentSettings(s);
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)"); if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)");
if (parent.cancel_requested_at || TERMINAL.includes(parent.status)) if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话"); throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`; const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
const existing = await ctx.db const existing = await ctx.db
.selectFrom("agent_run") .selectFrom("agent_run")
@@ -731,7 +731,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
let child = existing; let child = existing;
if (!child) { if (!child) {
if (parent.depth + 1 > cfg.maxDepth) if (parent.depth + 1 > cfg.maxDepth)
throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`); throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`);
const mode: AgentRunMode = input.mode ?? parent.mode; const mode: AgentRunMode = input.mode ?? parent.mode;
if (parent.mode === "plan_only" && mode === "execute") if (parent.mode === "plan_only" && mode === "execute")
throw new CapabilityError("unauthorized", "只给方案的会话只能派生只给方案的子会话"); throw new CapabilityError("unauthorized", "只给方案的会话只能派生只给方案的子会话");
@@ -812,7 +812,7 @@ export async function exceedsAncestors(
for (const a of chain) { for (const a of chain) {
const sc = (a.scope ?? {}) as Partial<RunScope>; const sc = (a.scope ?? {}) as Partial<RunScope>;
if (sc.capabilities?.length && !sc.capabilities.includes(capabilityId)) if (sc.capabilities?.length && !sc.capabilities.includes(capabilityId))
return `超出父会话 ${a.id} 的能力白名单:${capabilityId}`; return `超出父会话 ${a.id} 的能力白名单:${capabilityId}`;
if (sc.allowedKinds && !sc.allowedKinds.includes(kind)) return `父会话 ${a.id} 不允许 ${kind} 类能力`; if (sc.allowedKinds && !sc.allowedKinds.includes(kind)) return `父会话 ${a.id} 不允许 ${kind} 类能力`;
} }
return null; return null;
@@ -82,7 +82,7 @@ export async function ensureSetupToken(deps: Pick<AuthDeps, "db" | "logger">): P
returning id`.execute(deps.db); returning id`.execute(deps.db);
if (!r.rows.length) return null; if (!r.rows.length) return null;
// 注意:logger 会把名为 *token* 的字段脱敏,所以 token 放在消息文本里 // 注意:logger 会把名为 *token* 的字段脱敏,所以 token 放在消息文本里
deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`); deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`);
return token; return token;
} }
@@ -104,7 +104,7 @@ async function recentFailures(db: Kysely<DB>, col: "ip" | "username", value: str
export async function assertNotRateLimited(db: Kysely<DB>, ip: string, username: string): Promise<void> { export async function assertNotRateLimited(db: Kysely<DB>, ip: string, username: string): Promise<void> {
const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]); const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]);
if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) { if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) {
throw new ApiException("rate_limited", "失败次数过多,15 分钟后再试"); throw new ApiException("rate_limited", "失败次数过多,15 分钟后再试");
} }
} }
@@ -363,7 +363,7 @@ function otpauthUri(secret: string, account: string): string {
} }
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> { export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定"); if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
const secret = base32Encode(randomBytes(20)); const secret = base32Encode(randomBytes(20));
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` }); const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
const old = admin.totp_pending_secret_ref; const old = admin.totp_pending_secret_ref;
@@ -375,7 +375,7 @@ export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSe
/** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */ /** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */
export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> { export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定"); if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
if (admin.totp_pending_secret_ref) { if (admin.totp_pending_secret_ref) {
try { try {
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref); const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
@@ -25,7 +25,7 @@ const D = {
method: "get", method: "get",
path: "/engine/runs", path: "/engine/runs",
scope: "admin", scope: "admin",
summary: "引擎 runs(状态/名称/时间筛选,新→旧)", summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
query: EngineRunQuery, query: EngineRunQuery,
response: cursorPage(EngineRun), response: cursorPage(EngineRun),
tags, tags,
@@ -61,7 +61,7 @@ const D = {
method: "post", method: "post",
path: "/engine/runs/{id}/retry", path: "/engine/runs/{id}/retry",
scope: "admin", scope: "admin",
summary: "按原任务名与输入重新入队(新幂等键 retry:<id>:<序号>)", summary: "按原任务名与输入重新入队(新幂等键 retry:<id>:<序号>)",
params: RunIdParam, params: RunIdParam,
response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }), response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }),
tags, tags,
@@ -316,7 +316,7 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
const run = r.rows[0]; const run = r.rows[0];
if (!run) throw new ApiException("not_found", `run ${params.id} 不存在`); if (!run) throw new ApiException("not_found", `run ${params.id} 不存在`);
if (!RETRYABLE.has(run.status)) if (!RETRYABLE.has(run.status))
throw new ApiException("conflict", `run 状态为 ${run.status},只能重跑已结束的 run(可先取消)`); throw new ApiException("conflict", `run 状态为 ${run.status},只能重跑已结束的 run(可先取消)`);
const env = (run.input ?? {}) as { input?: unknown; traceId?: string; priority?: number }; const env = (run.input ?? {}) as { input?: unknown; traceId?: string; priority?: number };
const prefix = `retry:${params.id}:`; const prefix = `retry:${params.id}:`;
const prev = await sql<{ n: number }>` const prev = await sql<{ n: number }>`
@@ -330,7 +330,7 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
...(typeof env.priority === "number" ? { priority: env.priority } : {}), ...(typeof env.priority === "number" ? { priority: env.priority } : {}),
})); }));
} catch (err) { } catch (err) {
throw new ApiException("precondition_failed", `无法重新入队:${(err as Error).message}`); throw new ApiException("precondition_failed", `无法重新入队:${(err as Error).message}`);
} }
await audit( await audit(
ctx, ctx,
@@ -130,7 +130,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.deleteFrom(table) .deleteFrom(table)
.where("id", "=", id) .where("id", "=", id)
.execute() .execute()
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}仍被引用,不能删除`)); .catch((e: unknown) => pgConflict(e, `${NOUN[table]}仍被引用,不能删除`));
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {}); if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
await audit(ctx, actor, action, { type: table, id }); await audit(ctx, actor, action, { type: table, id });
return { ok: true as const }; return { ok: true as const };
@@ -142,7 +142,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.values(values) .values(values)
.returningAll() .returningAll()
.executeTakeFirstOrThrow() .executeTakeFirstOrThrow()
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`))) as Record< .catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`))) as Record<
string, string,
unknown unknown
> & { > & {
@@ -157,7 +157,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.where("id", "=", id) .where("id", "=", id)
.returningAll() .returningAll()
.executeTakeFirst() .executeTakeFirst()
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`)); .catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`));
if (!r) throw new ApiException("not_found", `${NOUN[table]}不存在`); if (!r) throw new ApiException("not_found", `${NOUN[table]}不存在`);
return r as Record<string, unknown> & { id: string }; return r as Record<string, unknown> & { id: string };
}; };
@@ -181,7 +181,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
} catch (e) { } catch (e) {
throw new ApiException( throw new ApiException(
"precondition_failed", "precondition_failed",
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`, `库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
); );
} }
return name; return name;
@@ -35,7 +35,7 @@ const D = {
method: "delete", method: "delete",
path: "/secrets/{id}", path: "/secrets/{id}",
scope: "admin", scope: "admin",
summary: "删除凭证(被引用时 409)", summary: "删除凭证(被引用时 409)",
params: IdParam, params: IdParam,
response: OkResult, response: OkResult,
tags: ["secrets"], tags: ["secrets"],
@@ -44,7 +44,7 @@ const D = {
method: "get", method: "get",
path: "/audit-events", path: "/audit-events",
scope: "admin", scope: "admin",
summary: "审计事件(按 actor/action/target 过滤,新→旧)", summary: "审计事件(按 actor/action/target 过滤,新→旧)",
query: AuditEventQuery, query: AuditEventQuery,
response: cursorPage(AuditEvent), response: cursorPage(AuditEvent),
tags: ["audit"], tags: ["audit"],
@@ -174,7 +174,7 @@ export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) {
.where("id", "=", params.id) .where("id", "=", params.id)
.returning("id") .returning("id")
.executeTakeFirst() .executeTakeFirst()
.catch((e) => pgConflict(e, "凭证仍被实例、站点账号、Feed 或设置引用,先解除引用")); .catch((e) => pgConflict(e, "凭证仍被实例、站点账号、Feed 或设置引用,先解除引用"));
if (!r) throw new ApiException("not_found", "凭证不存在"); if (!r) throw new ApiException("not_found", "凭证不存在");
await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id }); await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id });
return { ok: true as const }; return { ok: true as const };
@@ -180,7 +180,7 @@ export async function verifyRegistration(
const response = input.response as unknown as RegistrationResponseJSON; const response = input.response as unknown as RegistrationResponseJSON;
const origins = expectedOrigins(rp, response); const origins = expectedOrigins(rp, response);
const challenge = await takeChallenge(db, input.challengeId, "register", admin.id); const challenge = await takeChallenge(db, input.challengeId, "register", admin.id);
if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始"); if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始");
let v: Awaited<ReturnType<typeof verifyRegistrationResponse>>; let v: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try { try {
v = await verifyRegistrationResponse({ v = await verifyRegistrationResponse({
@@ -191,7 +191,7 @@ export async function verifyRegistration(
requireUserVerification: false, requireUserVerification: false,
}); });
} catch (err) { } catch (err) {
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`); throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
} }
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败"); if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo; const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;
@@ -220,7 +220,7 @@ export async function verifyRegistration(
export async function authenticationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> { export async function authenticationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
const creds = await listPasskeys(db, admin.id); const creds = await listPasskeys(db, admin.id);
if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录"); if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录");
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: rp.rpID, rpID: rp.rpID,
userVerification: "preferred", userVerification: "preferred",
@@ -240,7 +240,7 @@ export async function verifyAuthentication(
const response = input.response as unknown as AuthenticationResponseJSON; const response = input.response as unknown as AuthenticationResponseJSON;
const origins = expectedOrigins(rp, response); const origins = expectedOrigins(rp, response);
const challenge = await takeChallenge(db, input.challengeId, "login", null); const challenge = await takeChallenge(db, input.challengeId, "login", null);
if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试"); if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试");
const cred = await db const cred = await db
.selectFrom("admin_passkey") .selectFrom("admin_passkey")
.selectAll() .selectAll()
@@ -264,7 +264,7 @@ export async function verifyAuthentication(
requireUserVerification: false, requireUserVerification: false,
}); });
} catch (err) { } catch (err) {
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`); throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
} }
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败"); if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
// 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行) // 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行)
@@ -281,6 +281,6 @@ export async function verifyAuthentication(
.where(sql<boolean>`(counter < ${next} or (counter = 0 and ${next} = 0))`) .where(sql<boolean>`(counter < ${next} or (counter = 0 and ${next} = 0))`)
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝"); if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝");
return u; return u;
} }
@@ -22,7 +22,7 @@ export class SecretError extends Error {
readonly secretRef: string, readonly secretRef: string,
) { ) {
super( super(
kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`, kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`,
); );
this.name = "SecretError"; this.name = "SecretError";
} }
@@ -221,7 +221,7 @@ export async function rotateMasterKey(
newKey: string, newKey: string,
opts: { progressEvery?: number } = {}, opts: { progressEvery?: number } = {},
): Promise<RotateMasterKeyResult> { ): Promise<RotateMasterKeyResult> {
if (oldKey === newKey) throw new Error("新旧主密钥相同,不需要轮换"); if (oldKey === newKey) throw new Error("新旧主密钥相同,不需要轮换");
const { db, logger } = deps; const { db, logger } = deps;
const newKid = masterKeyId(newKey); const newKid = masterKeyId(newKey);
const every = opts.progressEvery ?? 50; const every = opts.progressEvery ?? 50;
@@ -266,7 +266,7 @@ export async function rotateMasterKey(
else if (r === "skipped") out.skipped++; else if (r === "skipped") out.skipped++;
else if (r === "failed") { else if (r === "failed") {
out.failed.push(id); out.failed.push(id);
logger.warn({ secretId: id }, "这条 secret 新旧主密钥都解不开,已跳过(保持原样)"); logger.warn({ secretId: id }, "这条 secret 新旧主密钥都解不开,已跳过(保持原样)");
} }
done++; done++;
if (done % every === 0 || done === ids.length) if (done % every === 0 || done === ids.length)
@@ -295,7 +295,7 @@ export async function rotateMasterKey(
.catch((err) => logger.warn({ err: { message: (err as Error).message } }, "主密钥轮换审计写入失败")); .catch((err) => logger.warn({ err: { message: (err as Error).message } }, "主密钥轮换审计写入失败"));
logger.info( logger.info(
{ total: out.total, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length }, { total: out.total, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length },
out.failed.length ? "主密钥轮换结束,有解不开的行" : "主密钥轮换完成", out.failed.length ? "主密钥轮换结束,有解不开的行" : "主密钥轮换完成",
); );
return out; return out;
} }
@@ -337,7 +337,7 @@ export function createSettingsReader(deps: {
const v = stored.get(key); const v = stored.get(key);
const merged = v && typeof v === "object" && !Array.isArray(v) ? { ...def, ...(v as object) } : def; const merged = v && typeof v === "object" && !Array.isArray(v) ? { ...def, ...(v as object) } : def;
const parsed = GlobalSettings.shape[key].safeParse(merged); const parsed = GlobalSettings.shape[key].safeParse(merged);
if (!parsed.success) deps.logger.warn({ key }, "settings 值不合法,回退缺省"); if (!parsed.success) deps.logger.warn({ key }, "settings 值不合法,回退缺省");
out[key] = parsed.success ? parsed.data : def; out[key] = parsed.success ? parsed.data : def;
} }
return out as GlobalSettings; return out as GlobalSettings;
@@ -33,7 +33,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
const resolveSession = async (input: { sessionId?: string | undefined; siteId?: string | undefined }) => { const resolveSession = async (input: { sessionId?: string | undefined; siteId?: string | undefined }) => {
if (input.sessionId) return input.sessionId; if (input.sessionId) return input.sessionId;
const row = input.siteId ? await svc().liveSessionFor(input.siteId) : undefined; const row = input.siteId ? await svc().liveSessionFor(input.siteId) : undefined;
if (!row) throw new BrowserServiceError("not_found", "该站点没有浏览器会话,先用 browser.navigate 打开页面"); if (!row) throw new BrowserServiceError("not_found", "该站点没有浏览器会话,先用 browser.navigate 打开页面");
return row.id; return row.id;
}; };
@@ -176,12 +176,12 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
sourceReleaseId: v.sourceReleaseId, sourceReleaseId: v.sourceReleaseId,
duplicate: !!v.duplicate, duplicate: !!v.duplicate,
note: v.duplicate note: v.duplicate
? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交" ? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交"
: !v.verification.valid : !v.verification.valid
? "不是有效的种子文件" ? "不是有效的种子文件"
: v.sourceReleaseId : v.sourceReleaseId
? "已暂存核验;用 download.plan 提交这个 sourceReleaseId" ? "已暂存核验;用 download.plan 提交这个 sourceReleaseId"
: "资源不在本地索引;先 release.search 该站点后再提交", : "资源不在本地索引;先 release.search 该站点后再提交",
}; };
}), }),
); );
+5 -5
View File
@@ -244,20 +244,20 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
const v = await svc().verifyDownload(params.id, artifact); const v = await svc().verifyDownload(params.id, artifact);
let submission: ManualAcquisitionResult | null = null; let submission: ManualAcquisitionResult | null = null;
let note: string | null = null; let note: string | null = null;
if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交"; if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交";
else if (!v.verification.valid) note = "不是有效的种子文件,未提交"; else if (!v.verification.valid) note = "不是有效的种子文件,未提交";
else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交"; else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交";
else if (body.submit) { else if (body.submit) {
try { try {
submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", { submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
...body.submit, ...body.submit,
sourceReleaseId: v.sourceReleaseId, sourceReleaseId: v.sourceReleaseId,
}); });
if (!submission.submitted) note = "提交未生成下载计划,见 rejected"; if (!submission.submitted) note = "提交未生成下载计划,见 rejected";
} catch (e) { } catch (e) {
// 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回 // 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回
if (!(e instanceof ApiException) || e.code === "internal") throw e; if (!(e instanceof ApiException) || e.code === "internal") throw e;
note = `提交未通过:${e.message}`; note = `提交未通过:${e.message}`;
} }
} }
await svc().recordDownload(params.id, { await svc().recordDownload(params.id, {
+10 -9
View File
@@ -86,7 +86,7 @@ export function fromBrowserError(e: unknown): unknown {
case "rate_limited": case "rate_limited":
return new BrowserServiceError("rate_limited", e.message); return new BrowserServiceError("rate_limited", e.message);
default: default:
return new BrowserServiceError("upstream", `浏览器服务不可用:${e.message}`); return new BrowserServiceError("upstream", `浏览器服务不可用:${e.message}`);
} }
} }
@@ -218,7 +218,8 @@ export class BrowserService {
.where("kind", "<>", "none"); .where("kind", "<>", "none");
if (id) q = q.where("id", "=", id); if (id) q = q.where("id", "=", id);
const row = await q.orderBy("created_at").executeTakeFirst(); const row = await q.orderBy("created_at").executeTakeFirst();
if (!row) throw new BrowserServiceError("precondition", "没有启用的浏览器服务(camofox),见 docs/runbook/browser.md"); if (!row)
throw new BrowserServiceError("precondition", "没有启用的浏览器服务(camofox),见 docs/runbook/browser.md");
return row; return row;
} }
@@ -329,13 +330,13 @@ export class BrowserService {
.executeTakeFirst(); .executeTakeFirst();
if (!prow?.enabled) { if (!prow?.enabled) {
await this.closeRow(row, "provider_disabled"); await this.closeRow(row, "provider_disabled");
throw new BrowserServiceError("precondition", "浏览器服务已停用,会话已关闭"); throw new BrowserServiceError("precondition", "浏览器服务已停用,会话已关闭");
} }
const site = await this.liveSite(row.site_id).catch(() => null); const site = await this.liveSite(row.site_id).catch(() => null);
if (!site?.enabled) { if (!site?.enabled) {
// 站点关闭即停止其网络访问(REQ §4.6、§4.7.3) // 站点关闭即停止其网络访问(REQ §4.6、§4.7.3)
await this.closeRow(row, "site_disabled", prow); await this.closeRow(row, "site_disabled", prow);
throw new BrowserServiceError("precondition", "站点已停用,浏览器会话已关闭"); throw new BrowserServiceError("precondition", "站点已停用,浏览器会话已关闭");
} }
const provider = this.providerFor(prow); const provider = this.providerFor(prow);
if (!provider.sessionInfo(row.id)) { if (!provider.sessionInfo(row.id)) {
@@ -611,8 +612,8 @@ export class BrowserService {
kind: "other", kind: "other",
status: "awaiting_user", status: "awaiting_user",
severity: "warning", severity: "warning",
title: `${site?.name ?? "站点"}:原站需要人工处理`, title: `${site?.name ?? "站点"}:原站需要人工处理`,
summary: `${REASON_TEXT[reason] ?? reason}。在站点详情的原站视图接管会话,完成后交还。`, summary: `${REASON_TEXT[reason] ?? reason}。在站点详情的原站视图接管会话,完成后交还。`,
subject_type: "browser_session", subject_type: "browser_session",
subject_id: row.id, subject_id: row.id,
dedupe_key: `browser.waiting:${row.id}`, dedupe_key: `browser.waiting:${row.id}`,
@@ -783,8 +784,8 @@ export class BrowserService {
throw new BrowserServiceError( throw new BrowserServiceError(
"conflict", "conflict",
row.controller === "human" row.controller === "human"
? "会话正由人工接管,自动化动作已停止" ? "会话正由人工接管,自动化动作已停止"
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),自动化动作已停止`, : `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),自动化动作已停止`,
); );
} }
} }
@@ -803,7 +804,7 @@ export class BrowserService {
if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) { if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) {
const r = await loaded.provider.reconcileTabs(id).catch(() => null); const r = await loaded.provider.reconcileTabs(id).catch(() => null);
await this.persist(id, loaded.provider, { needsSnapshot: true }); await this.persist(id, loaded.provider, { needsSnapshot: true });
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面"); if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
} }
await this.persist(id, loaded.provider).catch(() => {}); await this.persist(id, loaded.provider).catch(() => {});
throw fromBrowserError(e); throw fromBrowserError(e);
+9 -9
View File
@@ -60,12 +60,12 @@ export function readableLoginError(provider: string, err: unknown): string {
const raw = err instanceof Error ? err.message : String(err); const raw = err instanceof Error ? err.message : String(err);
if (/EADDRINUSE|address already in use/i.test(raw)) { if (/EADDRINUSE|address already in use/i.test(raw)) {
const port = OAUTH_CALLBACK_PORTS[provider] ?? raw.match(/:(\d{2,5})\b/)?.[1]; const port = OAUTH_CALLBACK_PORTS[provider] ?? raw.match(/:(\d{2,5})\b/)?.[1];
return `服务端回调端口${port ? ` ${port}` : ""} 被占用。结束占用该端口的进程(如另一个登录流程、Claude Code / Codex CLI)后重试`; return `服务端回调端口${port ? ` ${port}` : ""} 被占用。结束占用该端口的进程(如另一个登录流程、Claude Code / Codex CLI)后重试`;
} }
if (/cancel|abort/i.test(raw)) return "登录已取消"; if (/cancel|abort/i.test(raw)) return "登录已取消";
if (/state mismatch/i.test(raw)) return "粘贴的地址不属于本次登录(state 不一致)。用本次打开的授权链接重新登录"; if (/state mismatch/i.test(raw)) return "粘贴的地址不属于本次登录(state 不一致)。用本次打开的授权链接重新登录";
if (/\b404\b/.test(raw) && provider === "openai-codex") if (/\b404\b/.test(raw) && provider === "openai-codex")
return "这个账号未开启设备码登录(Codex 返回 404)。改用浏览器登录"; return "这个账号未开启设备码登录(Codex 返回 404)。改用浏览器登录";
return classifyUpstreamError(err).message; return classifyUpstreamError(err).message;
} }
@@ -111,7 +111,7 @@ export class OAuthSessions {
if (!info.oauth.modes.includes(mode)) if (!info.oauth.modes.includes(mode))
throw new ApiException( throw new ApiException(
"validation_failed", "validation_failed",
`${info.name} 不支持 ${mode} 登录,可用:${info.oauth.modes.join(" / ")}`, `${info.name} 不支持 ${mode} 登录,可用:${info.oauth.modes.join(" / ")}`,
); );
const busy = this.active(row.provider); const busy = this.active(row.provider);
@@ -119,7 +119,7 @@ export class OAuthSessions {
if (busy.upstreamId !== row.id) if (busy.upstreamId !== row.id)
throw new ApiException( throw new ApiException(
"conflict", "conflict",
`${info.name} 已有登录进行中。完成它,或等到 ${new Date(busy.expiresAt).toISOString()} 过期后重试`, `${info.name} 已有登录进行中。完成它,或等到 ${new Date(busy.expiresAt).toISOString()} 过期后重试`,
); );
this.cancel(busy, "cancelled", "已被新的登录请求取代"); this.cancel(busy, "cancelled", "已被新的登录请求取代");
await busy.done.catch(() => undefined); await busy.done.catch(() => undefined);
@@ -144,7 +144,7 @@ export class OAuthSessions {
pasteWaiters: [], pasteWaiters: [],
firstEvent: Promise.withResolvers<void>(), firstEvent: Promise.withResolvers<void>(),
done: Promise.resolve(), done: Promise.resolve(),
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,重新开始登录"), ttl), timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,重新开始登录"), ttl),
}; };
(s.timer as { unref?: () => void }).unref?.(); (s.timer as { unref?: () => void }).unref?.();
this.sessions.set(s.id, s); this.sessions.set(s.id, s);
@@ -192,7 +192,7 @@ export class OAuthSessions {
for (const w of s.pasteWaiters.splice(0)) w(); for (const w of s.pasteWaiters.splice(0)) w();
}); });
} }
throw new Error("这个登录流程需要额外输入(secret),服务端不支持"); throw new Error("这个登录流程需要额外输入(secret),服务端不支持");
} }
private notify(s: Session, e: UpstreamAuthEvent) { private notify(s: Session, e: UpstreamAuthEvent) {
@@ -238,11 +238,11 @@ export class OAuthSessions {
return { return {
sessionId: req.sessionId, sessionId: req.sessionId,
state: "expired", state: "expired",
message: "登录会话不存在或已过期,重新开始登录", message: "登录会话不存在或已过期,重新开始登录",
progress: [], progress: [],
}; };
if (req.pastedUrlOrCode && s.state === "pending") { if (req.pastedUrlOrCode && s.state === "pending") {
if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成"); if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成");
if (!s.paste) { if (!s.paste) {
// manual_code 提示可能还没发出:短暂等待 // manual_code 提示可能还没发出:短暂等待
await Promise.race([ await Promise.race([
@@ -342,7 +342,7 @@ export async function refreshSiteAccount(
decisionId: null, decisionId: null,
}); });
const def = getSiteDefinition(site.key); const def = getSiteDefinition(site.key);
if (!def || site.deleted_at) return unchanged("unsupported", "站点没有适配器定义,无法校验登录"); if (!def || site.deleted_at) return unchanged("unsupported", "站点没有适配器定义,无法校验登录");
const defaults = await rt.siteDefaults(); const defaults = await rt.siteDefaults();
const ua = acc.user_agent ?? defaults.userAgent; const ua = acc.user_agent ?? defaults.userAgent;
let limiter: ReturnType<SourcesRuntime["limiterOf"]> = null; let limiter: ReturnType<SourcesRuntime["limiterOf"]> = null;
@@ -402,7 +402,7 @@ export async function refreshSiteAccount(
const succeed = (info: SiteUserInfo | null, note?: string) => markSuccess(rt, acc, site, info, actor, note); const succeed = (info: SiteUserInfo | null, note?: string) => markSuccess(rt, acc, site, info, actor, note);
try { try {
if (acc.auth_kind === "passkey") return unchanged("unsupported", "Passkey 只用于 RSS 和下载,不参与登录"); if (acc.auth_kind === "passkey") return unchanged("unsupported", "Passkey 只用于 RSS 和下载,不参与登录");
if (!acc.secret_ref) return unchanged("unsupported", "账号没有凭证"); if (!acc.secret_ref) return unchanged("unsupported", "账号没有凭证");
if (acc.auth_kind === "password") { if (acc.auth_kind === "password") {
if (umt.kind !== "nexusphp" || !umt.login) { if (umt.kind !== "nexusphp" || !umt.login) {
@@ -434,14 +434,14 @@ export async function refreshSiteAccount(
if (r.kind === "ok") return await succeed(r.info); if (r.kind === "ok") return await succeed(r.info);
return await succeed( return await succeed(
null, null,
`登录成功;用户数据未取到:${r.kind === "no_parser" ? "站点没有用户数据解析器" : r.message}`, `登录成功;用户数据未取到:${r.kind === "no_parser" ? "站点没有用户数据解析器" : r.message}`,
); );
} }
const plain = await secrets.resolve(acc.secret_ref); const plain = await secrets.resolve(acc.secret_ref);
let r = await fetchUserData(acc.auth_kind === "cookie" ? { cookie: plain } : { apikey: plain }); let r = await fetchUserData(acc.auth_kind === "cookie" ? { cookie: plain } : { apikey: plain });
if (r.kind === "no_parser") r = await viaHealth(); if (r.kind === "no_parser") r = await viaHealth();
if (r.kind === "ok") return await succeed(r.info); if (r.kind === "ok") return await succeed(r.info);
if (r.kind === "no_parser") return await succeed(null, "站点没有用户数据解析器,只校验了登录态"); if (r.kind === "no_parser") return await succeed(null, "站点没有用户数据解析器,只校验了登录态");
if (r.kind === "network") return await fail("network", r.message); if (r.kind === "network") return await fail("network", r.message);
if (r.kind === "challenge") return await fail("challenge", r.message); if (r.kind === "challenge") return await fail("challenge", r.message);
return await fail( return await fail(
@@ -580,11 +580,11 @@ async function upsertUserDataDay(trx: Exec, accountId: string, now: Date, info:
const DECISION_TEXT: Record<string, { title: (s: string) => string; summary: string }> = { const DECISION_TEXT: Record<string, { title: (s: string) => string; summary: string }> = {
challenge: { challenge: {
title: (s) => `站点「${s}」登录需要人工验证`, title: (s) => `站点「${s}」登录需要人工验证`,
summary: "登录页要求验证码 / 二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试", summary: "登录页要求验证码 / 二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试",
}, },
invalid: { invalid: {
title: (s) => `站点「${s}」登录失败`, title: (s) => `站点「${s}」登录失败`,
summary: "用户名或密码错误,或账号被封禁 / 未确认。更新凭证后重新刷新登录", summary: "用户名或密码错误,或账号被封禁 / 未确认。更新凭证后重新刷新登录",
}, },
expired: { expired: {
title: (s) => `站点「${s}」登录失效`, title: (s) => `站点「${s}」登录失效`,
@@ -619,7 +619,7 @@ async function markFailure(
const text = DECISION_TEXT[status]!; const text = DECISION_TEXT[status]!;
const summary = const summary =
reason === "challenge" reason === "challenge"
? "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie 登录" ? "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie 登录"
: status === "expired" || : status === "expired" ||
reason === "account_blocked" || reason === "account_blocked" ||
reason === "bad_credentials" || reason === "bad_credentials" ||
@@ -719,7 +719,7 @@ export async function checkSiteHealth(
let message: string | null = null; let message: string | null = null;
const def = getSiteDefinition(site.key); const def = getSiteDefinition(site.key);
if (!def) { if (!def) {
message = "站点没有适配器定义,无法检测"; message = "站点没有适配器定义,无法检测";
status = "unsupported"; status = "unsupported";
} else { } else {
await rt.siteDefaults(); await rt.siteDefaults();
@@ -105,7 +105,7 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
expect(slot.status).toBe("empty"); expect(slot.status).toBe("empty");
// 汇报不采信模型文本:没有可核查的业务变更 // 汇报不采信模型文本:没有可核查的业务变更
expect(run.summary).toContain("没有提交下载"); expect(run.summary).toContain("没有提交下载");
expect(run.summary).toContain("模型汇报(未经核查)"); expect(run.summary).toContain("模型汇报(未经核查)");
}, 90_000); }, 90_000);
it("有持续授权:提交成功 → 已提交≠已入库(waiting)→ 下载完成、核验入库后 succeeded,汇报来自整理记录", async () => { it("有持续授权:提交成功 → 已提交≠已入库(waiting)→ 下载完成、核验入库后 succeeded,汇报来自整理记录", async () => {
+3 -3
View File
@@ -246,8 +246,8 @@ describe.skipIf(!hasPg)("E6 授权内异常调查与恢复", () => {
const done = await finished(run.id); const done = await finished(run.id);
expect(done.status).toBe("succeeded"); expect(done.status).toBe("succeeded");
expect(done.summary).toMatch(/^已恢复\(系统核实\)/); expect(done.summary).toMatch(/^已恢复(系统核实)/);
expect(done.summary).toContain("模型汇报(未经核查)"); expect(done.summary).toContain("模型汇报(未经核查)");
expect(await toolStarts(run.id)).toEqual(["task.inspect", "download.recheck"]); expect(await toolStarts(run.id)).toEqual(["task.inspect", "download.recheck"]);
expect(await toolRejected(run.id)).toEqual(["download__submit"]); expect(await toolRejected(run.id)).toEqual(["download__submit"]);
const inv = await e.db const inv = await e.db
@@ -339,7 +339,7 @@ describe.skipIf(!hasPg)("E6 授权内异常调查与恢复", () => {
expect(run.mode).toBe("execute"); expect(run.mode).toBe("execute");
const done = await finished(run.id); const done = await finished(run.id);
expect(done.status).toBe("budget_exceeded"); expect(done.status).toBe("budget_exceeded");
expect(done.summary).toMatch(/^未恢复:调查超出预算/); expect(done.summary).toMatch(/^未恢复:调查超出预算/);
expect(await toolStarts(run.id)).toEqual(["task.inspect"]); expect(await toolStarts(run.id)).toEqual(["task.inspect"]);
expect(e.qb.callsTo("torrents/recheck")).toHaveLength(1); expect(e.qb.callsTo("torrents/recheck")).toHaveLength(1);
const d2 = await waitFor( const d2 = await waitFor(
+1 -1
View File
@@ -271,7 +271,7 @@ export const REPORT_STATE: Record<
{ label: string; tone: "success" | "warning" | "destructive" | "neutral" } { label: string; tone: "success" | "warning" | "destructive" | "neutral" }
> = { > = {
no_actions: { label: "没有产生业务动作", tone: "neutral" }, no_actions: { label: "没有产生业务动作", tone: "neutral" },
waiting: { label: "已提交,等待下载/入库", tone: "warning" }, waiting: { label: "已提交,等待下载/入库", tone: "warning" },
completed: { label: "已核验入库", tone: "success" }, completed: { label: "已核验入库", tone: "success" },
partial: { label: "部分完成", tone: "warning" }, partial: { label: "部分完成", tone: "warning" },
failed: { label: "未达成", tone: "destructive" }, failed: { label: "未达成", tone: "destructive" },
+2 -2
View File
@@ -49,7 +49,7 @@ export function inventoryFlags(
const wants4k = entry.gaps.some((g) => /2160|4k|uhd/i.test(g.name)); const wants4k = entry.gaps.some((g) => /2160|4k|uhd/i.test(g.name));
if (wants4k && !has4k) flags.push({ key: "no4k", label: "缺 4K", tone: "warning" }); if (wants4k && !has4k) flags.push({ key: "no4k", label: "缺 4K", tone: "warning" });
for (const g of entry.gaps.filter((g) => !/2160|4k|uhd/i.test(g.name) || has4k)) { for (const g of entry.gaps.filter((g) => !/2160|4k|uhd/i.test(g.name) || has4k)) {
flags.push({ key: `gap-${g.slotId}`, label: `缺口:${g.name}`, tone: "warning" }); flags.push({ key: `gap-${g.slotId}`, label: `缺口:${g.name}`, tone: "warning" });
} }
if (entry.downloading.length) { if (entry.downloading.length) {
flags.push({ key: "dl", label: `下载中 ${entry.downloading.length}`, tone: "neutral" }); flags.push({ key: "dl", label: `下载中 ${entry.downloading.length}`, tone: "neutral" });
@@ -64,7 +64,7 @@ export function inventoryFlags(
}); });
} }
if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) { if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) {
flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" }); flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" });
} }
return flags; return flags;
} }
@@ -67,7 +67,7 @@ export const BUDGET_LABEL: Record<string, string> = {
}; };
const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" }; const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" };
const COMPACT_STATUS: Record<string, string> = { const COMPACT_STATUS: Record<string, string> = {
completed: "已完成,业务进度保留", completed: "已完成,业务进度保留",
declined: "未压缩", declined: "未压缩",
aborted: "已中止", aborted: "已中止",
failed: "失败", failed: "失败",
@@ -173,8 +173,8 @@ export function ToolCallCard({
reason={authReason ?? call.reason ?? "未授权"} reason={authReason ?? call.reason ?? "未授权"}
explanation={ explanation={
authReason authReason
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。' ? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。" : "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
} }
parameters={[ parameters={[
{ id: "call", label: "调用 ID", value: call.toolCallId }, { id: "call", label: "调用 ID", value: call.toolCallId },
@@ -199,10 +199,10 @@ export function ToolCallCard({
} }
summary={call.outputSummary ?? undefined} summary={call.outputSummary ?? undefined}
> >
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null} {call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
{call.outputSummary ? ( {call.outputSummary ? (
<ToolResultOutput <ToolResultOutput
label={call.status === "failed" ? "错误" : "结果(已脱敏)"} label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
tone={call.status === "failed" ? "destructive" : undefined} tone={call.status === "failed" ? "destructive" : undefined}
> >
{call.outputSummary} {call.outputSummary}
@@ -216,7 +216,7 @@ export function ToolCallCard({
<dt className="text-muted-foreground">幂等键</dt> <dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80"> <dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ?? {audit?.idempotencyKey ??
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")} (meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
</dd> </dd>
{audit?.grantId || audit?.approvalId ? ( {audit?.grantId || audit?.approvalId ? (
<> <>
@@ -292,7 +292,7 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
<MessageBubbleContent className={cn(aborted && "text-muted-foreground")}> <MessageBubbleContent className={cn(aborted && "text-muted-foreground")}>
<p className="font-medium"> <p className="font-medium">
{budget {budget
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止` ? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止`
: aborted : aborted
? "已停止" ? "已停止"
: "运行失败"} : "运行失败"}
@@ -479,7 +479,7 @@ export function AgentConversation({
rows.push({ rows.push({
key: `u${m.at}`, key: `u${m.at}`,
at: m.at, at: m.at,
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />, node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
}); });
rows.sort((a, b) => a.at.localeCompare(b.at)); rows.sort((a, b) => a.at.localeCompare(b.at));
@@ -584,7 +584,7 @@ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamS
exceeded={exceeded === "duration"} exceeded={exceeded === "duration"}
/> />
<Meter <Meter
label="费用(估算)" label="费用(估算)"
used={u.costUsd} used={u.costUsd}
max={run.budget.maxCostUsd} max={run.budget.maxCostUsd}
format={(n) => `$${n.toFixed(3)}`} format={(n) => `$${n.toFixed(3)}`}
@@ -626,7 +626,7 @@ export function ApprovalItem({ approval: a }: { approval: Approval }) {
<ApprovalCard <ApprovalCard
status={status} status={status}
title={`批准执行这份${PLAN_LABEL[a.planType] ?? a.planType}?`} title={`批准执行这份${PLAN_LABEL[a.planType] ?? a.planType}?`}
description={a.reason ?? "计划变化后,这份审批自动失效。"} description={a.reason ?? "计划变化后,这份审批自动失效。"}
badges={ badges={
<> <>
{risk ? <RiskBadge level={risk} /> : null} {risk ? <RiskBadge level={risk} /> : null}
@@ -654,7 +654,7 @@ export function ApprovalItem({ approval: a }: { approval: Approval }) {
<dt className="text-muted-foreground">到期</dt> <dt className="text-muted-foreground">到期</dt>
<dd className={cn("text-foreground/85", expired && "text-destructive")}> <dd className={cn("text-foreground/85", expired && "text-destructive")}>
{a.expiresAt ? formatDateTime(a.expiresAt) : "不过期"} {a.expiresAt ? formatDateTime(a.expiresAt) : "不过期"}
{expired ? "(已过期)" : ""} {expired ? "(已过期)" : ""}
</dd> </dd>
</dl> </dl>
</ApprovalCard> </ApprovalCard>
@@ -721,12 +721,12 @@ export function FinalReport({
<div className="min-w-0 rounded-xl border border-dashed border-border p-3"> <div className="min-w-0 rounded-xl border border-dashed border-border p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase"> <p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<Bot aria-hidden className="size-3.5" /> <Bot aria-hidden className="size-3.5" />
模型说的(未经核查) 模型说的(未经核查)
</p> </p>
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto"> <StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
{said || "(没有文字汇报)"} {said || "(没有文字汇报)"}
</StreamingResponse> </StreamingResponse>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p> <p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div> </div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3"> <div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase"> <p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
@@ -788,12 +788,12 @@ export function FinalReport({
/> />
) : null} ) : null}
<p className="mt-2 text-meta text-muted-foreground"> <p className="mt-2 text-meta text-muted-foreground">
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)} 取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
)。"已提交"指已进入下载器,整理成功才算入库。 )。"已提交"指已进入下载器,整理成功才算入库。
</p> </p>
</> </>
) : ( ) : (
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p> <p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
)} )}
{run.error ? ( {run.error ? (
<p className="mt-2 text-caption break-words text-destructive"> <p className="mt-2 text-caption break-words text-destructive">
@@ -93,9 +93,9 @@ export function AgentDrawer() {
Agent Agent
</SheetTitle> </SheetTitle>
<SheetDescription className="break-words"> <SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"} {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline"> <span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭) ({agentShortcutLabel()} 开关,Esc 关闭)
</span> </span>
</SheetDescription> </SheetDescription>
{context ? <ContextChips context={context} /> : null} {context ? <ContextChips context={context} /> : null}
@@ -105,8 +105,8 @@ export function AgentDrawer() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="absolute top-4 right-14" className="absolute top-4 right-14"
aria-label="最小化(运行继续)" aria-label="最小化(运行继续)"
title="最小化(运行继续)" title="最小化(运行继续)"
onClick={minimizeAgentDrawer} onClick={minimizeAgentDrawer}
> >
<Minus /> <Minus />
@@ -180,7 +180,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
? "思考中…" ? "思考中…"
: last?.kind === "end" : last?.kind === "end"
? last.ok ? last.ok
? "已完成,展开查看核实结果" ? "已完成,展开查看核实结果"
: last.message : last.message
: "等待输出…"; : "等待输出…";
const status = run.data const status = run.data
@@ -205,8 +205,8 @@ function AgentMiniDock({ runId }: { runId: string }) {
<Button <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
aria-label="关闭小窗(运行在后台继续)" aria-label="关闭小窗(运行在后台继续)"
title="关闭小窗(运行在后台继续)" title="关闭小窗(运行在后台继续)"
onClick={() => setAgentDrawerOpen(false)} onClick={() => setAgentDrawerOpen(false)}
> >
<X /> <X />
@@ -95,7 +95,7 @@ export function GoalComposer({
maxRows={10} maxRows={10}
maxLength={4000} maxLength={4000}
aria-label="目标" aria-label="目标"
placeholder="例如:把 沙丘:第二部 的 4K 收进来" placeholder="例如:把 沙丘:第二部 的 4K 收进来"
submitLabel={pending ? "提交中" : "开始"} submitLabel={pending ? "提交中" : "开始"}
header={ header={
prefix ? ( prefix ? (
@@ -242,7 +242,7 @@ export function AgentRunPanel({
className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning" className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
> >
<WifiOff aria-hidden className="size-4 shrink-0" /> <WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span> <span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
</div> </div>
) : null} ) : null}
{live.error ? ( {live.error ? (
@@ -250,7 +250,7 @@ export function AgentRunPanel({
role="alert" role="alert"
className="flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive" className="flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive"
> >
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span> <span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
<Button size="xs" variant="outline" onClick={live.retry}> <Button size="xs" variant="outline" onClick={live.retry}>
<RefreshCw /> <RefreshCw />
重试 重试
@@ -295,7 +295,7 @@ export function AgentRunPanel({
maxRows={6} maxRows={6}
maxLength={8000} maxLength={8000}
aria-label="追加消息" aria-label="追加消息"
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"} placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
/> />
<p className="px-1 text-meta text-muted-foreground"> <p className="px-1 text-meta text-muted-foreground">
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
@@ -308,8 +308,8 @@ export function AgentRunPanel({
<ConfirmDialog <ConfirmDialog
open={confirmStop} open={confirmStop}
onOpenChange={setConfirmStop} onOpenChange={setConfirmStop}
title="停止这次运行?" title="停止这次运行?"
description="后续步骤不再执行,已完成的外部动作(如已提交的下载)不会撤销。" description="后续步骤不再执行,已完成的外部动作(如已提交的下载)不会撤销。"
confirmLabel="停止运行" confirmLabel="停止运行"
destructive destructive
onConfirm={() => onConfirm={() =>
@@ -66,7 +66,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
</span> </span>
</div> </div>
{node.spawnReason ? ( {node.spawnReason ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p> <p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -107,9 +107,9 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
{splitContextGoal(parent.goal).text} {splitContextGoal(parent.goal).text}
</Link> </Link>
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "} 第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)} {budgetText(run.budget as Record<string, number | undefined>)}
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""} {run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
</p> </p>
</div> </div>
) : null} ) : null}
+10 -10
View File
@@ -76,7 +76,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
toolCallId: "t1", toolCallId: "t1",
capabilityId: "inventory.query", capabilityId: "inventory.query",
isError: false, isError: false,
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务", outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
recovery: false, recovery: false,
}); });
plan("s1", "done", "s2"); plan("s1", "done", "s2");
@@ -93,12 +93,12 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
capabilityId: "release.search", capabilityId: "release.search",
isError: false, isError: false,
outputSummary: outputSummary:
"3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB", "3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB",
recovery: false, recovery: false,
}); });
push(300, { type: "compaction", phase: "start", reason: "threshold" }); push(300, { type: "compaction", phase: "start", reason: "threshold" });
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" }); push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。"); say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
plan("s2", "done", "s3"); plan("s2", "done", "s3");
if (opts.proposeFirst) { if (opts.proposeFirst) {
push(300, { push(300, {
@@ -142,7 +142,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
toolCallId: "t3", toolCallId: "t3",
capabilityId: "download.plan", capabilityId: "download.plan",
isError: false, isError: false,
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)", outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
recovery: false, recovery: false,
}); });
plan("s3", "done", "s4"); plan("s3", "done", "s4");
@@ -151,7 +151,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
toolCallId: "t4", toolCallId: "t4",
toolName: "qb_delete_torrent", toolName: "qb_delete_torrent",
capabilityId: "download.cleanup", capabilityId: "download.cleanup",
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)", reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
}); });
push(300, { push(300, {
type: "tool_call_start", type: "tool_call_start",
@@ -165,7 +165,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
toolCallId: "t5", toolCallId: "t5",
capabilityId: "download.submit", capabilityId: "download.submit",
isError: false, isError: false,
outputSummary: '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}', outputSummary: '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}',
recovery: false, recovery: false,
}); });
push(200, { push(200, {
@@ -275,7 +275,7 @@ function seed() {
type: "run_failed", type: "run_failed",
reason: "budget", reason: "budget",
budget: "duration", budget: "duration",
message: "超出单次运行时长预算(600 秒)", message: "超出单次运行时长预算(600 秒)",
usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 }, usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 },
}, },
}, },
@@ -367,7 +367,7 @@ export function mockDetail(id: string, op?: { abort?: boolean; append?: string }
consumedAt: null, consumedAt: null,
}); });
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed"); const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({ const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({
delayMs: at + 200 + i * 60, delayMs: at + 200 + i * 60,
body: { type: "text_delta", delta: c }, body: { type: "text_delta", delta: c },
})); }));
@@ -390,10 +390,10 @@ function report(e: Entry, run: AgentRunView): GoalReport | null {
return { return {
state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed", state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed",
summary: submitted summary: submitted
? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准" ? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准"
: run.status === "succeeded" : run.status === "succeeded"
? "本次没有产生下载或整理动作" ? "本次没有产生下载或整理动作"
: "运行未完成,没有产生业务动作", : "运行未完成,没有产生业务动作",
submitted: submitted ? 1 : 0, submitted: submitted ? 1 : 0,
imported: 0, imported: 0,
inProgress: submitted ? 1 : 0, inProgress: submitted ? 1 : 0,
+4 -4
View File
@@ -18,7 +18,7 @@ const RULES: Rule[] = [
}, },
{ {
match: /^\/console\/sites\/?$/, match: /^\/console\/sites\/?$/,
goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"], goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"],
}, },
{ {
match: /^\/console\/tasks\/[^/]+/, match: /^\/console\/tasks\/[^/]+/,
@@ -46,11 +46,11 @@ const RULES: Rule[] = [
}, },
{ {
match: /^\/console\/subscriptions/, match: /^\/console\/subscriptions/,
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"], goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"],
}, },
{ {
match: /^\/console\/sources/, match: /^\/console\/sources/,
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 Feed 的抓取状态", "最近一天各来源抓到了什么"], goals: ["哪些来源不新鲜了,原因是什么", "检查所有 Feed 的抓取状态", "最近一天各来源抓到了什么"],
}, },
{ {
match: /^\/console\/engine/, match: /^\/console\/engine/,
@@ -59,7 +59,7 @@ const RULES: Rule[] = [
]; ];
const FALLBACK: [string, string, string] = [ const FALLBACK: [string, string, string] = [
"收一部电影:", "收一部电影:",
"看看今天有什么需要我处理的", "看看今天有什么需要我处理的",
"检查下载器和媒体服务器是否正常", "检查下载器和媒体服务器是否正常",
]; ];
@@ -79,7 +79,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<EmptyState <EmptyState
icon={Globe} icon={Globe}
title="没有启用的浏览器服务" title="没有启用的浏览器服务"
description="登记 camofox 地址与访问凭证后,这里显示站点的真实页面。" description="登记 camofox 地址与访问凭证后,这里显示站点的真实页面。"
/> />
); );
if (!site.enabled) return <EmptyState icon={Globe} title="站点已停用" description="启用站点后才能打开原站页面。" />; if (!site.enabled) return <EmptyState icon={Globe} title="站点已停用" description="启用站点后才能打开原站页面。" />;
@@ -88,7 +88,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<EmptyState <EmptyState
icon={Globe} icon={Globe}
title="还没有打开这个站点的浏览器会话" title="还没有打开这个站点的浏览器会话"
description="打开后显示站点首页;验证码等需本人操作在此完成。" description="打开后显示站点首页;验证码等需本人操作在此完成。"
action={ action={
<Button <Button
size="sm" size="sm"
@@ -143,7 +143,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge> <StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}> <StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
当前控制:{CONTROLLER_LABEL[s.controller]} 当前控制:{CONTROLLER_LABEL[s.controller]}
{human && leaseHolder ? `(${leaseHolder})` : ""} {human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge> </StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null} {waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
@@ -155,7 +155,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
disabled={busy} disabled={busy}
onClick={() => onClick={() =>
control.release.mutate(s.id, { control.release.mutate(s.id, {
onSuccess: () => toast.success("已交还,Agent 会重新读取页面"), onSuccess: () => toast.success("已交还,Agent 会重新读取页面"),
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
}) })
} }
@@ -261,7 +261,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<form onSubmit={onTextSubmit} className="flex min-w-0 items-center gap-1.5"> <form onSubmit={onTextSubmit} className="flex min-w-0 items-center gap-1.5">
<Input <Input
aria-label="输入到页面" aria-label="输入到页面"
placeholder="先点页面上的输入框,再在这里输入" placeholder="先点页面上的输入框,再在这里输入"
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
className="h-8 min-w-0 flex-1" className="h-8 min-w-0 flex-1"
@@ -315,7 +315,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
variant="outline" variant="outline"
size="xs" size="xs"
disabled={busy} disabled={busy}
title="用站点账号的密码填当前输入框;密码不经过这个页面" title="用站点账号的密码填当前输入框;密码不经过这个页面"
onClick={() => send({ kind: "credential", credential: "password" })} onClick={() => send({ kind: "credential", credential: "password" })}
> >
<KeyRound /> <KeyRound />
@@ -53,7 +53,7 @@ const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.opti
})); }));
export const PUBLISH_NOTICE = export const PUBLISH_NOTICE =
"发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。"; "发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。";
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean }; type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
type PolicyDraft = { type PolicyDraft = {
@@ -164,7 +164,7 @@ function buildPolicy(d: PolicyDraft):
const strict = AcquisitionPolicyRules.strict().safeParse(rules); const strict = AcquisitionPolicyRules.strict().safeParse(rules);
if (!strict.success && !Object.keys(errors).length) { if (!strict.success && !Object.keys(errors).length) {
const i = strict.error.issues[0]; const i = strict.error.issues[0];
errors.rules = `规则结构不合法:${i?.path.join(".")} ${i?.message}`; errors.rules = `规则结构不合法:${i?.path.join(".")} ${i?.message}`;
} }
if (Object.keys(errors).length || !meta.success) return { ok: false, errors }; if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
return { return {
@@ -221,7 +221,7 @@ export function PolicyWorkspace({
<EmptyState <EmptyState
icon={ScrollText} icon={ScrollText}
title="还没有收录策略" title="还没有收录策略"
description="新建草案,预览后发布。" description="新建草案,预览后发布。"
action={ action={
<Button onClick={() => onCreatingChange(true)}> <Button onClick={() => onCreatingChange(true)}>
<FilePlus2 /> <FilePlus2 />
@@ -297,7 +297,7 @@ function PolicyGroup({
description={ description={
published published
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${actorText(published.publishedBy)} 发布` ? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${actorText(published.publishedBy)} 发布`
: "未发布,不会自动执行" : "未发布,不会自动执行"
} }
actions={ actions={
draft ? ( draft ? (
@@ -348,13 +348,13 @@ function PolicyGroup({
} }
function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) { function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) {
if (!valid || !rules) return <p className="text-caption text-destructive">规则无法解析,不会执行</p>; if (!valid || !rules) return <p className="text-caption text-destructive">规则无法解析,不会执行</p>;
const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`; const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`;
return ( return (
<div className="space-y-2 text-caption"> <div className="space-y-2 text-caption">
<div> <div>
<span className="text-muted-foreground">默认版本目标 </span> <span className="text-muted-foreground">默认版本目标 </span>
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有版本目标时不自动创建)" /> <ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有版本目标时不自动创建)" />
</div> </div>
<div> <div>
<span className="text-muted-foreground">新作品收录 </span> <span className="text-muted-foreground">新作品收录 </span>
@@ -469,12 +469,12 @@ function PolicyDetailSheet({
</Button> </Button>
} }
title={`回滚到 r${rev.revision}?`} title={`回滚到 r${rev.revision}?`}
description={`用 r${rev.revision} 的内容发布一个新版本,当前版本标记为已回滚。${PUBLISH_NOTICE}`} description={`用 r${rev.revision} 的内容发布一个新版本,当前版本标记为已回滚。${PUBLISH_NOTICE}`}
confirmLabel="回滚" confirmLabel="回滚"
onConfirm={() => onConfirm={() =>
rollback rollback
.mutateAsync(rev.id) .mutateAsync(rev.id)
.then((n) => toast.success(`已回滚,r${n.revision} 生效`)) .then((n) => toast.success(`已回滚,r${n.revision} 生效`))
.then(onClose) .then(onClose)
.catch((e) => { .catch((e) => {
toast.error(errorMessage(e)); toast.error(errorMessage(e));
@@ -538,7 +538,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
if (!value) if (!value)
return ( return (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
还没有预览。预览用最近已识别的资源评估模板,不会执行任何操作。 还没有预览。预览用最近已识别的资源评估模板,不会执行任何操作。
</p> </p>
); );
const p = PolicyPreview.safeParse(value); const p = PolicyPreview.safeParse(value);
@@ -548,7 +548,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
<div className="space-y-3"> <div className="space-y-3">
{!v.valid ? ( {!v.valid ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-caption text-destructive"> <p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-caption text-destructive">
规则未通过校验,预览按空模板计算,无法发布。 规则未通过校验,预览按空模板计算,无法发布。
</p> </p>
) : null} ) : null}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -565,7 +565,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
))} ))}
</div> </div>
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"资源 × 模板"计数。 计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"资源 × 模板"计数。
</p> </p>
{v.samples.length ? ( {v.samples.length ? (
<ul className="divide-y divide-border/60 rounded-lg border border-border/70"> <ul className="divide-y divide-border/60 rounded-lg border border-border/70">
@@ -660,7 +660,7 @@ function PolicyEditorSheet({
onOpenChange={(o) => !o && !pending && onClose()} onOpenChange={(o) => !o && !pending && onClose()}
width="lg" width="lg"
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"} title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
description="保存为草案,发布后才生效。" description="保存为草案,发布后才生效。"
footer={ footer={
<> <>
<Button variant="outline" onClick={onClose} disabled={pending}> <Button variant="outline" onClick={onClose} disabled={pending}>
@@ -699,7 +699,12 @@ function PolicyEditorSheet({
/> />
</FormField> </FormField>
</div> </div>
<FormField label="本版说明" htmlFor={`${id}-sum`} error={errors.summary} hint="这一版改了什么,显示在版本历史里"> <FormField
label="本版说明"
htmlFor={`${id}-sum`}
error={errors.summary}
hint="这一版改了什么,显示在版本历史里"
>
<Textarea id={`${id}-sum`} rows={2} value={d.summary} onChange={(e) => set("summary", e.target.value)} /> <Textarea id={`${id}-sum`} rows={2} value={d.summary} onChange={(e) => set("summary", e.target.value)} />
</FormField> </FormField>
<FormField label="作用范围" hint="不选即全部作品类型"> <FormField label="作用范围" hint="不选即全部作品类型">
@@ -725,7 +730,7 @@ function PolicyEditorSheet({
</Button> </Button>
} }
> >
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有版本目标时,按这些模板创建。</p> <p className="mb-2 text-meta text-muted-foreground">订阅的作品没有版本目标时,按这些模板创建。</p>
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} /> <TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
</DetailSection> </DetailSection>
@@ -734,7 +739,7 @@ function PolicyEditorSheet({
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"> <div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-caption font-medium text-foreground">开启新作品收录</p> <p className="text-caption font-medium text-foreground">开启新作品收录</p>
<p className="text-meta text-muted-foreground">按发行/首播日期判断,新上传的旧片不算</p> <p className="text-meta text-muted-foreground">按发行/首播日期判断,新上传的旧片不算</p>
</div> </div>
<Switch <Switch
checked={d.newTitleEnabled} checked={d.newTitleEnabled}
@@ -743,7 +748,7 @@ function PolicyEditorSheet({
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<FormField label="时间窗口(天)" htmlFor={`${id}-win`} error={errors.windowDays} hint="发行/首播在窗口内"> <FormField label="时间窗口(天)" htmlFor={`${id}-win`} error={errors.windowDays} hint="发行/首播在窗口内">
<Input <Input
id={`${id}-win`} id={`${id}-win`}
inputMode="numeric" inputMode="numeric"
@@ -790,7 +795,7 @@ function PolicyEditorSheet({
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
<SelectItem value="__default">按系统设置</SelectItem> <SelectItem value="__default">按系统设置</SelectItem>
<SelectItem value="copy">复制(保留源文件做种)</SelectItem> <SelectItem value="copy">复制(保留源文件做种)</SelectItem>
<SelectItem value="move">移动</SelectItem> <SelectItem value="move">移动</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -850,7 +855,7 @@ function TemplateList({
aria-label="模板名称" aria-label="模板名称"
aria-invalid={!!errs.name} aria-invalid={!!errs.name}
value={t.name} value={t.name}
placeholder="模板名称,如 archival" placeholder="模板名称,如 archival"
onChange={(e) => patch(t.key, { name: e.target.value })} onChange={(e) => patch(t.key, { name: e.target.value })}
className="h-8 min-w-0 flex-1 font-mono" className="h-8 min-w-0 flex-1 font-mono"
/> />
@@ -30,7 +30,7 @@ const SlotMeta = z.object({
/** 409:别人(或状态机)已改过这个 Slot */ /** 409:别人(或状态机)已改过这个 Slot */
export function conflictToast(err: unknown, onRefresh: () => void) { export function conflictToast(err: unknown, onRefresh: () => void) {
if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) { if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) {
toast.error("规则已被修改,已刷新。在最新版本上重新编辑。", { duration: 4000 }); toast.error("规则已被修改,已刷新。在最新版本上重新编辑。", { duration: 4000 });
onRefresh(); onRefresh();
return; return;
} }
@@ -66,7 +66,7 @@ export function SlotsPanel({
plain plain
icon={Target} icon={Target}
title="还没有版本目标" title="还没有版本目标"
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。" description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
className="py-8" className="py-8"
/> />
) : ( ) : (
@@ -180,7 +180,7 @@ function SlotEditorSheet({
width="lg" width="lg"
title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"} title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"}
description={ description={
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。" slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
} }
footer={ footer={
<> <>
@@ -97,8 +97,8 @@ function PasskeyRow({ item }: { item: PasskeyInfo }) {
<Trash2 /> <Trash2 />
</Button> </Button>
} }
title={`删除 Passkey「${item.name}」?`} title={`删除 Passkey「${item.name}」?`}
description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。" description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。"
confirmLabel="删除" confirmLabel="删除"
destructive destructive
onConfirm={async () => { onConfirm={async () => {
@@ -139,7 +139,7 @@ export function PasskeyBlock({ disabled }: { disabled: boolean }) {
}; };
let body: React.ReactNode; let body: React.ReactNode;
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>; if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
else else
body = ( body = (
<div className="grid gap-3"> <div className="grid gap-3">
@@ -155,7 +155,7 @@ export function PasskeyBlock({ disabled }: { disabled: boolean }) {
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p> <p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
)} )}
{!supported ? ( {!supported ? (
<p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p> <p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p>
) : adding ? ( ) : adding ? (
<form onSubmit={add} className="flex max-w-md flex-wrap items-center gap-2"> <form onSubmit={add} className="flex max-w-md flex-wrap items-center gap-2">
<Input <Input
@@ -197,7 +197,7 @@ export function PasskeyBlock({ disabled }: { disabled: boolean }) {
section="account" section="account"
anchor="passkeys" anchor="passkeys"
title="Passkey" title="Passkey"
description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。" description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。"
> >
{body} {body}
</SettingsBlock> </SettingsBlock>
@@ -45,13 +45,13 @@ function QrCode({ value }: { value: string }) {
/** 恢复码展示:只此一次;可复制、下载 */ /** 恢复码展示:只此一次;可复制、下载 */
function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) { function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) {
const text = `UHD Media Tool 恢复码(每个只能用一次)\n\n${codes.join("\n")}\n`; const text = `UHD Media Tool 恢复码(每个只能用一次)\n\n${codes.join("\n")}\n`;
const copy = async () => { const copy = async () => {
try { try {
await navigator.clipboard.writeText(codes.join("\n")); await navigator.clipboard.writeText(codes.join("\n"));
toast.success("恢复码已复制"); toast.success("恢复码已复制");
} catch { } catch {
toast.error("复制失败,手动选中复制"); toast.error("复制失败,手动选中复制");
} }
}; };
const download = () => { const download = () => {
@@ -65,8 +65,8 @@ function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void
return ( return (
<div className="grid max-w-md gap-3"> <div className="grid max-w-md gap-3">
<p className="text-caption"> <p className="text-caption">
验证器丢了就用恢复码登录,每个只能用一次。 验证器丢了就用恢复码登录,每个只能用一次。
<span className="text-muted-foreground">它们只显示这一次,存到密码管理器里。</span> <span className="text-muted-foreground">它们只显示这一次,存到密码管理器里。</span>
</p> </p>
<ol className="grid grid-cols-2 gap-x-6 gap-y-1 rounded-lg bg-muted px-4 py-3 font-mono text-sm select-all"> <ol className="grid grid-cols-2 gap-x-6 gap-y-1 rounded-lg bg-muted px-4 py-3 font-mono text-sm select-all">
{codes.map((c) => ( {codes.map((c) => (
@@ -173,7 +173,7 @@ export function TotpBlock({ disabled }: { disabled: boolean }) {
const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim(); const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim();
let body: React.ReactNode; let body: React.ReactNode;
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>; if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
else if (mode === "codes") body = <RecoveryCodes codes={codes} onDone={() => reset()} />; else if (mode === "codes") body = <RecoveryCodes codes={codes} onDone={() => reset()} />;
else if (mode === "setup") else if (mode === "setup")
body = setup.isPending ? ( body = setup.isPending ? (
@@ -190,9 +190,11 @@ export function TotpBlock({ disabled }: { disabled: boolean }) {
<form onSubmit={enable} noValidate className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start"> <form onSubmit={enable} noValidate className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start">
<QrCode value={setup.data.otpauthUri} /> <QrCode value={setup.data.otpauthUri} />
<div className="grid max-w-md gap-2"> <div className="grid max-w-md gap-2">
<p className="text-caption">用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。</p> <p className="text-caption">
用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。
</p>
<details className="text-meta text-muted-foreground"> <details className="text-meta text-muted-foreground">
<summary className="cursor-pointer select-none">扫不了码?手动输入密钥</summary> <summary className="cursor-pointer select-none">扫不了码?手动输入密钥</summary>
<code className="mt-1.5 block rounded-lg bg-muted px-2 py-1.5 font-mono text-sm break-all text-foreground select-all"> <code className="mt-1.5 block rounded-lg bg-muted px-2 py-1.5 font-mono text-sm break-all text-foreground select-all">
{grouped} {grouped}
</code> </code>
@@ -261,7 +263,7 @@ export function TotpBlock({ disabled }: { disabled: boolean }) {
body = ( body = (
<div className="grid justify-items-start gap-3"> <div className="grid justify-items-start gap-3">
<p className={left <= 2 ? "text-caption text-warning" : "text-caption text-muted-foreground"}> <p className={left <= 2 ? "text-caption text-warning" : "text-caption text-muted-foreground"}>
还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""} 还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => reset("regenerate")}> <Button size="sm" variant="outline" onClick={() => reset("regenerate")}>
@@ -290,7 +292,7 @@ export function TotpBlock({ disabled }: { disabled: boolean }) {
description={ description={
enabled enabled
? "登录时除了密码还要输入验证器里的 6 位验证码。关闭需要当前密码。" ? "登录时除了密码还要输入验证器里的 6 位验证码。关闭需要当前密码。"
: "开启后登录还要输入验证器里的 6 位验证码,同时生成 8 个一次性恢复码。" : "开启后登录还要输入验证器里的 6 位验证码,同时生成 8 个一次性恢复码。"
} }
actions={ actions={
disabled || status.isPending ? null : ( disabled || status.isPending ? null : (
+3 -3
View File
@@ -39,7 +39,7 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
setErrors({}); setErrors({});
try { try {
const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword }); const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword });
toast.success(r.revokedSessions ? `密码已修改,注销了其它 ${r.revokedSessions} 个会话` : "密码已修改"); toast.success(r.revokedSessions ? `密码已修改,注销了其它 ${r.revokedSessions} 个会话` : "密码已修改");
setForm(EMPTY_PWD); setForm(EMPTY_PWD);
} catch (err) { } catch (err) {
const msg = errorMessage(err); const msg = errorMessage(err);
@@ -56,7 +56,7 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
section="account" section="account"
anchor="password" anchor="password"
title="修改密码" title="修改密码"
description="修改后其它设备上的会话全部注销,当前会话保留。" description="修改后其它设备上的会话全部注销,当前会话保留。"
dirty={dirty} dirty={dirty}
> >
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-3"> <form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-3">
@@ -135,7 +135,7 @@ function SessionBlock() {
<div className="mt-4 border-t border-border/60 pt-4"> <div className="mt-4 border-t border-border/60 pt-4">
<p className="text-caption font-medium">本机 Token</p> <p className="text-caption font-medium">本机 Token</p>
<p className="mb-2 text-meta text-muted-foreground"> <p className="mb-2 text-meta text-muted-foreground">
只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"} 只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"}
</p> </p>
<div className="flex max-w-md flex-wrap items-center gap-2"> <div className="flex max-w-md flex-wrap items-center gap-2">
<Input <Input
+16 -16
View File
@@ -17,7 +17,7 @@ const RUNTIME_CARD: CardSpec = {
name: "pi.contextBudget", name: "pi.contextBudget",
label: "上下文预算(token)", label: "上下文预算(token)",
kind: "number", kind: "number",
hint: "4096–2000000,超过模型窗口时按窗口算", hint: "4096–2000000,超过模型窗口时按窗口算",
}, },
{ name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" }, { name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" }, { name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
@@ -25,7 +25,7 @@ const RUNTIME_CARD: CardSpec = {
name: "pi.compaction.thresholdRatio", name: "pi.compaction.thresholdRatio",
label: "压缩触发比例", label: "压缩触发比例",
kind: "number", kind: "number",
hint: "0.3–0.95,用到预算的这个比例时压缩", hint: "0.3–0.95,用到预算的这个比例时压缩",
visible: (v) => v["pi.compaction.enabled"] === true, visible: (v) => v["pi.compaction.enabled"] === true,
}, },
], ],
@@ -50,12 +50,12 @@ const RUNTIME_CARD: CardSpec = {
const BUDGET_CARD: CardSpec = { const BUDGET_CARD: CardSpec = {
anchor: "budget", anchor: "budget",
title: "预算", title: "预算",
description: "超出每日预算的运行停在「超出预算」,等你放行。", description: "超出每日预算的运行停在「超出预算」,等你放行。",
fields: [ fields: [
{ name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" }, { name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" },
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" }, { name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" },
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" }, { name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" }, { name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{ name: "agent.maxTokensPerRun", label: "单次 token 上限", kind: "number", hint: "留空不限" }, { name: "agent.maxTokensPerRun", label: "单次 token 上限", kind: "number", hint: "留空不限" },
{ name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" }, { name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" },
], ],
@@ -84,7 +84,7 @@ const BUDGET_CARD: CardSpec = {
const INVESTIGATE_CARD: CardSpec = { const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate", anchor: "investigate",
title: "自动调查", title: "自动调查",
description: "待处理事项(识别有歧义、下载失败等)创建时,自动发起一次有界调查。", description: "待处理事项(识别有歧义、下载失败等)创建时,自动发起一次有界调查。",
fields: [ fields: [
{ name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true }, { name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true },
{ {
@@ -92,10 +92,10 @@ const INVESTIGATE_CARD: CardSpec = {
label: "模式", label: "模式",
kind: "select", kind: "select",
options: [ options: [
{ value: "investigate", label: "只读调查,结论交还给你" }, { value: "investigate", label: "只读调查,结论交还给你" },
{ value: "recover", label: "授权内恢复(重试任务、重新校验、确认识别)" }, { value: "recover", label: "授权内恢复(重试任务、重新校验、确认识别)" },
], ],
hint: "恢复只在已发布策略或持续授权覆盖时执行,换源、删除、清理始终排除;没有授权时按只读调查", hint: "恢复只在已发布策略或持续授权覆盖时执行,换源、删除、清理始终排除;没有授权时按只读调查",
wide: true, wide: true,
visible: (v) => v["agent.autoInvestigate"] === true, visible: (v) => v["agent.autoInvestigate"] === true,
}, },
@@ -110,12 +110,12 @@ const INVESTIGATE_CARD: CardSpec = {
name: "agent.investigateMaxAttempts", name: "agent.investigateMaxAttempts",
label: "同一根因最多恢复次数", label: "同一根因最多恢复次数",
kind: "number", kind: "number",
hint: "1–10,用完后只交还给你", hint: "1–10,用完后只交还给你",
visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover", visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover",
}, },
{ {
name: "agent.recoverVerifySec", name: "agent.recoverVerifySec",
label: "恢复后核实时长(秒)", label: "恢复后核实时长(秒)",
kind: "number", kind: "number",
hint: "等下载或任务状态变化的最长时间", hint: "等下载或任务状态变化的最长时间",
visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover", visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover",
@@ -143,16 +143,16 @@ const ADVANCED_CARD: CardSpec = {
anchor: "advanced", anchor: "advanced",
title: "高级", title: "高级",
fields: [ fields: [
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" }, { name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{ {
name: "agent.goalWaitHours", name: "agent.goalWaitHours",
label: "目标最长等待(小时)", label: "目标最长等待(小时)",
kind: "number", kind: "number",
hint: "超时后按「仍在等待」汇报结束", hint: "超时后按「仍在等待」汇报结束",
}, },
{ {
name: "agent.optionsTimeoutSec", name: "agent.optionsTimeoutSec",
label: "方案选择等待(秒)", label: "方案选择等待(秒)",
kind: "number", kind: "number",
hint: "Agent 给出方案后等你选择的最长时间", hint: "Agent 给出方案后等你选择的最长时间",
}, },
@@ -167,7 +167,7 @@ const ADVANCED_CARD: CardSpec = {
}, },
{ {
name: "agent.approvalTimeoutSec", name: "agent.approvalTimeoutSec",
label: "会话内审批有效期(秒)", label: "会话内审批有效期(秒)",
kind: "number", kind: "number",
hint: "步骤处的审批卡过期未批准,按拒绝处理", hint: "步骤处的审批卡过期未批准,按拒绝处理",
}, },
@@ -177,13 +177,13 @@ const ADVANCED_CARD: CardSpec = {
kind: "text", kind: "text",
wide: true, wide: true,
placeholder: "留空用 DATA_DIR/agent-sessions", placeholder: "留空用 DATA_DIR/agent-sessions",
hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改", hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改",
}, },
{ {
name: "agent.fakeScript", name: "agent.fakeScript",
label: "fake 脚本(JSON)", label: "fake 脚本(JSON)",
kind: "textarea", kind: "textarea",
hint: "只对 fake 上游生效,留空用内置演示脚本;格式见 docs/runbook/agent.md", hint: "只对 fake 上游生效,留空用内置演示脚本;格式见 docs/runbook/agent.md",
}, },
], ],
fromSettings: (s) => ({ fromSettings: (s) => ({
@@ -39,7 +39,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
setDone(true); setDone(true);
toast.success("已复制"); toast.success("已复制");
} catch { } catch {
toast.error("复制失败,手动选中复制"); toast.error("复制失败,手动选中复制");
} }
}} }}
> >
@@ -149,7 +149,7 @@ export function PiOAuthLogin({
waitMs: 25_000, waitMs: 25_000,
}); });
setProgress(r.progress); setProgress(r.progress);
if (r.state === "pending") setMessage("还在处理,稍后再提交一次。"); if (r.state === "pending") setMessage("还在处理,稍后再提交一次。");
else finish(r.state, r.message); else finish(r.state, r.message);
} catch (err) { } catch (err) {
toast.error(errorMessage(err, "提交失败")); toast.error(errorMessage(err, "提交失败"));
@@ -213,7 +213,7 @@ export function PiOAuthLogin({
) : session.mode === "device" ? ( ) : session.mode === "device" ? (
<div className="grid gap-2 rounded-lg border border-border/60 p-3"> <div className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
在任意设备打开验证地址,输入下面的验证码。授权后这里自动完成。 在任意设备打开验证地址,输入下面的验证码。授权后这里自动完成。
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="rounded-md bg-muted px-3 py-1.5 font-mono text-lead tracking-widest select-all"> <code className="rounded-md bg-muted px-3 py-1.5 font-mono text-lead tracking-widest select-all">
@@ -256,7 +256,7 @@ export function PiOAuthLogin({
) : ( ) : (
<form onSubmit={submitPasted} noValidate className="grid gap-2 rounded-lg border border-border/60 p-3"> <form onSubmit={submitPasted} noValidate className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
在浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开属正常,把地址栏里的完整 URL 粘贴到下面。 在浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开属正常,把地址栏里的完整 URL 粘贴到下面。
</p> </p>
{session.authorizeUrl ? ( {session.authorizeUrl ? (
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
+6 -6
View File
@@ -22,21 +22,21 @@ const DEFAULTS_CARD: CardSpec = {
{ name: "sites.defaultRateLimit.concurrency", label: "并发", kind: "number", hint: "同一站点同时进行的请求" }, { name: "sites.defaultRateLimit.concurrency", label: "并发", kind: "number", hint: "同一站点同时进行的请求" },
{ {
name: "sites.defaultSnapshotIntervalSec", name: "sites.defaultSnapshotIntervalSec",
label: "快照周期(分钟)", label: "快照周期(分钟)",
kind: "number", kind: "number",
hint: "站点开了定时快照但没设周期时使用;5–10080", hint: "站点开了定时快照但没设周期时使用;5–10080",
}, },
{ {
name: "sites.userDataMaxSeedingPages", name: "sites.userDataMaxSeedingPages",
label: "做种列表页数上限", label: "做种列表页数上限",
kind: "number", kind: "number",
hint: "拉取用户数据时最多翻几页做种列表;1–50", hint: "拉取用户数据时最多翻几页做种列表;1–50",
}, },
{ {
name: "sites.userAgent", name: "sites.userAgent",
label: "User-Agent", label: "User-Agent",
kind: "text", kind: "text",
hint: "账号没填 UA 时,登录和拉取用户数据用这个", hint: "账号没填 UA 时,登录和拉取用户数据用这个",
wide: true, wide: true,
}, },
{ {
@@ -44,7 +44,7 @@ const DEFAULTS_CARD: CardSpec = {
label: "代理地址", label: "代理地址",
kind: "url", kind: "url",
placeholder: "http://127.0.0.1:7890", placeholder: "http://127.0.0.1:7890",
hint: "只对开了“使用代理”的站点生效;留空不用代理", hint: "只对开了「使用代理」的站点生效;留空不用代理",
wide: true, wide: true,
}, },
], ],
@@ -81,7 +81,7 @@ export function SitesSection() {
<div className="space-y-6"> <div className="space-y-6">
<div className="pg-slab flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3"> <div className="pg-slab flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3">
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。 站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
</p> </p>
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}> <Link to={consolePath("sites")}>
@@ -136,7 +136,7 @@ export function CredentialFields({
<FormField <FormField
label="TOTP 密钥" label="TOTP 密钥"
htmlFor={id("totp")} htmlFor={id("totp")}
hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码" hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码"
> >
<Input <Input
id={id("totp")} id={id("totp")}
@@ -157,7 +157,7 @@ export function CredentialFields({
<label htmlFor={id("cookie-only")} className="text-caption"> <label htmlFor={id("cookie-only")} className="text-caption">
<span className="block font-medium text-foreground">只换 Cookie</span> <span className="block font-medium text-foreground">只换 Cookie</span>
<span className="block text-muted-foreground"> <span className="block text-muted-foreground">
密码加密保存,用于 Cookie 失效后自动重新登录。打开后登录成功只留 Cookie,不保存密码 密码加密保存,用于 Cookie 失效后自动重新登录。打开后登录成功只留 Cookie,不保存密码
</span> </span>
</label> </label>
</div> </div>
@@ -168,7 +168,7 @@ export function CredentialFields({
htmlFor={id("cookie")} htmlFor={id("cookie")}
required required
error={errors.cookie} error={errors.cookie}
hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie" hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie"
> >
<Textarea <Textarea
id={id("cookie")} id={id("cookie")}
@@ -198,7 +198,7 @@ export function CredentialFields({
<FormField <FormField
label="User-Agent" label="User-Agent"
htmlFor={id("ua")} htmlFor={id("ua")}
hint={value.kind === "cookie" ? "Cookie 与浏览器 UA 绑定时填写;留空用站点默认" : "留空用站点默认"} hint={value.kind === "cookie" ? "Cookie 与浏览器 UA 绑定时填写;留空用站点默认" : "留空用站点默认"}
> >
<Input <Input
id={id("ua")} id={id("ua")}
@@ -36,7 +36,7 @@ export function SiteAccountsTab({
<EmptyState <EmptyState
icon={UserRound} icon={UserRound}
title="还没有账号" title="还没有账号"
description="填写凭证后,这里显示登录状态和登录历史" description="填写凭证后,这里显示登录状态和登录历史"
action={ action={
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}> <Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound /> <KeyRound />
@@ -110,8 +110,8 @@ function AccountBlock({
删除账号 删除账号
</Button> </Button>
} }
title={`删除账号「${a.label}」?`} title={`删除账号「${a.label}」?`}
description="账号、凭证和用户数据历史一起删除,不可恢复。站点和资源保留。" description="账号、凭证和用户数据历史一起删除,不可恢复。站点和资源保留。"
confirmLabel="删除账号" confirmLabel="删除账号"
destructive destructive
onConfirm={async () => { onConfirm={async () => {
@@ -241,7 +241,7 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
> >
<h2 className="text-lead font-semibold text-foreground">Passkey</h2> <h2 className="text-lead font-semibold text-foreground">Passkey</h2>
<p className="mb-3 text-caption text-muted-foreground"> <p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "已保存;再次保存会替换。" : "还没有保存。"} 用于 RSS 和下载链接,不参与登录。{has ? "已保存;再次保存会替换。" : "还没有保存。"}
</p> </p>
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
<FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1"> <FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1">
@@ -96,9 +96,9 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
const u = new URL(form.baseUrl.trim()); const u = new URL(form.baseUrl.trim());
if (!/^https?:$/.test(u.protocol)) throw new Error(); if (!/^https?:$/.test(u.protocol)) throw new Error();
} catch { } catch {
fe.baseUrl = "填完整地址,如 https://example.org"; fe.baseUrl = "填完整地址,如 https://example.org";
} }
if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _"; if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
let body = null; let body = null;
if (kinds.length) { if (kinds.length) {
// 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突 // 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突
@@ -258,14 +258,14 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
onClick={() => pick(CUSTOM)} onClick={() => pick(CUSTOM)}
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground" className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
> >
自定义 RSS 站点:只抓 Feed,不登录 自定义 RSS 站点:只抓 Feed,不登录
</button> </button>
</section> </section>
</> </>
)} )}
</div> </div>
<p className="mt-3 text-meta text-muted-foreground"> <p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。 "未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
</p> </p>
</div> </div>
) : step === 2 ? ( ) : step === 2 ? (
@@ -343,7 +343,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
<div className="min-w-0 space-y-3" data-state={phase ? "running" : "done"} aria-busy={busy}> <div className="min-w-0 space-y-3" data-state={phase ? "running" : "done"} aria-busy={busy}>
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
{created ? `已添加「${created.name}」。` : ""} {created ? `已添加「${created.name}」。` : ""}
{phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")} {phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")}
</p> </p>
{phase ? <LoadingState rows={2} /> : null} {phase ? <LoadingState rows={2} /> : null}
<LoginResultView <LoginResultView
+1 -1
View File
@@ -150,7 +150,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
variant="outline" variant="outline"
size="sm" size="sm"
disabled={h || !site.enabled} disabled={h || !site.enabled}
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"} title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)} onClick={() => void checks.checkHealth(site)}
> >
{h ? <LoaderCircle className="animate-spin" /> : <Activity />} {h ? <LoaderCircle className="animate-spin" /> : <Activity />}
+11 -11
View File
@@ -256,7 +256,7 @@ export function CredentialDialog({
onClose(); onClose();
} }
} else { } else {
toast.success("凭证已保存,刷新登录已排队"); toast.success("凭证已保存,刷新登录已排队");
onClose(); onClose();
} }
} catch (err) { } catch (err) {
@@ -271,8 +271,8 @@ export function CredentialDialog({
<DialogTitle>更新凭证 · {site.name}</DialogTitle> <DialogTitle>更新凭证 · {site.name}</DialogTitle>
<DialogDescription> <DialogDescription>
{current {current
? `当前:${AUTH_KIND_LABEL[current.authKind]}。凭证加密保存,不回显;保存后立即刷新登录` ? `当前:${AUTH_KIND_LABEL[current.authKind]}。凭证加密保存,不回显;保存后立即刷新登录`
: "凭证加密保存,不回显;保存后立即刷新登录"} : "凭证加密保存,不回显;保存后立即刷新登录"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form id="site-cred" onSubmit={submit} noValidate> <form id="site-cred" onSubmit={submit} noValidate>
@@ -334,12 +334,12 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
try { try {
new URL(v.baseUrl.trim()); new URL(v.baseUrl.trim());
} catch { } catch {
fe.baseUrl = "填完整地址,如 https://example.org"; fe.baseUrl = "填完整地址,如 https://example.org";
} }
const posInt = (s: string, k: string, min = 1) => { const posInt = (s: string, k: string, min = 1) => {
if (!s.trim()) return undefined; if (!s.trim()) return undefined;
const n = Number(s); const n = Number(s);
if (!Number.isInteger(n) || n < min) fe[k] = `填不小于 ${min} 的整数,留空用站点默认`; if (!Number.isInteger(n) || n < min) fe[k] = `填不小于 ${min} 的整数,留空用站点默认`;
return n; return n;
}; };
const rpm = posInt(v.rpm, "rpm"); const rpm = posInt(v.rpm, "rpm");
@@ -436,7 +436,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
</label> </label>
</div> </div>
{v.snapshotEnabled ? ( {v.snapshotEnabled ? (
<FormField label="快照周期(分钟)" htmlFor="se-int" error={errors.intervalMin} hint="留空用站点默认"> <FormField label="快照周期(分钟)" htmlFor="se-int" error={errors.intervalMin} hint="留空用站点默认">
<Input <Input
id="se-int" id="se-int"
inputMode="numeric" inputMode="numeric"
@@ -450,10 +450,10 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
<Switch id="se-proxy" checked={v.proxy} onCheckedChange={(x) => set("proxy", x)} disabled={busy} /> <Switch id="se-proxy" checked={v.proxy} onCheckedChange={(x) => set("proxy", x)} disabled={busy} />
<label htmlFor="se-proxy" className="text-caption"> <label htmlFor="se-proxy" className="text-caption">
<span className="block font-medium text-foreground">使用代理</span> <span className="block font-medium text-foreground">使用代理</span>
<span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span> <span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span>
</label> </label>
</div> </div>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p> <p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
</div> </div>
</DetailSheet> </DetailSheet>
); );
@@ -474,7 +474,7 @@ export function DeleteSiteDialog({
<ConfirmDialog <ConfirmDialog
open open
onOpenChange={(o) => !o && onClose()} onOpenChange={(o) => !o && onClose()}
title={`删除站点「${site.name}」?`} title={`删除站点「${site.name}」?`}
description="不可恢复。" description="不可恢复。"
confirmLabel="删除站点" confirmLabel="删除站点"
destructive destructive
@@ -491,8 +491,8 @@ export function DeleteSiteDialog({
> >
<ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground"> <ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground">
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li> <li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li>
<li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li> <li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li>
<li>该站 Feed 停用;已有下载任务按原策略继续</li> <li>该站 Feed 停用;已有下载任务按原策略继续</li>
<li>同一站点以后可以重新添加</li> <li>同一站点以后可以重新添加</li>
</ul> </ul>
</ConfirmDialog> </ConfirmDialog>
+6 -6
View File
@@ -150,15 +150,15 @@ export function loginFailureText(
): { text: string; suggestCookie: boolean } { ): { text: string; suggestCookie: boolean } {
switch (reason) { switch (reason) {
case "captcha": case "captcha":
return { text: "登录失败:站点要求验证码。改用 Cookie,或稍后重试", suggestCookie: true }; return { text: "登录失败:站点要求验证码。改用 Cookie,或稍后重试", suggestCookie: true };
case "two_factor": case "two_factor":
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true }; return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
case "challenge": case "challenge":
return { text: "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie", suggestCookie: true }; return { text: "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie", suggestCookie: true };
case "bad_credentials": case "bad_credentials":
return { text: "登录失败:账号或密码错误", suggestCookie: false }; return { text: "登录失败:账号或密码错误", suggestCookie: false };
case "account_blocked": case "account_blocked":
return { text: `登录失败:账号被封禁或未确认${message ? `(${message})` : ""}`, suggestCookie: false }; return { text: `登录失败:账号被封禁或未确认${message ? `(${message})` : ""}`, suggestCookie: false };
case "auth_expired": case "auth_expired":
return { return {
text: text:
@@ -172,7 +172,7 @@ export function loginFailureText(
case "unsupported": case "unsupported":
return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false }; return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false };
default: default:
return { text: message ?? "登录失败,原因未知", suggestCookie: false }; return { text: message ?? "登录失败,原因未知", suggestCookie: false };
} }
} }
@@ -90,7 +90,7 @@ export function UserDataChart({ points }: { points: SiteUserDataPoint[] }) {
if (rows.length < 2) if (rows.length < 2)
return ( return (
<p className="py-6 text-center text-caption text-muted-foreground"> <p className="py-6 text-center text-caption text-muted-foreground">
{rows.length ? "只有 1 天的数据,满 2 天后显示趋势" : "还没有历史数据。刷新登录成功后每天记一次"} {rows.length ? "只有 1 天的数据,满 2 天后显示趋势" : "还没有历史数据。刷新登录成功后每天记一次"}
</p> </p>
); );
@@ -52,7 +52,7 @@ export function useSubscriptionOps() {
remove: (ids: string[]) => remove: (ids: string[]) =>
batch.mutateAsync({ ids, action: "delete" }).then((r) => { batch.mutateAsync({ ids, action: "delete" }).then((r) => {
const bad = r.results.filter((x) => !x.ok); const bad = r.results.filter((x) => !x.ok);
if (bad.length) toast.error(`${bad.length} 个没有删除:${bad[0]?.error ?? ""}`); if (bad.length) toast.error(`${bad.length} 个没有删除:${bad[0]?.error ?? ""}`);
else toast.success(ids.length > 1 ? `已删除 ${ids.length} 个订阅` : "订阅已删除"); else toast.success(ids.length > 1 ? `已删除 ${ids.length} 个订阅` : "订阅已删除");
}), }),
batch: (ids: string[], a: "search" | "pause" | "resume") => batch: (ids: string[], a: "search" | "pause" | "resume") =>
@@ -215,8 +215,8 @@ export function DeleteSubscriptionsDialog({
<ConfirmDialog <ConfirmDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"} title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"}
description="不再搜索和追更。已入库文件、版本目标和下载记录保留,可在订阅历史里重新订阅。" description="不再搜索和追更。已入库文件、版本目标和下载记录保留,可在订阅历史里重新订阅。"
confirmLabel="删除" confirmLabel="删除"
destructive destructive
onConfirm={() => onConfirm={() =>
@@ -238,7 +238,7 @@ export function CellSheet({
} }
}} }}
title={sel ? `${sel.season} · ${columnLabel(sel.col)}` : ""} title={sel ? `${sel.season} · ${columnLabel(sel.col)}` : ""}
description={sel ? `${sel.row.name}${sel.row.slotId === null ? "(作品全部资产)" : ""}` : null} description={sel ? `${sel.row.name}${sel.row.slotId === null ? "(作品全部资产)" : ""}` : null}
footer={ footer={
sel && c ? ( sel && c ? (
<> <>
@@ -254,7 +254,7 @@ export function CellSheet({
<Button <Button
onClick={doFill} onClick={doFill}
disabled={!canFill || fill.isPending || done === c.unitKey} disabled={!canFill || fill.isPending || done === c.unitKey}
title={canFill ? undefined : "订阅不在追踪中,先恢复"} title={canFill ? undefined : "订阅不在追踪中,先恢复"}
> >
<Wand2 /> <Wand2 />
{done === c.unitKey ? "已提交" : c.upgradable ? "补全更好版本" : "手动补全"} {done === c.unitKey ? "已提交" : c.upgradable ? "补全更好版本" : "手动补全"}
@@ -282,7 +282,7 @@ export function CellSheet({
c.quality?.label ?? ([c.quality?.resolution, c.quality?.source].filter(Boolean).join(" ") || null), c.quality?.label ?? ([c.quality?.resolution, c.quality?.source].filter(Boolean).join(" ") || null),
}, },
{ label: "pri", value: c.pri !== null ? String(c.pri) : null }, { label: "pri", value: c.pri !== null ? String(c.pri) : null },
{ label: "可升级", value: c.state === "have" ? (c.upgradable ? "是,低于目标档位" : "否") : null }, { label: "可升级", value: c.state === "have" ? (c.upgradable ? "是,低于目标档位" : "否") : null },
{ {
label: "资产", label: "资产",
value: c.assetId ? ( value: c.assetId ? (
@@ -306,17 +306,17 @@ export function CellSheet({
: null, : null,
}, },
{ label: "原因", value: c.reason ? (SKIP_REASON[c.reason] ?? c.reason) : null }, { label: "原因", value: c.reason ? (SKIP_REASON[c.reason] ?? c.reason) : null },
{ label: "延后", value: c.deferred ? "有资源在等待重评(预算或自动补全关闭)" : null }, { label: "延后", value: c.deferred ? "有资源在等待重评(预算或自动补全关闭)" : null },
]} ]}
/> />
</DetailSection> </DetailSection>
{fillable ? ( {fillable ? (
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
手动补全不受自动补全开关和补全预算限制,仍受全局每日预算与缓存卷阈值约束。 手动补全不受自动补全开关和补全预算限制,仍受全局每日预算与缓存卷阈值约束。
</p> </p>
) : null} ) : null}
{sel.row.slotId === null && c.state === "missing" ? ( {sel.row.slotId === null && c.state === "missing" ? (
<p className="text-meta text-muted-foreground">这一行汇总全部版本。要补全,点具体版本目标那一行的格。</p> <p className="text-meta text-muted-foreground">这一行汇总全部版本。要补全,点具体版本目标那一行的格。</p>
) : null} ) : null}
</> </>
) : null} ) : null}
@@ -137,7 +137,7 @@ export const SKIP_REASON: Record<string, string> = {
out_of_range: "集范围外", out_of_range: "集范围外",
not_aired: "未播出", not_aired: "未播出",
not_required: "该版本目标不要求这个内容版本", not_required: "该版本目标不要求这个内容版本",
suspected: "总集数未知,按已见最高集推断", suspected: "总集数未知,按已见最高集推断",
deferred: "有延后的资源", deferred: "有延后的资源",
}; };
@@ -55,7 +55,7 @@ export function ProgressBlock({ x, className }: { x: Pick<Item, "progress" | "mo
"bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20", "bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20",
)} )}
role="img" role="img"
aria-label={`已入库 ${x.progress.have},在途 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`} aria-label={`已入库 ${x.progress.have},在途 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`}
> >
{unknown ? null : ( {unknown ? null : (
<> <>
@@ -53,7 +53,7 @@ export function SlotPanel({
setOrder.mutate( setOrder.mutate(
{ id: sub.id, slotIds: rows.map((r) => r.id) }, { id: sub.id, slotIds: rows.map((r) => r.id) },
{ {
onSuccess: () => toast.success(`主版本:${rows[0]?.name ?? ""}`), onSuccess: () => toast.success(`主版本:${rows[0]?.name ?? ""}`),
onError: (e) => { onError: (e) => {
toast.error(errorMessage(e)); toast.error(errorMessage(e));
setRows(serverRows); setRows(serverRows);
@@ -84,7 +84,7 @@ export function SlotPanel({
return ( return (
<Panel <Panel
title="版本目标" title="版本目标"
description="拖动调整顺序,第一个是主版本。主版本缺的照常追更;其他版本按下面的自动补全设置补齐。" description="拖动调整顺序,第一个是主版本。主版本缺的照常追更;其他版本按下面的自动补全设置补齐。"
actions={ actions={
dirty ? ( dirty ? (
<> <>
@@ -119,7 +119,7 @@ export function SlotPanel({
size="xs" size="xs"
onClick={() => fillSlot(r)} onClick={() => fillSlot(r)}
disabled={!canFill || fill.isPending || r.summary.missing === 0} disabled={!canFill || fill.isPending || r.summary.missing === 0}
title={canFill ? undefined : "订阅不在追踪中,先恢复"} title={canFill ? undefined : "订阅不在追踪中,先恢复"}
> >
<Wand2 /> <Wand2 />
补全此版本 补全此版本
@@ -154,7 +154,7 @@ function WorkPicker({ onPick, error }: { onPick: (t: Target) => void; error?: st
kind: "title", kind: "title",
titleQuery: c.year ? `${c.title} ${c.year}` : c.title, titleQuery: c.year ? `${c.title} ${c.year}` : c.title,
mediaKind: c.entityType === "movie" ? "movie" : "series", mediaKind: c.entityType === "movie" ? "movie" : "series",
note: `TMDB ${c.externalId},建档后自动关联`, note: `TMDB ${c.externalId},建档后自动关联`,
}); });
const row = (key: string, main: ReactNode, sub: string, onClick: () => void) => ( const row = (key: string, main: ReactNode, sub: string, onClick: () => void) => (
<li key={key}> <li key={key}>
@@ -178,7 +178,7 @@ function WorkPicker({ onPick, error }: { onPick: (t: Target) => void; error?: st
className="pl-9" className="pl-9"
value={q} value={q}
aria-invalid={!!error} aria-invalid={!!error}
placeholder="搜索标题 / 原名,库里没有的从 TMDB 选" placeholder="搜索标题 / 原名,库里没有的从 TMDB 选"
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
</div> </div>
@@ -240,7 +240,7 @@ function WorkPicker({ onPick, error }: { onPick: (t: Target) => void; error?: st
</p> </p>
) : null} ) : null}
{s.data?.media && !s.data.media.tmdb.configured ? ( {s.data?.media && !s.data.media.tmdb.configured ? (
<p className="px-3 pb-2 text-meta text-muted-foreground">TMDB 未配置,只搜了媒体库。</p> <p className="px-3 pb-2 text-meta text-muted-foreground">TMDB 未配置,只搜了媒体库。</p>
) : null} ) : null}
</> </>
)} )}
@@ -405,7 +405,7 @@ export function SubscriptionFormDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{editing ? "编辑订阅" : "新建订阅"}</DialogTitle> <DialogTitle>{editing ? "编辑订阅" : "新建订阅"}</DialogTitle>
<DialogDescription> <DialogDescription>
{editing ? titleText : "从媒体库或 TMDB 选作品;库里没有的按标题追踪,建档后自动关联。"} {editing ? titleText : "从媒体库或 TMDB 选作品;库里没有的按标题追踪,建档后自动关联。"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{dup ? ( {dup ? (
@@ -413,7 +413,7 @@ export function SubscriptionFormDialog({
role="alert" role="alert"
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-foreground" className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-foreground"
> >
<span>这部作品的同一季已有订阅,不能重复创建。</span> <span>这部作品的同一季已有订阅,不能重复创建。</span>
<Link <Link
to={consolePath("subscriptions", dup)} to={consolePath("subscriptions", dup)}
onClick={onClose} onClick={onClose}
@@ -445,7 +445,7 @@ export function SubscriptionFormDialog({
<p className="truncate text-sm font-medium text-foreground">{titleText}</p> <p className="truncate text-sm font-medium text-foreground">{titleText}</p>
)} )}
<p className="mt-0.5 text-meta text-muted-foreground"> <p className="mt-0.5 text-meta text-muted-foreground">
{target.kind === "entity" ? "媒体库作品" : (target.note ?? "按标题追踪,尚未建档")} {target.kind === "entity" ? "媒体库作品" : (target.note ?? "按标题追踪,尚未建档")}
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setTarget(null)}> <Button variant="ghost" size="sm" onClick={() => setTarget(null)}>
@@ -486,7 +486,7 @@ export function SubscriptionFormDialog({
<FormField <FormField
label="季" label="季"
htmlFor={f("season")} htmlFor={f("season")}
hint={editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品建档后可选季"} hint={editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品建档后可选季"}
> >
<Pick <Pick
id={f("season")} id={f("season")}
@@ -534,7 +534,7 @@ export function SubscriptionFormDialog({
label="总集数" label="总集数"
htmlFor={f("total")} htmlFor={f("total")}
error={err("total")} error={err("total")}
hint={d.totalLocked ? "锁定后元数据刷新不再改写" : "空 = 按 TMDB;不锁定时只升不降"} hint={d.totalLocked ? "锁定后元数据刷新不再改写" : "空 = 按 TMDB;不锁定时只升不降"}
> >
<Input <Input
id={f("total")} id={f("total")}
@@ -620,7 +620,7 @@ export function SubscriptionFormDialog({
) : subSlots.length === 0 ? ( ) : subSlots.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有版本目标。在对比视图里新建。</p> <p className="text-caption text-muted-foreground">还没有版本目标。在对比视图里新建。</p>
) : ( ) : (
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐"> <FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
<SlotOrderList <SlotOrderList
items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))} items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))}
onChange={setOrderState} onChange={setOrderState}
@@ -634,7 +634,7 @@ export function SubscriptionFormDialog({
htmlFor={f("tpl")} htmlFor={f("tpl")}
hint={ hint={
target?.kind === "entity" target?.kind === "entity"
? "作品还没有版本目标时按模板顺序创建,第一个是主版本" ? "作品还没有版本目标时按模板顺序创建,第一个是主版本"
: "作品建档后按模板创建版本目标" : "作品建档后按模板创建版本目标"
} }
> >
@@ -653,7 +653,7 @@ export function SubscriptionFormDialog({
<FormField <FormField
label="升级策略" label="升级策略"
htmlFor={f("upgrade")} htmlFor={f("upgrade")}
hint="作用于这个订阅的全部版本目标;已入库低于目标档位时继续找更好的" hint="作用于这个订阅的全部版本目标;已入库低于目标档位时继续找更好的"
> >
<Pick <Pick
id={f("upgrade")} id={f("upgrade")}
@@ -675,7 +675,7 @@ export function SubscriptionFormDialog({
<FormField <FormField
label="规则组" label="规则组"
htmlFor={f("groups")} htmlFor={f("groups")}
hint="按选择顺序串联;不选 = 订阅默认规则组。版本目标自己的规则组另算" hint="按选择顺序串联;不选 = 订阅默认规则组。版本目标自己的规则组另算"
> >
{groups.isPending ? ( {groups.isPending ? (
<p className="text-caption text-muted-foreground">读取规则组…</p> <p className="text-caption text-muted-foreground">读取规则组…</p>
@@ -697,7 +697,7 @@ export function SubscriptionFormDialog({
)} )}
</FormField> </FormField>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签"> <FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签">
<Textarea <Textarea
id={f("inc")} id={f("inc")}
rows={3} rows={3}
@@ -761,7 +761,7 @@ export function SubscriptionFormDialog({
label="搜索关键词" label="搜索关键词"
htmlFor={f("kw")} htmlFor={f("kw")}
error={err("keywords")} error={err("keywords")}
hint="每行一个,追加在标题和别名之后;最多 10 个" hint="每行一个,追加在标题和别名之后;最多 10 个"
> >
<Textarea <Textarea
id={f("kw")} id={f("kw")}
@@ -775,7 +775,7 @@ export function SubscriptionFormDialog({
<Section title="搜索与优先级"> <Section title="搜索与优先级">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<FormField <FormField
label="主动搜索间隔(小时)" label="主动搜索间隔(小时)"
htmlFor={f("interval")} htmlFor={f("interval")}
error={err("searchIntervalHours")} error={err("searchIntervalHours")}
hint="空 = 24;0 = 关" hint="空 = 24;0 = 关"
@@ -843,7 +843,7 @@ export function AutoFillFields({
<div className="space-y-3"> <div className="space-y-3">
{compact ? null : ( {compact ? null : (
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。 主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。
</p> </p>
)} )}
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
@@ -867,7 +867,7 @@ export function AutoFillFields({
))} ))}
</fieldset> </fieldset>
</FormField> </FormField>
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}> <FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
<Input <Input
id={`${idPrefix}-units`} id={`${idPrefix}-units`}
inputMode="numeric" inputMode="numeric"
@@ -888,7 +888,9 @@ export function AutoFillFields({
/> />
</FormField> </FormField>
</div> </div>
<p className="text-meta text-muted-foreground">预算只算自动补全的计划,超出的延到次日;同时受全局每日预算限制。</p> <p className="text-meta text-muted-foreground">
预算只算自动补全的计划,超出的延到次日;同时受全局每日预算限制。
</p>
</div> </div>
); );
} }
+2 -2
View File
@@ -25,7 +25,7 @@ export default function AgentPage() {
{mock ? ( {mock ? (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning"> <div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
<FlaskConical className="h-4 w-4 shrink-0" /> <FlaskConical className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">演示模式:会话是本地假数据,不连接后端,不执行任何操作。</span> <span className="min-w-0 flex-1">演示模式:会话是本地假数据,不连接后端,不执行任何操作。</span>
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=0")}> <Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=0")}>
退出演示 退出演示
</Button> </Button>
@@ -57,7 +57,7 @@ export default function AgentPage() {
<p> <p>
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。 每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
</p> </p>
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p> <p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
</div> </div>
</div> </div>
</div> </div>
+13 -13
View File
@@ -207,7 +207,7 @@ export default function SiteWorkbenchPage() {
</a> </a>
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null} {account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null} {health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null} {!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null} {circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null} {s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
</span> </span>
@@ -250,7 +250,7 @@ export default function SiteWorkbenchPage() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })} onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
> >
<Bot /> <Bot />
<span className="sr-only sm:not-sr-only">问 Agent</span> <span className="sr-only sm:not-sr-only">问 Agent</span>
@@ -292,7 +292,7 @@ export default function SiteWorkbenchPage() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!s?.enabled || snap.isPending} disabled={!s?.enabled || snap.isPending}
title="手动快照(受站点限流)" title="手动快照(受站点限流)"
onClick={() => onClick={() =>
snap.mutate( snap.mutate(
{ id: siteId, maxPages: 2 }, { id: siteId, maxPages: 2 },
@@ -309,7 +309,7 @@ export default function SiteWorkbenchPage() {
<Button <Button
size="sm" size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)} disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地快照,直接在站点搜索" : "先输入搜索词"} title={q ? "跳过本地快照,直接在站点搜索" : "先输入搜索词"}
onClick={() => { onClick={() => {
if (live) void releases.refetch(); if (live) void releases.refetch();
else setLive(true); else setLive(true);
@@ -376,17 +376,17 @@ export default function SiteWorkbenchPage() {
all.length all.length
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。" ? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
: coverage?.liveError : coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。` ? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q : q
? "点“实时补查”直接在站点搜索。" ? "点「实时补查」直接在站点搜索。"
: "点“快照”抓取站点列表。" : "点「快照」抓取站点列表。"
} }
/> />
) : ( ) : (
<> <>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span> <span>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""} 已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span> </span>
{freeCount ? <span>其中免费 {freeCount}</span> : null} {freeCount ? <span>其中免费 {freeCount}</span> : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null} {releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
@@ -469,17 +469,17 @@ function CoverageBanner({
refreshError: unknown; refreshError: unknown;
}) { }) {
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = []; const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` }); if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` });
if (coverage) { if (coverage) {
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" }); if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" });
if (coverage.stale) if (coverage.stale)
notes.push({ notes.push({
tone: "warning", tone: "warning",
text: coverage.lastSnapshotAt text: coverage.lastSnapshotAt
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)})` ? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "没有快照,列表只来自 Feed 和实时补查", : "没有快照,列表只来自 Feed 和实时补查",
}); });
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError}` }); if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError}` });
else if (coverage.live) else if (coverage.live)
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "已实时补查" }); notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "已实时补查" });
} }
+2 -2
View File
@@ -98,7 +98,7 @@ export default function SitesPage() {
<> <>
<PageHeader <PageHeader
title="站点" title="站点"
description="凭证、账号数据和健康状态;点站点名看资源、快照与 Feed" description="凭证、账号数据和健康状态;点站点名看资源、快照与 Feed"
actions={ actions={
<> <>
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}> <Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
@@ -155,7 +155,7 @@ export default function SitesPage() {
<EmptyState <EmptyState
icon={Globe} icon={Globe}
title="还没有站点" title="还没有站点"
description="添加站点并填写凭证后,这里显示账号数据和健康状态" description="添加站点并填写凭证后,这里显示账号数据和健康状态"
action={ action={
<Button size="sm" onClick={() => setAdding(true)}> <Button size="sm" onClick={() => setAdding(true)}>
<Plus /> <Plus />
@@ -179,7 +179,7 @@ export default function SubscriptionDetailPage() {
<ErrorState plain error={covQ.error} onRetry={() => covQ.refetch()} /> <ErrorState plain error={covQ.error} onRetry={() => covQ.refetch()} />
) : !cov || cov.seasons.length === 0 ? ( ) : !cov || cov.seasons.length === 0 ? (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。 还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。
</p> </p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -239,7 +239,7 @@ export default function SubscriptionDetailPage() {
item.ruleGroups.map((g) => g.name), item.ruleGroups.map((g) => g.name),
4, 4,
"未设置", "未设置",
)}${item.defaultRuleGroups && item.ruleGroups.length ? "(未单独设置)" : ""}` )}${item.defaultRuleGroups && item.ruleGroups.length ? "(未单独设置)" : ""}`
: null, : null,
}, },
{ label: "自动补全", value: item ? autoFillText(item.autoFill) : null }, { label: "自动补全", value: item ? autoFillText(item.autoFill) : null },
@@ -271,7 +271,7 @@ export default function SubscriptionDetailPage() {
/> />
</Panel> </Panel>
{sub.mediaEntityId ? ( {sub.mediaEntityId ? (
<Panel title="版本目标规则" description="条件、阶梯与升级;新建的版本目标排在最后"> <Panel title="版本目标规则" description="条件、阶梯与升级;新建的版本目标排在最后">
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} /> <SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
</Panel> </Panel>
) : null} ) : null}
@@ -393,7 +393,7 @@ export default function SubscriptionsPage() {
icon={CalendarClock} icon={CalendarClock}
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"} title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
description={ description={
filtered || v.group || v.kind ? undefined : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。" filtered || v.group || v.kind ? undefined : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。"
} }
/> />
) : grouped ? ( ) : grouped ? (
+1 -1
View File
@@ -83,7 +83,7 @@
"check:tokens": "cd apps/web && deno task check:tokens", "check:tokens": "cd apps/web && deno task check:tokens",
"check:copy": { "check:copy": {
"description": "文案标点检查(规范见 docs/design/glossary.md「标点」);--fix 自动修正,--strict 有违规即失败", "description": "文案标点检查(规范见 docs/design/glossary.md「标点」);--fix 自动修正,--strict 有违规即失败",
"command": "deno run -A scripts/check-copy-punctuation.mjs" "command": "deno run -A scripts/check-copy-punctuation.mjs --strict"
}, },
"build": "cd apps/web && deno task build && cd ../server && deno task compile", "build": "cd apps/web && deno task build && cd ../server && deno task compile",
"ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task check:copy && deno task build", "ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task check:copy && deno task build",
+1 -1
View File
@@ -371,7 +371,7 @@ function interactionHint(
summary: e.outcome === "chosen" ? `已选 ${e.choice}` : e.outcome === "rejected" ? "都不要" : "超时未选", summary: e.outcome === "chosen" ? `已选 ${e.choice}` : e.outcome === "rejected" ? "都不要" : "超时未选",
}; };
case "approval_requested": case "approval_requested":
return { capabilityId: e.capabilityId, phase: "started", summary: `等待审批:${e.summary}`.slice(0, 600) }; return { capabilityId: e.capabilityId, phase: "started", summary: `等待审批:${e.summary}`.slice(0, 600) };
case "approval_resolved": case "approval_resolved":
return { return {
capabilityId: "approval", capabilityId: "approval",
+1 -1
View File
@@ -330,7 +330,7 @@ export const AgentRunStartInput = z.object({
*/ */
proposeFirst: z.boolean().default(false), proposeFirst: z.boolean().default(false),
/** @deprecated 已取消运行模式;plan_only 按 proposeFirst=true 处理,execute 忽略 */ /** @deprecated 已取消运行模式;plan_only 按 proposeFirst=true 处理,execute 忽略 */
mode: AgentRunMode.optional().describe("已弃用:plan_only 等同 proposeFirst=true"), mode: AgentRunMode.optional().describe("已弃用:plan_only 等同 proposeFirst=true"),
/** 能力白名单(缺省全部可用能力) */ /** 能力白名单(缺省全部可用能力) */
capabilityAllowlist: z.array(KnownCapabilityId).min(1).max(50).optional(), capabilityAllowlist: z.array(KnownCapabilityId).min(1).max(50).optional(),
}); });
File diff suppressed because it is too large. Load diff
+23 -23
View File
@@ -132,7 +132,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"release.search": d( "release.search": d(
"release.search", "release.search",
"搜资源", "搜资源",
"先查本地资源索引与站点快照,不足时对已开启站点实时补查", "先查本地资源索引与站点快照,不足时对已开启站点实时补查",
"read", "read",
"ReleaseSearch", "ReleaseSearch",
"read", "read",
@@ -154,7 +154,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"identity.resolve": d( "identity.resolve": d(
"identity.resolve", "identity.resolve",
"识别身份", "识别身份",
"为观察/资源提出身份映射;证据充分时自动接受,有歧义时转为待处理事项", "为观察/资源提出身份映射;证据充分时自动接受,有歧义时转为待处理事项",
"propose", "propose",
"IdentityResolve", "IdentityResolve",
"organize.execute", "organize.execute",
@@ -165,7 +165,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"download.plan": d( "download.plan": d(
"download.plan", "download.plan",
"生成下载计划", "生成下载计划",
"评估候选,生成占用版本目标(slot)与预算预留的下载计划;不提交", "评估候选,生成占用版本目标(slot)与预算预留的下载计划;不提交",
"preview", "preview",
"DownloadPlan", "DownloadPlan",
"organize.execute", "organize.execute",
@@ -176,7 +176,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"download.submit": d( "download.submit": d(
"download.submit", "download.submit",
"提交下载", "提交下载",
"按已保存的计划提交到下载器;校验授权与硬阈值", "按已保存的计划提交到下载器;校验授权与硬阈值",
"execute", "execute",
"DownloadSubmit", "DownloadSubmit",
"organize.execute", "organize.execute",
@@ -187,7 +187,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"download.recheck": d( "download.recheck": d(
"download.recheck", "download.recheck",
"重新校验/恢复下载", "重新校验/恢复下载",
"对已纳管的下载任务强制重新校验(action=recheck),或恢复非阈值、非人工的暂停(action=resume);不换源、不删除、不改保存路径", "对已纳管的下载任务强制重新校验(action=recheck),或恢复非阈值、非人工的暂停(action=resume);不换源、不删除、不改保存路径",
"execute", "execute",
"DownloadRecheck", "DownloadRecheck",
"organize.execute", "organize.execute",
@@ -198,7 +198,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"organize.plan": d( "organize.plan": d(
"organize.plan", "organize.plan",
"生成整理计划", "生成整理计划",
"生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行", "生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行",
"preview", "preview",
"OrganizePlan", "OrganizePlan",
"organize.read", "organize.read",
@@ -209,7 +209,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"organize.execute": d( "organize.execute": d(
"organize.execute", "organize.execute",
"执行整理", "执行整理",
"执行已保存的整理计划(复制/移动),生成整理记录", "执行已保存的整理计划(复制/移动),生成整理记录",
"execute", "execute",
"OrganizeExecute", "OrganizeExecute",
"organize.execute", "organize.execute",
@@ -231,7 +231,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"task.retry": d( "task.retry": d(
"task.retry", "task.retry",
"重试任务", "重试任务",
"对失败/死信任务重新排队;副作用经操作账本去重", "对失败/死信任务重新排队;副作用经操作账本去重",
"execute", "execute",
"TaskRetry", "TaskRetry",
"organize.execute", "organize.execute",
@@ -242,7 +242,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"task.cancel": d( "task.cancel": d(
"task.cancel", "task.cancel",
"取消任务", "取消任务",
"停止未来步骤,不撤销已完成的外部动作", "停止未来步骤,不撤销已完成的外部动作",
"execute", "execute",
"TaskCancel", "TaskCancel",
"organize.execute", "organize.execute",
@@ -253,7 +253,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"policy.preview": d( "policy.preview": d(
"policy.preview", "policy.preview",
"预览策略", "预览策略",
"用历史数据回放策略草案,展示新增命中与误排除", "用历史数据回放策略草案,展示新增命中与误排除",
"preview", "preview",
"PolicyPreview", "PolicyPreview",
"read", "read",
@@ -264,7 +264,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"policy.apply": d( "policy.apply": d(
"policy.apply", "policy.apply",
"发布策略", "发布策略",
"发布策略版本(需授权);不直接执行 JavaScript", "发布策略版本(需授权);不直接执行 JavaScript",
"execute", "execute",
"PolicyApply", "PolicyApply",
"admin", "admin",
@@ -288,7 +288,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"agent.spawn": d( "agent.spawn": d(
"agent.spawn", "agent.spawn",
"派生子会话", "派生子会话",
"把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报", "把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报",
"propose", "propose",
"AgentSpawn", "AgentSpawn",
"organize.execute", "organize.execute",
@@ -299,7 +299,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"agent.await": d( "agent.await": d(
"agent.await", "agent.await",
"等待子会话", "等待子会话",
"等待本会话派生的子会话结束(等待期间不调用模型),返回各自状态、用量与汇报摘要;超时仍未结束的标为 pending", "等待本会话派生的子会话结束(等待期间不调用模型),返回各自状态、用量与汇报摘要;超时仍未结束的标为 pending",
"read", "read",
"AgentAwait", "AgentAwait",
"read", "read",
@@ -311,7 +311,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"site.list": d( "site.list": d(
"site.list", "site.list",
"列出站点", "列出站点",
"列出已登记站点的健康、登录状态与账号摘要(不含凭证)", "列出已登记站点的健康、登录状态与账号摘要(不含凭证)",
"read", "read",
"SiteList", "SiteList",
"read", "read",
@@ -323,7 +323,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"site.health_check": d( "site.health_check": d(
"site.health_check", "site.health_check",
"检测站点", "检测站点",
"检测站点首页连通与登录态,写回站点健康状态", "检测站点首页连通与登录态,写回站点健康状态",
"execute", "execute",
"SiteHealthCheck", "SiteHealthCheck",
"admin", "admin",
@@ -335,7 +335,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"site.refresh_login": d( "site.refresh_login": d(
"site.refresh_login", "site.refresh_login",
"刷新站点登录", "刷新站点登录",
"对站点账号重新登录或校验 Cookie / API Key,成功后拉取用户数据;需持续授权或审批", "对站点账号重新登录或校验 Cookie / API Key,成功后拉取用户数据;需持续授权或审批",
"execute", "execute",
"SiteLoginRefresh", "SiteLoginRefresh",
"admin", "admin",
@@ -346,7 +346,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"site.user_data": d( "site.user_data": d(
"site.user_data", "site.user_data",
"查站点用户数据", "查站点用户数据",
"读取站点账号的最新用户数据(上传、下载、分享率、魔力、做种)与按天历史", "读取站点账号的最新用户数据(上传、下载、分享率、魔力、做种)与按天历史",
"read", "read",
"SiteUserData", "SiteUserData",
"read", "read",
@@ -359,7 +359,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"browser.snapshot": d( "browser.snapshot": d(
"browser.snapshot", "browser.snapshot",
"读取原站页面", "读取原站页面",
"读取站点浏览器会话当前页面的结构化快照(可访问性文本 + eN 元素引用,凭证已遮蔽);控制权交还或导航后必须重新读取", "读取站点浏览器会话当前页面的结构化快照(可访问性文本 + eN 元素引用,凭证已遮蔽);控制权交还或导航后必须重新读取",
"read", "read",
"BrowserSnapshot", "BrowserSnapshot",
"read", "read",
@@ -370,7 +370,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"browser.navigate": d( "browser.navigate": d(
"browser.navigate", "browser.navigate",
"打开原站页面", "打开原站页面",
"在站点浏览器会话里打开站点域名内的地址(没有会话时按站点打开);受站点限流、超时与重试预算约束", "在站点浏览器会话里打开站点域名内的地址(没有会话时按站点打开);受站点限流、超时与重试预算约束",
"execute", "execute",
"BrowserNavigate", "BrowserNavigate",
"admin", "admin",
@@ -381,7 +381,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"browser.interact": d( "browser.interact": d(
"browser.interact", "browser.interact",
"操作原站页面", "操作原站页面",
"在站点浏览器会话里点击 / 输入 / 选择 / 滚动 / 按键;登录字段用 credential 由服务端注入凭证;验证码或二次认证用 request_human 转人工", "在站点浏览器会话里点击 / 输入 / 选择 / 滚动 / 按键;登录字段用 credential 由服务端注入凭证;验证码或二次认证用 request_human 转人工",
"execute", "execute",
"BrowserInteract", "BrowserInteract",
"admin", "admin",
@@ -392,7 +392,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"browser.download": d( "browser.download": d(
"browser.download", "browser.download",
"捕获原站下载", "捕获原站下载",
"捕获浏览器会话里的种子下载,交后端暂存并核验 infohash、匹配本地资源;提交下载仍用 download.plan / download.submit", "捕获浏览器会话里的种子下载,交后端暂存并核验 infohash、匹配本地资源;提交下载仍用 download.plan / download.submit",
"execute", "execute",
"BrowserDownload", "BrowserDownload",
"organize.execute", "organize.execute",
@@ -404,7 +404,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"agent.plan": d( "agent.plan": d(
"agent.plan", "agent.plan",
"更新步骤清单", "更新步骤清单",
"开始时列出 3–7 个步骤,之后每开始、完成、跳过或失败一步就更新(只传变化的步骤,按 id 合并;replace=true 整体替换)。步骤清单实时显示在会话页顶部", "开始时列出 3–7 个步骤,之后每开始、完成、跳过或失败一步就更新(只传变化的步骤,按 id 合并;replace=true 整体替换)。步骤清单实时显示在会话页顶部",
"read", "read",
"AgentPlan", "AgentPlan",
"read", "read",
@@ -417,7 +417,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"agent.propose_options": d( "agent.propose_options": d(
"agent.propose_options", "agent.propose_options",
"给出方案", "给出方案",
"有明显取舍时给用户 2–3 个方案(各写摘要、风险、估算代价,标出推荐项),等用户选定后返回所选方案;用户也可以都不要并说明原因。超时按设置自动选推荐项或停止", "有明显取舍时给用户 2–3 个方案(各写摘要、风险、估算代价,标出推荐项),等用户选定后返回所选方案;用户也可以都不要并说明原因。超时按设置自动选推荐项或停止",
"propose", "propose",
"AgentProposeOptions", "AgentProposeOptions",
"organize.execute", "organize.execute",
+1 -1
View File
@@ -807,7 +807,7 @@ export const RuleExpr = z
args: z.array(JsonObject).optional(), args: z.array(JsonObject).optional(),
}) })
.refine(isRuleExpr, { .refine(isRuleExpr, {
message: "条件表达式结构不对:{op:atom,id} / {op:not,arg} / {op:and|or,args[]}", message: "条件表达式结构不对:{op:atom,id} / {op:not,arg} / {op:and|or,args[]}",
}) as unknown as z.ZodType<RuleExpr>; }) as unknown as z.ZodType<RuleExpr>;
/** 优先级阶梯的一层;第 k 层(0 起)命中 pri = 100 - k */ /** 优先级阶梯的一层;第 k 层(0 起)命中 pri = 100 - k */