Merge branch 'deai2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	apps/server/src/modules/acquisition-organize/services/organize.ts
#	apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
root committed 2026-09-29 21:26:16 +08:00
commit 12bc4664b6
247 files changed
+2658 -2148

No files matched your search

@@ -72,7 +72,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
for (const dryRun of [true, false]) {
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("不在已登记的库根或下载器路径映射之下");
expect(r.body.error.message).toContain("不在已登记的媒体库目录或下载器路径映射之下");
}
// `..` 即使规范化后仍在根内也拒绝
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
@@ -77,7 +77,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
return { items } as never;
});
// AG-3:订阅摘要(分组计数 + 条目),版本目标只给总数 / 已满足数,细节用 inventory.query
// AG-3:订阅摘要(分组计数 + 条目),收录版本只给总数 / 已满足数,细节用 inventory.query
registry.register(capabilitySpec("subscription.list"), async (input) => {
const db = deps().ctx.db;
let q = db
@@ -253,7 +253,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
const d = deps();
const db = d.ctx.db;
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
if (!slot) throw new CapabilityError("not_found", "版本目标不存在");
if (!slot) throw new CapabilityError("not_found", "收录版本不存在");
const rel = await db
.selectFrom("source_release")
.select(["id", "media_entity_id"])
@@ -285,7 +285,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
if (rejected.length === 0)
rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
rejected.push({ code: "not_planned", message: "未生成计划:收录版本正忙或条件不满足" });
}
const fresh = await db
.selectFrom("acquisition_slot")
@@ -324,8 +324,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
.where("id", "=", plan.slot_id)
.forUpdate()
.executeTakeFirstOrThrow();
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
if (slot.revision + 1 !== plan.expected_revision) return "收录版本已变化,重新生成计划";
if (BUSY.includes(slot.status)) return `收录版本正忙(${slot.status})`;
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
st = transition(st, { type: "reserve" });
@@ -348,7 +348,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
return null;
})
.catch((err) => {
if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留";
if ((err as { code?: string }).code === "23505") return "收录版本已被其他计划预留";
throw err;
});
if (ok) throw new CapabilityError("conflict", ok);
@@ -110,7 +110,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.sync",
defaults: {
name: "qB 同步",
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
group: "download",
schedule: every(syncMs),
},
@@ -129,8 +129,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerScheduledJob(ctx, {
key: "cache.threshold",
defaults: {
name: "缓存阈值检查",
description: "下载缓存超过硬阈值时入队清理",
name: "下载盘检查",
description: "下载盘超过暂停线时清理已入库的下载",
group: "maintain",
schedule: every(planMs),
},
@@ -145,7 +145,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.orchestrate",
defaults: {
name: "补提交与补入库",
description: "补投已预留未提交的下载、已完成未入库的任务",
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
group: "download",
schedule: every(planMs),
},
@@ -159,7 +159,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "slots.reevaluate",
defaults: {
name: "到期重评",
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
description: "重新检查等待中的资源",
group: "download",
schedule: every(planMs),
},
@@ -169,7 +169,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "subscriptions.search",
defaults: {
name: "订阅周期搜索",
description: "到期的订阅主动搜索站点",
description: "到期的订阅去站点搜索",
group: "collect",
schedule: every(planMs),
},
@@ -190,8 +190,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerScheduledJob(ctx, {
key: "library.reconcile",
defaults: {
name: "资产对账",
description: "核对媒体库文件与资产记录",
name: "文件核对",
description: "检查媒体库里的文件是否还在",
group: "organize",
schedule: every(reconcileMs),
},
@@ -30,19 +30,19 @@ const READ_ONLY: ReadOnly[] = [
{
...Api.mediaAssets,
table: "media_asset",
what: "资产",
what: "文件",
filters: { mediaEntityId: "media_entity_id", status: "status" },
},
{
...Api.assetUnitCoverage,
table: "asset_unit_coverage",
what: "资产覆盖",
what: "文件覆盖",
filters: { mediaAssetId: "media_asset_id", contentUnitId: "content_unit_id" },
},
{
...Api.assetLocations,
table: "asset_location",
what: "资产位置",
what: "文件位置",
filters: { mediaAssetId: "media_asset_id", status: "status" },
},
{
@@ -143,7 +143,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
const notes: string[] = [];
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
if (dv.unmanagedGrowthRisk) notes.push("有外部任务仍在增长,暂停本工具的任务后磁盘仍会增长");
const pct = usedPercent(v);
volumes.push({
volumeKey: v.volumeKey,
@@ -110,7 +110,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
method: "post",
path: "/library/reconcile",
scope: "admin",
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
summary: "入队一轮资产对账(媒体库目录哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
body: z.object({ libraryRootId: z.uuid().optional() }),
response: accepted,
},
@@ -49,7 +49,7 @@ export function registerNamingRoutes(app: ModuleApp, d: AoDeps) {
};
const rootRow = async (id: string) => {
const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst();
if (!r) throw notFound("库根");
if (!r) throw notFound("媒体库目录");
return r;
};
const audit = (
@@ -209,7 +209,7 @@ async function loadSample(d: AoDeps, input: NamingPreviewInput): Promise<SampleS
.where("a.id", "=", s.assetId)
.orderBy("l.created_at", "desc")
.executeTakeFirst();
if (!asset) throw notFound("资产");
if (!asset) throw notFound("文件");
const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv";
const e = asset.media_entity_id
? await db.selectFrom("media_entity").selectAll().where("id", "=", asset.media_entity_id).executeTakeFirst()
@@ -244,7 +244,7 @@ async function preview(
let root: LibraryRootRow | null = null;
if (rootId) {
root = (await db.selectFrom("library_root").selectAll().where("id", "=", rootId).executeTakeFirst()) ?? null;
if (!root && input.libraryRootId) throw notFound("库根");
if (!root && input.libraryRootId) throw notFound("媒体库目录");
}
const eff = effectiveNaming(await namingSettings(), root, entity);
const template = input.template ?? eff.templates[kind];
@@ -91,11 +91,11 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
.where("active", "=", true)
.executeTakeFirst();
if (!sup) return;
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,智能体不能自动加回");
await liftSuppressions(trx, fingerprint, by);
}
/** 被版本目标 / 订阅显式引用的次数 */
/** 被收录版本 / 订阅显式引用的次数 */
async function referenceCount(trx: Trx, id: string): Promise<number> {
const r = await sql<{ n: number }>`
select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
@@ -117,7 +117,7 @@ async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly stri
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
}
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
/** 收录版本 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
export const assertRuleGroupIds = checkIdsExist;
const TRACKED_FIELDS = [
@@ -528,7 +528,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
.executeTakeFirst();
if (!cur) notFound("规则组");
const refs = await referenceCount(trx, cur!.id);
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
if (refs > 0) conflict(`规则组被 ${refs} 个收录版本或订阅引用,先解除引用`);
const row = await trx
.updateTable("rule_group")
.set({
@@ -597,7 +597,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
);
}
/** 规则组改动后重评引用它的版本目标(显式引用;默认链改动由周期重评覆盖) */
/** 规则组改动后重评引用它的收录版本(显式引用;默认链改动由周期重评覆盖) */
async function kickReevaluate(d: AoDeps, groupId: string) {
try {
const slots = await d.ctx.db
@@ -136,7 +136,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
Api.acquisitionSlots.get,
async (_c, { params }) => {
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<AcquisitionSlot>(r ?? notFound("版本目标"));
return toApi<AcquisitionSlot>(r ?? notFound("收录版本"));
},
{ tag: TAG },
);
@@ -171,7 +171,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
if (!r) conflict("该作品或内容单元下已有同名版本目标");
if (!r) conflict("该作品或内容单元下已有同名收录版本");
await kickEvaluate(d, { acquisitionSlotId: r!.id });
return toApi<AcquisitionSlot>(r!);
},
@@ -208,7 +208,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.select("revision")
.where("id", "=", params.id)
.executeTakeFirst();
if (!exists) notFound("版本目标");
if (!exists) notFound("收录版本");
conflict(`revision 不匹配(当前 ${exists!.revision})`);
}
await kickEvaluate(d, { acquisitionSlotId: params.id });
@@ -49,7 +49,7 @@ import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from
import { mount, paginate } from "./helpers.ts";
// W9-E 订阅 API(docs/design/subscriptions.md §1–§3、§5):总览(分组/排序/筛选)、覆盖矩阵、表单字段规范化、
// 暂停/恢复/重新订阅、批量、手动补全某版本、版本目标排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
// 暂停/恢复/重新订阅、批量、手动补全某版本、收录版本排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
const TAG = "subscriptions";
const OVERVIEW_MAX = 2000;
@@ -106,12 +106,12 @@ async function assertNoDuplicate(
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
}
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个收录版本(预设随后写入) */
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
/**
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
* 该订阅自己的版本目标;MP total_episode → 季总集数;best_version → 版本目标升级策略。
* 该订阅自己的收录版本;MP total_episode → 季总集数;best_version → 收录版本升级策略。
*/
async function applyDerived(
exec: Exec,
@@ -616,7 +616,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
// ── 版本目标:手动补全 / 排序 ──
// ── 收录版本:手动补全 / 排序 ──
mount(
app,
Api.subscriptions.fillSlot,
@@ -632,7 +632,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.execute()
: [];
const slot = slotsOfSub(slots, sub).find((s) => s.id === params.slotId);
if (!slot) notFound("版本目标");
if (!slot) notFound("收录版本");
invalidateCoverage(sub.id);
try {
return await d.ctx.engine.enqueue(
@@ -668,7 +668,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
new Set(want).size !== want.length ||
!want.every((id) => mine.some((s) => s.id === id))
)
throw new ApiException("bad_request", "slotIds 必须恰好是该订阅的全部版本目标");
throw new ApiException("bad_request", "slotIds 必须恰好是该订阅的全部收录版本");
for (const [i, id] of want.entries())
await trx.updateTable("acquisition_slot").set({ position: i }).where("id", "=", id).execute();
return (await trx.selectFrom("acquisition_slot").selectAll().where("id", "in", want).execute()).sort(
@@ -200,7 +200,7 @@ export async function createSlotsFromTemplates(
await exec
.insertInto("acquisition_slot")
.values({
// 同一事务建的版本目标 created_at 相同:按模板顺序给 position,第一个为主版本
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
position: i,
media_entity_id: input.entityId,
subscription_id: input.subscriptionId,
@@ -556,7 +556,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
}
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
const ctx = contextOf(rel, now);
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
// 规则组:收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
c.rules ??= loadRuleCatalog(db);
const chain = resolveRuleChain(await c.rules, {
kind: "acquisition",
@@ -568,7 +568,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
siteId: rel.site_id,
subscriptionId: sub?.id,
});
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入收录版本硬条件
spec.hard = mergeHard(spec.hard, chain.hard);
if (chain.groups.length) {
spec.rules = { groups: chain.groups, atoms: chain.atoms };
@@ -811,7 +811,7 @@ async function unitQuality(
exec: Exec,
slot: AcquisitionSlotRow,
keys: readonly string[],
/** 版本目标引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
/** 收录版本引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
priOf?: (assetId: string) => Promise<number | undefined>,
): Promise<Record<string, QualityFacts | undefined>> {
const rows = await exec
@@ -25,9 +25,9 @@ import { defaultChainIds, loadRuleCatalog, type RuleCatalog, resolveRuleChain }
import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
// 一批订阅一次取齐 版本目标 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
// 在内存里按版本目标算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
// 版本目标覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个版本目标时,作品下全部资产都算)。
// 一批订阅一次取齐 收录版本 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
// 在内存里按收录版本算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
// 收录版本覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个收录版本时,作品下全部资产都算)。
const MAX_EPISODES = 2000;
const COVERAGE_TTL_MS = 30_000;
@@ -111,11 +111,11 @@ export function subscriptionAutoFill(
};
}
/** 版本目标排序:position → created_at → id;第一个为主版本 */
/** 收录版本排序:position → created_at → id;第一个为主版本 */
export const slotOrder = (a: AcquisitionSlotRow, b: AcquisitionSlotRow) =>
a.position - b.position || a.created_at.getTime() - b.created_at.getTime() || a.id.localeCompare(b.id);
/** 订阅关联的版本目标:同作品、属于该订阅或不属于任何订阅 */
/** 订阅关联的收录版本:同作品、属于该订阅或不属于任何订阅 */
export const slotsOfSub = (all: readonly AcquisitionSlotRow[], sub: Pick<SubscriptionRow, "id" | "media_entity_id">) =>
all
.filter(
@@ -287,7 +287,7 @@ export async function buildSubscriptionModels(
if (p?.episode !== undefined || /^d\d{8}$/.test(k)) put(k, { assetId: id, quality: q, pri });
}
}
// 分集账本优先(质量与 pri 按版本目标记)
// 分集账本优先(质量与 pri 按收录版本记)
for (const r of ledger) {
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
if (r.kind === "episode" && r.episode_number !== null)
@@ -542,7 +542,7 @@ export async function buildSubscriptionModels(
upgrade: m.upgrade,
});
if (isMovie) {
// 电影:该版本目标不要求的内容版本不算缺
// 电影:该收录版本不要求的内容版本不算缺
for (const c of r.cells)
if (c.state === "missing" && !m.requiredEditions.includes(c.unitKey.slice("edition:".length))) {
c.state = "skipped";
@@ -106,7 +106,7 @@ export async function namingByVersion(exec: Exec, version: number): Promise<Nami
return { movie: t.movie, tv: t.tv, variety: t.variety, version: row.version };
}
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 版本目标名 */
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 收录版本名 */
export async function loadNamingEntity(
exec: Exec,
entity: MediaEntityRow,
@@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
}
if (!roots.some((root) => within(r, root)))
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下:${p}`, p);
throw new PathGuardError(`源路径不在已登记的媒体库目录或下载器路径映射之下:${p}`, p);
return r;
},
};
@@ -15,6 +15,7 @@ import {
statSafe,
} from "@umt/adapters";
import type { ActorType, OrganizationRecord, OrganizationTrigger, PathBinding } from "@umt/contracts";
import { reasonSentence, reasonText } from "@umt/contracts";
import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecordRow } from "@umt/db";
import { sql } from "@umt/db";
import {
@@ -415,7 +416,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {});
await decide_({
kind: "identity_ambiguous",
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
summary: takeoverSummary(takeover),
subjectType: "organization_record",
subjectId: rid.recordId,
@@ -500,8 +501,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({
kind: "other",
severity: "critical",
title: `库根离线:${root.name}`,
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
title: `媒体库目录离线:${root.name}`,
summary: "媒体库目录不可访问或未挂载,整理已暂停。文件不会因此被判为缺失",
subjectType: "library_root",
subjectId: root.id,
dedupeKey: `library_root_offline:${root.id}`,
@@ -759,9 +760,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const existsConflict = blocking.some((c) => c.kind === "target_exists");
await upsertDecision(d, trx, {
kind: hnrConflict ? "approval_required" : "path_conflict",
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
title: hnrConflict
? `做种义务未满足,已阻止移动:${entity.title}`
: `${reasonText(blocking[0]?.kind)?.text ?? "整理冲突"}:${entity.title}`,
summary: blocking
.map((c) => c.detail ?? c.kind)
.map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind)
.join(";")
.slice(0, 500),
subjectType: "organization_record",
@@ -911,7 +914,7 @@ export async function executeRecord(
const fsr = fsRuntime(d);
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
if (health.kind === "offline") throw new Error(`媒体库目录离线(${health.reason}),稍后重试`);
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
if (fsr.partialMaxAgeMs > 0)
@@ -1730,7 +1733,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
}
/** 关闭下载任务的"无法确定作品 / 整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
const rows = await trx
@@ -1857,7 +1860,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
.join(";");
const reason = (code: string | null, err: unknown) =>
code
? { code: code.split(":")[0]!, message: conflictText(err) || code }
? { code: code.split(":")[0]!, message: conflictText(err) || reasonSentence(code, code) }
: err
? {
code: (err as { code?: string }).code ?? "error",
@@ -35,7 +35,7 @@ import { ApiException } from "../../../http/errors.ts";
import { hashJson, toApi } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
// 规则组(W9-D):目录加载、引用链解析(版本目标 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
// 规则组(W9-D):目录加载、引用链解析(收录版本 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
export interface RuleCatalog {
/** 未删除的全部规则组(含停用,解析链时再过滤) */
@@ -132,17 +132,17 @@ export function defaultChainIds(cat: RuleCatalog, kind: RuleGroupKind): string[]
}
export interface ResolveInput extends RuleTarget {
/** 版本目标 rule_group_ids(非空时优先) */
/** 收录版本 rule_group_ids(非空时优先) */
slotRuleGroupIds?: readonly string[] | undefined;
/** 订阅 params.ruleGroupIds */
subRuleGroupIds?: readonly string[] | undefined;
kind: "acquisition" | "search";
/** 版本目标开了升级:叠加洗版默认链 */
/** 收录版本开了升级:叠加洗版默认链 */
upgrade?: boolean | undefined;
}
/**
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 引用链:订阅私有组 → (收录版本 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
*/
export function resolveRuleChain(
@@ -190,7 +190,7 @@ export function chainHard(rows: readonly Pick<RuleGroupRow, "hard">[]): SlotHard
return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) };
}
/** 版本目标硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
/** 收录版本硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions {
if (!extra.include?.length && !extra.exclude?.length) return base;
const out: SlotHardConditions = { ...base };
@@ -11,7 +11,7 @@ import { TRACKING_SUB_STATUSES } from "./subscriptions.ts";
// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date,
// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。
// 状态取主版本那一行的格(没有版本目标时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
// 状态取主版本那一行的格(没有收录版本时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。
// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。
@@ -8,7 +8,7 @@ import { INFLIGHT_PLAN_STATUSES } from "./subscriptions.ts";
// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。
// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled,
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉版本目标的延后记录,标记立即重评。
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉收录版本的延后记录,标记立即重评。
// 已入库资产、分集账本、总集数(含锁定)不动。
/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */
@@ -19,10 +19,10 @@ import { allOfAny } from "./rules.ts";
// total_episode (+ manual_total_episode) → 保存后写季总集数(manual 锁定)
// include / exclude(字符串或数组)→ include[] / exclude[](正则白名单校验)
// keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds
// best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片)
// best_version / best_version_full → 订阅收录版本的升级策略(按集 / 整季;电影 = 单片)
// autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay}
// resolution / quality(resource_type) / effect(MP 标签或正则)→ resolution / source / effect 质量预设
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的版本目标。
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的收录版本。
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -32,7 +32,7 @@ export interface NormalizedParams {
params: SubscriptionParams;
/** MP total_episode:保存后写入订阅季的总集数 */
episodeTotal?: { total: number; locked: boolean };
/** MP best_version:写到订阅版本目标的升级策略 */
/** MP best_version:写到订阅收录版本的升级策略 */
upgrade?: UpgradeStrategy;
/** 被改写的兼容字段(调试/审计用) */
mapped: string[];
@@ -141,7 +141,7 @@ export function parseMpEffect(v: string): SubscriptionEffect | null {
return null;
}
/** 质量预设 → 版本目标硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
/** 质量预设 → 收录版本硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
export interface PresetConditions {
hard: Record<string, unknown>;
soft: Record<string, unknown>;
@@ -214,8 +214,8 @@ export function presetConditions(p: Pick<SubscriptionParams, "resolution" | "sou
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
/**
* 质量预设写到该订阅自己的版本目标:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
* 只有当版本目标上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
* 质量预设写到该订阅自己的收录版本:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
* 只有当收录版本上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
*/
export async function applySlotPreset(
exec: Exec,
@@ -461,7 +461,7 @@ export async function normalizeSubscriptionParams(
// ── 库根存在性与正则白名单 ──
if (p.libraryRootId) {
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 pat of p[k] ?? []) {
@@ -481,7 +481,7 @@ export async function normalizeSubscriptionParams(
return out;
}
/** best_version 等 → 订阅版本目标的升级策略(保留其余升级参数;revision+1) */
/** best_version 等 → 订阅收录版本的升级策略(保留其余升级参数;revision+1) */
export async function applySlotUpgrade(
exec: Exec,
input: { subscriptionId: string; entityId: string; strategy: UpgradeStrategy },
@@ -709,7 +709,7 @@ export async function sweepMetadataRefresh(d: AoDeps): Promise<number> {
}
/**
* 订阅指定的库根(params.libraryRootId):下载计划所属版本目标的订阅优先,否则取该作品最近的未取消订阅。
* 订阅指定的库根(params.libraryRootId):下载计划所属收录版本的订阅优先,否则取该作品最近的未取消订阅。
* 整理时只在作品没有路径绑定时使用(首次建绑定)。
*/
export async function subscriptionLibraryRoot(
+2 -2
View File
@@ -204,13 +204,13 @@ export function createAuthorizer(deps: {
if (d.id === "agent.spawn") {
return runId && (ctx.source === "agent" || ctx.source === "task")
? { allowed: true }
: { allowed: false, reason: "只有 Agent 会话可以派生子会话" };
: { allowed: false, reason: "只有智能体会话可以派生子会话" };
}
// agent.propose_options 只登记一个待用户选择的事项(无外部副作用);能否问用户由处理器校验
if (d.id === "agent.propose_options") {
return runId && (ctx.source === "agent" || ctx.source === "task")
? { allowed: true }
: { allowed: false, reason: "只有 Agent 会话可以让用户选方案" };
: { allowed: false, reason: "只有智能体会话可以让用户选方案" };
}
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
if (d.id === "metadata.enrich") return { allowed: true };
@@ -62,7 +62,7 @@ export function registerDownloadRecoveryCapabilities(
.executeTakeFirst();
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
if (!dt.managed || dt.removed_at || dt.status === "removed")
throw new CapabilityError("precondition", "未纳管或已移除的下载任务不做自动恢复");
throw new CapabilityError("precondition", "外部或已移除的下载任务不做自动恢复");
const status = dt.status as DownloadStatus;
if (input.action === "recheck" && !RECHECK_FROM.includes(status))
throw new CapabilityError("precondition", `当前状态 ${status} 不需要重新校验`);
@@ -59,7 +59,8 @@ export async function upstreamComplete(
sessionId: `umt-enrich-${randomUUID()}`,
},
);
if (res.stopReason === "error" || res.stopReason === "aborted") throw new Error(res.errorMessage ?? "上游返回错误");
if (res.stopReason === "error" || res.stopReason === "aborted")
throw new Error(res.errorMessage ?? "模型服务返回错误");
const text = res.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
@@ -87,7 +88,7 @@ export function registerMetadataCapabilities(
const agent = await ctx.settings.get("agent").catch(() => null);
const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) };
if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search"))
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → Agent → 网页搜索里开启后再补全");
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启后再补全");
// 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖
const sub = (scope: string) => ({
actorId: scope,
+2 -2
View File
@@ -149,7 +149,7 @@ async function solveChallenge(env: AgentEnv, registry: CapabilityRegistry, input
reason = "页面要求二次验证 / 短信 / 本人设备确认,不自动处理";
} else if (r.kind === "no_vision") {
status = "needs_human";
reason = "当前 Agent 上游模型不支持图片输入,不能做视觉自动化;换支持视觉的模型或人工处理";
reason = "当前模型不支持图片输入,不能做视觉自动化;换支持视觉的模型或人工处理";
} else if (r.kind === "no_upstream") {
status = "needs_human";
reason = r.message;
@@ -230,7 +230,7 @@ async function runChild(
childRunId: null,
steps: 0,
outcome: null,
message: "未配置 Agent 上游,不能做视觉自动化",
message: "未配置模型服务,不能做视觉自动化",
};
const fake = upstream.row.provider === "fake";
const s = await ctx.settings.all();
@@ -79,7 +79,7 @@ export const DEMO_SCRIPTS: FakeScript[] = [
finalText: (results) => {
const r = results.find((x) => x.capabilityId === "inventory.query");
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置模型服务。`;
},
},
];
+4 -4
View File
@@ -76,9 +76,9 @@ const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-
async function runOf(ctx: ServerContext, c: HandlerContext | InvokeContext): Promise<AgentRunRow> {
const id = c.agentRunId && isUuid(c.agentRunId) ? c.agentRunId : null;
if (!id) throw new CapabilityError("precondition", "只有 Agent 会话可以使用这个能力");
if (!id) throw new CapabilityError("precondition", "只有智能体会话可以使用这个能力");
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", id).executeTakeFirst();
if (!run) throw new CapabilityError("not_found", "Agent 会话不存在");
if (!run) throw new CapabilityError("not_found", "智能体会话不存在");
return run;
}
@@ -405,7 +405,7 @@ export async function inlineApproval(
const summary = await planSummary(db, plan.planType, plan.planId);
const decision = await insertDecision(sctx, {
kind: "approval_required",
title: `Agent 请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
title: `智能体请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
summary,
runId,
dedupeKey: `${APPROVAL_KEY}:${runId}:${inv}`,
@@ -625,7 +625,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
const stepId = await currentStepId(ctx, live, run.id);
d = await insertDecision(ctx, {
kind: "other",
title: `Agent 等你选方案:${clip(input.question, 80)}`,
title: `智能体等你选方案:${clip(input.question, 80)}`,
summary: input.options.map((o) => `${o.id}. ${o.title}`).join(";"),
runId: run.id,
dedupeKey,
+5 -5
View File
@@ -139,7 +139,7 @@ export function registerAgentRoutes(
route(app, Api.agentRuns.get, async ({ params, query }) => {
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", "智能体运行不存在");
const events = await db()
.selectFrom("agent_run_event")
.selectAll()
@@ -222,9 +222,9 @@ export function registerAgentRoutes(
route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => {
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", "智能体运行不存在");
if (body.optionId === null && !body.reason?.trim())
throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案");
throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案");
const r = await answerOptions(ctx, run.id, body, actorId);
if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
@@ -427,7 +427,7 @@ export function registerAgentRoutes(
let slotIds: string[];
if (body.slotId) {
const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst();
if (!s) throw new ApiException("not_found", "版本目标不存在");
if (!s) throw new ApiException("not_found", "收录版本不存在");
slotIds = [s.id];
} else {
const entityId = body.entityId ?? rel.media_entity_id;
@@ -444,7 +444,7 @@ export function registerAgentRoutes(
}
const rejected: { slotId: string | null; code: string; message: string }[] = [];
if (slotIds.length === 0)
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" });
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有收录版本,先在订阅里新建" });
const invokeCtx = {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
+4 -4
View File
@@ -288,7 +288,7 @@ function systemPromptFor(
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
const opts = sessionOptions(run);
return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化智能体",
goal: run.goal,
constraints,
// 自动化优先:用户会话默认执行,先出步骤清单;有取舍时给方案;"先看方案再执行"是会话选项
@@ -326,8 +326,8 @@ async function dailyBudgetBlock(env: AgentEnv, s: GlobalSettings, run: AgentRunR
from agent_run where started_at >= ${day} and id <> ${run.id}`.execute(env.ctx.db);
const n = r.rows[0]?.n ?? 0;
const cost = r.rows[0]?.cost ?? 0;
if (n >= s.budget.agentMaxRunsPerDay) return `今日 Agent 运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`;
if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日 Agent 费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`;
if (n >= s.budget.agentMaxRunsPerDay) return `今日智能体运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`;
if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日智能体费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`;
return null;
}
@@ -357,7 +357,7 @@ export async function drive(
let settings: AgentSettings;
try {
const resolved = await resolveRunUpstream(ctx, run.upstream_id);
if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
if (!resolved) throw new Error("未配置模型服务(设置 → 智能体 → 模型服务)");
upstream = resolved;
fake = upstream.row.provider === "fake";
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
+3 -3
View File
@@ -152,7 +152,7 @@ async function enqueueRun(ctx: ServerContext, run: AgentRunRow, key: string) {
/** 追加用户消息:运行中由当前会话在本轮结束后消费;已结束/等待中则唤醒续跑(新的 runAgent run) */
export async function appendMessage(ctx: ServerContext, runId: string, text: string, by: string): Promise<AgentRunRow> {
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirst();
if (!run) throw new AgentRunError("not_found", "Agent 运行不存在");
if (!run) throw new AgentRunError("not_found", "智能体运行不存在");
const msg = await ctx.db
.insertInto("agent_run_message")
.values({ run_id: runId, text, created_by: by })
@@ -173,7 +173,7 @@ export async function appendMessage(ctx: ServerContext, runId: string, text: str
/** 中止:只写取消标志,由会话监视器中止模型并走 finalize(不取消引擎 run,保证终态落库) */
export async function abortRun(ctx: ServerContext, runId: string, reason: string | undefined, by: string) {
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirst();
if (!run) throw new AgentRunError("not_found", "Agent 运行不存在");
if (!run) throw new AgentRunError("not_found", "智能体运行不存在");
if (TERMINAL.includes(run.status)) return run;
const now = new Date();
const row = await ctx.db
@@ -353,7 +353,7 @@ export async function finalize(
// 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建
await writeDecision(ctx, {
kind: "other",
title: "Agent 运行超出预算",
title: "智能体运行超出预算",
summary: res.message ?? "超出预算",
subjectId: runId,
dedupeKey: await budgetDedupeKey(ctx, run),
+3 -3
View File
@@ -707,9 +707,9 @@ export interface SpawnDeps {
async function parentOf(ctx: ServerContext, c: HandlerContext): Promise<AgentRunRow> {
const id = c.agentRunId;
if (!id) throw new CapabilityError("precondition", "只有 Agent 会话可以派生 / 等待子会话");
if (!id) throw new CapabilityError("precondition", "只有智能体会话可以派生 / 等待子会话");
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", id).executeTakeFirst();
if (!run) throw new CapabilityError("not_found", "Agent 会话不存在");
if (!run) throw new CapabilityError("not_found", "智能体会话不存在");
return run;
}
@@ -719,7 +719,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
const parent = await parentOf(ctx, c);
const s = await ctx.settings.all();
const cfg = subagentSettings(s);
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)");
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → 智能体 → 允许子会话)");
if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
@@ -52,7 +52,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
key: "auth.cleanup",
defaults: {
name: "登录会话清理",
description: "删除 7 天前的登录尝试和 30 天前过期或吊销的会话",
description: "删除 7 天前的登录尝试和 30 天前失效的会话",
group: "maintain",
schedule: { type: "interval", everyMs },
},
@@ -63,7 +63,7 @@ const test = (path: string, summary: string, tag: string) => ({
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
const NOUN: Record<Table, string> = {
library_root: "库根",
library_root: "媒体库目录",
downloader_instance: "下载器实例",
media_server_instance: "媒体服务器实例",
path_mapping: "路径映射",
@@ -72,7 +72,7 @@ const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"]
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
const LR = crudDefs("/library-roots", "库根", {
const LR = crudDefs("/library-roots", "媒体库目录", {
row: LibraryRoot,
create: LibraryRootCreate,
update: LibraryRootUpdate,
@@ -182,7 +182,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
} catch (e) {
throw new ApiException(
"precondition_failed",
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
`媒体库目录不可写(需要目录存在且可写入标记文件 ${name}):${errMessage(e)}`,
);
}
return name;
@@ -216,7 +216,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
endpoint(
app,
test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"),
test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 哨兵文件)", "library-roots"),
async ({ params, actor }) => {
const row = await getRow("library_root", params.id);
const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
@@ -149,7 +149,7 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
expect(files).toHaveLength(1);
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
// 识别到作品后再下载:走同一流程(无版本目标 → no_slot);同一文件第二次捕获标 duplicate、不提交
// 识别到作品后再下载:走同一流程(无收录版本 → no_slot);同一文件第二次捕获标 duplicate、不提交
const entity = await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
+2 -2
View File
@@ -1579,7 +1579,7 @@ export class BrowserService {
const give = opts.wantImage && !blocked && (!sensitive || opts.sensitiveAllowed);
let note: string | null = null;
if (blocked) note = "页面要求二次验证 / 短信 / 本人设备确认:不自动处理,会话已转等待处理,由人在原站视图接管";
else if (!opts.wantImage) note = "当前上游模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
else if (!opts.wantImage) note = "当前模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
else if (sensitive && !opts.sensitiveAllowed)
note =
"登录页 / 含密码框的截图属于敏感截图,默认不交给模型(需要 browser.sensitive_screenshot 持续授权并传 allowSensitive);下面是已脱敏的文字快照";
@@ -1801,7 +1801,7 @@ const REASON_TEXT: Record<string, string> = {
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "人工接管超时未交还",
agent_request: "Agent 请求人工处理",
agent_request: "智能体请求人工处理",
human_released_waiting: "人工交还时选择继续等待",
};
+3 -3
View File
@@ -183,7 +183,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
key: "cache.sweep",
defaults: {
name: "缓存清理",
description: "删除 PG 缓存里已过期的条目(TMDB 请求与识别缓存)",
description: "删除过期的 TMDB 与识别缓存",
group: "maintain",
schedule: { type: "interval", everyMs: 10 * 60_000 },
firstRun: "after_interval",
@@ -194,7 +194,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
key: "cache.images_clean",
defaults: {
name: "图片缓存清理",
description: "删除超过保留天数的图片代理缓存文件(对照 MP 启动时的 clear_temp)",
description: "删除过期的海报缓存",
group: "maintain",
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
},
@@ -208,7 +208,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
key: "cache.images_lru",
defaults: {
name: "图片缓存容量控制",
description: "图片代理缓存超过容量预算(存储设置,缺省 20 GB)时,按最久未访问删除到预算的 90%",
description: "海报缓存超过容量上限时,删掉最久没用的",
group: "maintain",
schedule: { type: "interval", everyMs: 30 * 60_000 },
firstRun: "after_interval",
@@ -231,7 +231,7 @@ export async function organizeFiles(
if (it.targetDir) {
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录:${it.targetDir.path || "/"}`);
throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`);
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
targetDir = join(t.def.hostPath, t.rel);
@@ -264,10 +264,10 @@ export async function organizeFiles(
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== entityId) {
warnings.push(`${r.rel}:版本目标 ${slotId} 不属于该作品,未应用`);
warnings.push(`${r.rel}:收录版本 ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}:版本目标正在获取中(${s.status}),不改其状态`);
warnings.push(`${r.rel}:收录版本正在获取中(${s.status}),不改其状态`);
slotId = null;
}
}
@@ -120,7 +120,7 @@ export async function scrape(
.where("media_entity_id", "=", e.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
if (!registered) throw new FilesError("bad_request", "作品没有已入库文件。先识别并整理入库,再刮削");
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: e.id },
@@ -95,7 +95,7 @@ export async function transfer(
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录`);
const sources: Resolved[] = [];
for (const it of body.items) {
@@ -157,7 +157,7 @@ const BLOCK_ERR: Record<
> = {
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
needs_retire: ["needs_retire", "含已登记资产,需勾选「同时退役资产」"],
needs_retire: ["needs_retire", "含已入库文件,需勾选「同时从媒体库移除」"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
root_unavailable: ["root_unavailable", "根不可用"],
not_found: ["not_found", "不存在"],
@@ -276,8 +276,8 @@ export async function deleteFiles(
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `版本目标的文件已删除:${s.name}`,
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
title: `收录版本的文件已删除:${s.name}`,
summary: "满足该收录版本的文件已在文件管理中删除,收录版本改为需关注。确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
@@ -509,7 +509,7 @@ export async function restoreTrash(
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
if (target && !(await isWritableTarget(target.def, target.rootReal)))
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 可写的媒体库目录`);
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
+1 -1
View File
@@ -21,7 +21,7 @@ export const MCP_RESOURCES: readonly McpResourceDef[] = [
uri: "umt://settings/summary",
name: "settings-summary",
title: "设置概要",
description: "自动化总开关、只读对账、预算与 Agent 运行上限。不含凭证。",
description: "自动化总开关、只读对账、预算与智能体运行上限。不含凭证。",
mimeType: "application/json",
async load(ctx) {
const s = await ctx.settings.all();
+1 -1
View File
@@ -48,7 +48,7 @@ export function createMcpConfigStore(db: () => Kysely<DB>): McpConfigStore {
/** 这个 Key 能不能连 MCP:总开关开着,且在允许列表里(列表为 null 表示不限) */
export function keyAllowed(cfg: McpServerConfig, principal: { kind: string; id: string }): string | null {
if (!cfg.enabled) return "MCP 服务端已停用。在设置 → Agent → 工具与 MCP 里启用";
if (!cfg.enabled) return "MCP 服务端已停用。在设置 → 智能体 → 工具与 MCP 里启用";
if (principal.kind === "api_key" && cfg.allowedApiKeyIds && !cfg.allowedApiKeyIds.includes(principal.id)) {
return "这个 API Key 不在 MCP 允许列表里";
}
+1 -1
View File
@@ -102,7 +102,7 @@ export function createMcpServer(
prompts: { listChanged: false },
},
instructions:
"工具与控制台、内置 Agent 共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
"工具与控制台、内置智能体共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
},
);
const limiter = sharedInvokeLimiter();
+2 -2
View File
@@ -73,8 +73,8 @@ export function createPiModule(
registerScheduledJob(ctx, {
key: "pi.token_refresh",
defaults: {
name: "上游登录刷新",
description: "订阅登录即将过期的 Agent 上游,提前刷新 token",
name: "模型服务登录刷新",
description: "订阅登录快过期时提前续期",
group: "maintain",
schedule: { type: "interval", everyMs: interval },
},
+4 -4
View File
@@ -92,12 +92,12 @@ function keyOf(cred: UpstreamCredential | undefined): string | null {
/** 按 provider / 认证方式挑模型列表接口 */
function planFor(spec: UpstreamSpec, cred: UpstreamCredential | undefined): Plan {
const extra = spec.extra ?? {};
if (spec.provider === "fake") return { kind: "unsupported", reason: "Fake 上游没有远程模型列表" };
if (spec.provider === "fake") return { kind: "unsupported", reason: "Fake 模型服务没有远程模型列表" };
if (spec.provider === "radius") return { kind: "pi" };
const custom = spec.provider === "openai-compatible";
// 订阅登录只有 OpenRouter 换到的是普通 API Key,其它订阅 token 不能调公开的模型列表接口
if (spec.authKind === "oauth" && spec.provider !== "openrouter")
return { kind: "unsupported", reason: "订阅登录的上游没有公开的模型列表接口,用内置模型表" };
return { kind: "unsupported", reason: "订阅登录的模型服务没有公开的模型列表接口,用内置模型表" };
const key = keyOf(cred);
if (spec.authKind !== "none" && !key && !custom) throw new FetchError("auth", "还没有配置 API Key");
@@ -232,7 +232,7 @@ function normalizeGoogle(item: Record<string, unknown>): Remote | null {
function parseList(shape: Shape, body: unknown): Remote[] {
const b = obj(body);
const raw = Array.isArray(body) ? body : Array.isArray(b.data) ? b.data : Array.isArray(b.models) ? b.models : null;
if (!raw) throw new FetchError("other", "上游返回的不是模型列表");
if (!raw) throw new FetchError("other", "模型服务返回的不是模型列表");
const norm = shape === "anthropic" ? normalizeAnthropic : shape === "google" ? normalizeGoogle : normalizeOpenAI;
const out: Remote[] = [];
const seen = new Set<string>();
@@ -376,7 +376,7 @@ async function fetchRemote(
if (plan.kind === "unsupported") throw new FetchError("unsupported", plan.reason);
if (plan.kind === "http") return await fetchList(plan.endpoint);
// Radius:pi-ai 自带的动态刷新
if (!client) throw new FetchError("config", "上游配置不完整");
if (!client) throw new FetchError("config", "模型服务配置不完整");
const r = await client.listModels({ refresh: true });
if (!r.refreshed) throw toFetchError(new Error(r.refreshError ?? "刷新失败"));
return r.items.map(fromInfo);
+3 -3
View File
@@ -105,8 +105,8 @@ export class OAuthSessions {
async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> {
const info = providerInfo(row.provider);
if (row.auth_kind !== "oauth" || !info?.oauth)
throw new ApiException("precondition_failed", "这个上游不是订阅登录类型");
if (!row.enabled) throw new ApiException("precondition_failed", "上游已停用");
throw new ApiException("precondition_failed", "这个模型服务不是订阅登录类型");
if (!row.enabled) throw new ApiException("precondition_failed", "模型服务已停用");
const mode = req.mode ?? info.oauth.modes[0]!;
if (!info.oauth.modes.includes(mode))
throw new ApiException(
@@ -270,6 +270,6 @@ export class OAuthSessions {
export async function requireRow(ctx: ServerContext, id: string): Promise<PiUpstreamRow> {
const row = await getRow(ctx, id);
if (!row) throw new ApiException("not_found", "上游不存在");
if (!row) throw new ApiException("not_found", "模型服务不存在");
return row;
}
+3 -3
View File
@@ -24,13 +24,13 @@ const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
const title = `模型服务「${row.name}」登录已失效,需要重新登录`;
await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
values ('other', 'warning', ${title}, ${message}, 'pi_upstream', ${row.id}, ${DECISION_KEY(row.id)},
${JSON.stringify({ provider: row.provider, upstreamId: row.id, error: message })}::jsonb,
${JSON.stringify([{ key: "relogin", label: "重新登录", description: "设置 → Agent → 上游" }])}::jsonb)
${JSON.stringify([{ key: "relogin", label: "重新登录", description: "设置 → 智能体 → 模型服务" }])}::jsonb)
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
updated_at = now()
@@ -102,7 +102,7 @@ export async function refreshPiTokens(ctx: ServerContext, opts: RefreshOptions =
if (r.ok) report.refreshed += 1;
else {
report.failed.push({ upstreamId: row.id, message: r.message });
ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message: r.message }, "Agent 上游 token 刷新失败");
ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message: r.message }, "模型服务 token 刷新失败");
}
}
return report;
+8 -7
View File
@@ -116,7 +116,7 @@ export async function createUpstream(
.execute();
} catch (err) {
if (secretRef) await ctx.secrets.remove(secretRef).catch(() => {});
if (isUnique(err)) throw new ApiException("conflict", `上游名称「${body.name}」已存在`);
if (isUnique(err)) throw new ApiException("conflict", `模型服务名称「${body.name}」已存在`);
throw err;
}
if (body.makeDefault || !(await defaultUpstreamId(ctx))) await setDefaultUpstream(ctx, id);
@@ -140,7 +140,7 @@ export async function updateUpstream(
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
validateShape({ provider: row.provider, authKind, baseUrl, defaultModel });
if (body.apiKey && authKind !== "api_key")
throw new ApiException("validation_failed", "只有 api_key 型上游可以填 apiKey");
throw new ApiException("validation_failed", "只有 api_key 型模型服务可以填 apiKey");
const kindChanged = authKind !== row.auth_kind;
const store = dbCredentialStore(ctx, row.id);
@@ -167,7 +167,7 @@ export async function updateUpstream(
.where("id", "=", row.id)
.execute()
.catch((err) => {
if (isUnique(err)) throw new ApiException("conflict", `上游名称「${body.name}」已存在`);
if (isUnique(err)) throw new ApiException("conflict", `模型服务名称「${body.name}」已存在`);
throw err;
});
if (typeof body.apiKey === "string") {
@@ -217,10 +217,11 @@ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string
const id = upstreamId ?? (await defaultUpstreamId(ctx));
if (!id) return null;
const row = await getRow(ctx, id);
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
if (!row.enabled) throw new Error(`上游「${row.name}」已停用`);
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
if (!row)
throw new Error(upstreamId ? `指定的模型服务 ${upstreamId} 不存在` : "默认模型服务已不存在,在设置里重新选择");
if (!row.enabled) throw new Error(`模型服务「${row.name}」已停用`);
if (row.status === "expired") throw new Error(`模型服务「${row.name}」登录已失效,在设置里重新登录`);
if (row.status === "auth_required")
throw new Error(`上游「${row.name}」${row.auth_kind === "oauth" ? "未登录" : "未配置 API Key"}`);
throw new Error(`模型服务「${row.name}」${row.auth_kind === "oauth" ? "未登录" : "未配置 API Key"}`);
return { row, spec: { ...specOf(row), extra: parseExtra(row.extra) }, credentials: dbCredentialStore(ctx, row.id) };
}
+2 -2
View File
@@ -59,7 +59,7 @@ async function readSecret(ctx: Ctx, row: Pick<PiUpstreamRow, "auth_kind" | "secr
try {
return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref));
} catch (err) {
ctx.logger.warn({ secretRef: row.secret_ref, err: { message: (err as Error).message } }, "上游凭证读取失败");
ctx.logger.warn({ secretRef: row.secret_ref, err: { message: (err as Error).message } }, "模型服务凭证读取失败");
return undefined;
}
}
@@ -85,7 +85,7 @@ export function dbCredentialStore(ctx: Ctx, upstreamId: string): UpstreamCredent
.where("id", "=", upstreamId)
.forUpdate()
.executeTakeFirst();
if (!row) throw new Error("上游不存在或已删除");
if (!row) throw new Error("模型服务不存在或已删除");
const cur = await readSecret(ctx, row);
const next = await fn(cur);
if (next === undefined) return cur;
@@ -114,7 +114,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
(cfg.triggerCondition === "stalled_downloads" && f.stalledDownloads === 0) ||
(cfg.triggerCondition === "subscriptions_active" && f.activeSubscriptions === 0);
// 手动执行不看触发条件
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒 Agent" };
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒智能体" };
let mode: "execute" | "plan_only" = "plan_only";
let allowlist = cfg.capabilityAllowlist.filter(isPlanOnly);
@@ -138,7 +138,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
budget: cfg.budget,
});
return {
summary: `${created ? "已入队" : "本时段已有"} Agent 运行(${mode === "execute" ? "授权内恢复" : "只读"})${note}`,
summary: `${created ? "已入队" : "本时段已有"} 智能体运行(${mode === "execute" ? "授权内恢复" : "只读"})${note}`,
agentRunId: run.id,
};
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
key: "maintain.backup",
defaults: {
name: "备份",
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
description: "数据库与数据目录备份,目前在服务器上用命令执行",
group: "maintain",
schedule: { type: "cron", cron: "30 3 * * *" },
enabled: false,
@@ -296,7 +296,7 @@ export class Scheduler {
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
limit 50`.execute(this.db);
for (const r of rows.rows) {
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
const summary = (r.summary ?? "").trim() || `智能体运行结束:${r.status}`;
await this.db
.updateTable("scheduled_job_run")
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
+3 -3
View File
@@ -23,7 +23,7 @@ const action = (id: string, group: string, title: string, href: string, keywords
export const SEARCH_ACTIONS: readonly SearchAction[] = [
page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]),
page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]),
page("agent", "概览", "Agent 会话", "/console/agent", ["agent", "会话"]),
page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]),
page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]),
page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]),
page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]),
@@ -34,13 +34,13 @@ export const SEARCH_ACTIONS: readonly SearchAction[] = [
page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]),
page("files", "整理", "文件管理", "/console/files", ["files", "文件"]),
page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]),
page("library", "整理", "媒体概览", "/console/library", ["library", "媒体库", "作品"]),
page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]),
page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]),
page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]),
page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]),
page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]),
action("new-subscription", "订阅", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]),
action("new-agent-run", "概览", "新建 Agent 会话", "/console/agent?new=1", ["agent", "委托"]),
action("new-agent-run", "概览", "新建智能体会话", "/console/agent?new=1", ["agent", "委托"]),
action("add-site", "资源", "添加站点", "/console/sites?new=1", ["site", "站点"]),
action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]),
action("new-schedule", "概览", "新建巡检", "/console/tasks?tab=plan&new=1", ["巡检", "agent_check"]),
+1 -1
View File
@@ -82,7 +82,7 @@ export async function localInfo(ctx: ServerContext, ids: string[]): Promise<Map<
return out;
}
/** 各作品版本目标按状态计数 */
/** 各作品收录版本按状态计数 */
export async function slotCountsFor(ctx: ServerContext, ids: string[]): Promise<Map<string, SlotStatusCounts>> {
const out = new Map<string, SlotStatusCounts>();
if (ids.length === 0) return out;
@@ -61,15 +61,15 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
return { summary: `${await fn(rt)} 项` };
},
});
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
registerScheduledJob(ctx, {
key: "sites.user_data",
defaults: {
name: "站点数据刷新",
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
description: "刷新站点账号的上传、下载和分享率;登录失效的账密账号自动重登一次",
group: "collect",
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
firstRun: "after_interval",
@@ -83,7 +83,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: "sites.health_check",
defaults: {
name: "站点健康检测",
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
description: "检查到期的站点能否打开、是否还在登录(每个站点按自己的检测间隔),结果在站点页",
group: "collect",
schedule: { type: "interval", everyMs: 3_600_000 },
firstRun: "after_interval",
@@ -95,8 +95,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: BACKFILL_LANGUAGES_JOB,
defaults: {
name: "元数据语言回刷",
description:
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行",
group: "maintain",
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
firstRun: "now",
@@ -79,7 +79,7 @@ export function enrichRoutes(app: ModuleApp, rt: () => SourcesRuntime): void {
path: "/identity-pool/{id}/enrich/apply",
scope: "admin",
auth: "session",
summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联待识别资源(只接受控制台管理员会话)",
summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联未识别资源(只接受控制台管理员会话)",
tags: T_ID,
params: IdParam,
body: EnrichApplyInput,
@@ -546,7 +546,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/identity-pool",
scope: "read",
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
summary: "未识别资源(可按原始标题检索;候选不建正式作品)",
tags: T_ID,
query: IdentityPoolQuery,
response: cursorPage(IdentityPoolItem),
@@ -623,7 +623,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/identity-pool/{id}/assign",
scope: "organize.execute",
summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份",
summary: "为未识别资源手动指定候选 / 本地作品 / 外部身份",
tags: T_ID,
params: IdParam,
body: ReleaseIdentityAssign,
@@ -635,7 +635,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.select(["source_release_id"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
return assignReleaseIdentity(rt(), pool.source_release_id, body, actor);
},
);
@@ -645,7 +645,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/identity-pool/{id}/dismiss",
scope: "organize.execute",
summary: "忽略待识别资源(不再自动复查)",
summary: "忽略未识别资源(不再自动复查)",
tags: T_ID,
params: IdParam,
body: z.object({ note: z.string().max(2000).optional() }),
@@ -670,7 +670,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.select(["source_release_id", "attempts"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
const r = await rt().ctx.engine.enqueue(
"resolveObservation",
// 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径)
@@ -43,7 +43,7 @@ const MAX_SEARCHES = 2;
const SITE_FILTER_DOMAINS = 6;
/** 交给模型的每页正文上限 */
const PAGE_CHARS_FOR_MODEL = 6000;
export const ENRICH_NOTICE = "字段来自网页与 Agent 抽取,不是 TMDB 数据;应用前核对出处";
export const ENRICH_NOTICE = "字段来自网页与智能体抽取,不是 TMDB 数据;应用前核对出处";
export class EnrichError extends Error {
constructor(
@@ -411,7 +411,7 @@ async function resolveQuery(ctx: Pick<ServerContext, "db">, input: MetadataEnric
.select(["p.id", "p.query", "r.title as release_title"])
.where("p.id", "=", input.poolEntryId)
.executeTakeFirst();
if (!p) throw new EnrichError("not_found", "待识别条目不存在");
if (!p) throw new EnrichError("not_found", "未识别条目不存在");
const q = (p.query ?? {}) as { title?: string | null; titleCn?: string | null; year?: number | null };
const title = (q.titleCn || q.title || "").trim();
if (!title) throw new EnrichError("precondition", "这条资源没有解析出标题,无法补全");
@@ -737,7 +737,7 @@ export async function applyEnrichment(
.select(["id", "source_release_id", "status"])
.where("id", "=", poolEntryId)
.executeTakeFirst();
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
const row = await ctx.db
.selectFrom("metadata_enrichment")
.selectAll()
@@ -44,9 +44,12 @@ async function entityForExternal(
const hit = await entityByExternal(exec, ref);
if (hit) return { entityId: hit, created: false };
if (ref.provider !== "tmdb")
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
throw new ApiException(
"bad_request",
`未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 加入媒体库`,
);
const tmdb = await rt.tmdb();
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部编号加入媒体库");
let detail: Awaited<ReturnType<typeof tmdb.details>>;
try {
detail = await tmdb.details(ref.entityType, ref.externalId);
@@ -480,7 +483,7 @@ export async function dismissPoolEntry(rt: SourcesRuntime, poolId: string, actor
.where("id", "=", poolId)
.returningAll()
.executeTakeFirst();
if (!row) throw new ApiException("not_found", "待识别条目不存在");
if (!row) throw new ApiException("not_found", "未识别条目不存在");
await trx
.updateTable("source_release")
.set({ resolution_status: "rejected" })
@@ -25,7 +25,7 @@ function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
{
key: "manual_tmdb",
label: "手动指定 TMDB ID",
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
description: "按 TMDB 电影或剧集 ID 加入媒体库并关联,加人工锁",
input: { sourceReleaseId, requires: "tmdb" },
},
{
@@ -79,7 +79,7 @@ function titleFor(
one: string,
work: string | null,
) {
const what = kind === "identity_ambiguous" ? "待确认身份" : "待识别";
const what = kind === "identity_ambiguous" ? "没识别出作品" : "未识别";
if (n <= 1) return `${what}:${one}`;
return `来源「${siteName}」的 ${n} 条资源${what}${work ? `:${work}` : ""}`;
}
+1 -1
View File
@@ -210,7 +210,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
key: "system.heartbeat_cleanup",
defaults: {
name: "进程心跳清理",
description: "删除 24 小时没有心跳的进程行和超过保留期(存储设置,缺省 24 小时)的心跳采样",
description: "删除过期的进程状态记录",
group: "maintain",
schedule: { type: "interval", everyMs },
},
+15 -15
View File
@@ -105,8 +105,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "observation",
jobId: "storage.retention.observation",
name: "观察记录清理",
description: "删除超过保留期的 Feed 观察记录(observation,按 created_at)",
name: "RSS 抓取记录清理",
description: "删除过期的 RSS 抓取记录",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.observation} 天`,
async run(c) {
@@ -118,8 +118,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "sse_event",
jobId: "storage.retention.sse_event",
name: "SSE 事件清理",
description: "删除超过保留期的 SSE 补读事件(sse_event,按 at;发布方另按条数裁剪)",
name: "实时事件清理",
description: "删除过期的页面实时推送记录",
everyMs: HOUR,
retention: (s) => `${s.retentionDays.sseEvent} 天`,
async run(c) {
@@ -132,7 +132,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "task_run",
jobId: "storage.retention.task_run",
name: "任务运行清理",
description: "删除超过保留期、已结束的业务任务(task)与引擎 run(含步骤、运行元数据)",
description: "删除过期的已结束任务",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.taskRun} 天`,
async run(c) {
@@ -167,8 +167,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "agent_event",
jobId: "storage.retention.agent_event",
name: "Agent 事件清理",
description: "删除超过保留期、会话已结束的 Agent 事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
name: "智能体事件清理",
description: "删除已结束会话的过期消息,会话本身保留",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.agentEvent} 天`,
async run(c) {
@@ -182,14 +182,14 @@ export const RETENTION_JOBS: RetentionJob[] = [
sql`created_at < ${cutoff} and status = 'consumed' and ${ended}`,
c.batch,
);
return `Agent 事件 ${ev.deleted}、消息 ${msg.deleted} 行已删除${ev.truncated || msg.truncated ? ",未删完,下次继续" : ""}`;
return `智能体事件 ${ev.deleted}、消息 ${msg.deleted} 行已删除${ev.truncated || msg.truncated ? ",未删完,下次继续" : ""}`;
},
},
{
target: "audit",
jobId: "storage.retention.audit",
name: "审计清理",
description: "删除超过保留期的审计事件(audit_event,能力调用审计按能力统计的保留期另算)",
description: "删除过期的审计日志",
everyMs: 24 * HOUR,
retention: (s) => `${s.retentionDays.audit} 天`,
async run(c) {
@@ -207,7 +207,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "capability_stats",
jobId: "storage.retention.capability_stats",
name: "能力统计清理",
description: "删除超过保留期的能力调用审计(audit_event target_type=capability)与已结束的能力调用记录",
description: "删除过期的能力调用记录",
everyMs: 24 * HOUR,
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
async run(c) {
@@ -230,8 +230,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "identity_pool",
jobId: "storage.retention.identity_pool",
name: "待识别池归档",
description: "待识别池里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
name: "未识别资源归档",
description: "长期没有新线索的未识别资源归档,仍可手动指定",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
async run(c) {
@@ -246,14 +246,14 @@ export const RETENTION_JOBS: RetentionJob[] = [
archived += n;
if (n < size) break;
}
return `归档 ${archived} 条待识别条目`;
return `归档 ${archived} 条未识别条目`;
},
},
{
target: "analyze",
jobId: "storage.analyze",
name: "统计信息更新",
description: "对全库执行 ANALYZE,刷新查询规划用的统计信息(每周)",
name: "数据库维护",
description: "每周刷新数据库统计信息",
everyMs: 7 * 24 * HOUR,
retention: () => null,
async run(c) {
+2 -2
View File
@@ -88,7 +88,7 @@ export class WebSearchService {
private async client(s: WebSearchSettings) {
if (s.provider === "none")
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → Agent → 网页搜索里开启");
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启");
if (!s.apiKeySecretRef) throw new WebSearchServiceError("auth", "未保存搜索服务 API Key");
const apiKey = await this.ctx.secrets.resolve(s.apiKeySecretRef);
return createBraveClient({
@@ -175,7 +175,7 @@ export class WebSearchService {
async fetch(url: string, o: { scopeKey: string; signal?: AbortSignal }): Promise<WebFetchOutput> {
const s = await this.settings();
if (s.provider === "none")
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → Agent → 网页搜索里开启");
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启");
const want = canonicalUrl(url.trim());
const allow = await pgCacheFor(this.ctx.db).get<{ urls: string[] }>(WEBSEARCH_ALLOW_REGION, o.scopeKey);
if (!want || !allow?.urls.includes(want))