refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)

- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:35:43 +08:00
1 parent d282212a6b
commit 4480fc346d
212 files changed
+741 -715

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);
@@ -186,8 +186,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;
},
};
@@ -393,7 +393,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const rid = await fail("identity_unknown");
await decide_({
kind: "identity_ambiguous",
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
summary: "下载内容未关联作品。识别或指定作品后再整理",
subjectType: "organization_record",
subjectId: rid.recordId,
@@ -460,8 +460,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}`,
@@ -860,7 +860,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)
@@ -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 };
@@ -63,7 +63,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()}`}`;
@@ -62,7 +62,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: "路径映射",
@@ -71,7 +71,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,
@@ -181,7 +181,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
} catch (e) {
throw new ApiException(
"precondition_failed",
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
`媒体库目录不可写(需要目录存在且可写入标记文件 ${name}):${errMessage(e)}`,
);
}
return name;
@@ -215,7 +215,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: "人工交还时选择继续等待",
};
@@ -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: "订阅登录即将过期的模型服务,提前刷新 token",
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,
};
}
@@ -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) })
+2 -2
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"]),
@@ -40,7 +40,7 @@ export const SEARCH_ACTIONS: readonly SearchAction[] = [
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;
@@ -63,7 +63,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
});
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
registerScheduledJob(ctx, {
key: "sites.user_data",
@@ -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,
@@ -542,7 +542,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),
@@ -619,7 +619,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,
@@ -631,7 +631,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);
},
);
@@ -641,7 +641,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() }),
@@ -666,7 +666,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);
@@ -475,7 +478,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}` : ""}`;
}
+6 -6
View File
@@ -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: "删除超过保留期、会话已结束的智能体事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.agentEvent} 天`,
async run(c) {
@@ -182,7 +182,7 @@ 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 ? ",未删完,下次继续" : ""}`;
},
},
{
@@ -230,8 +230,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "identity_pool",
jobId: "storage.retention.identity_pool",
name: "待识别池归档",
description: "待识别池里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
name: "未识别资源归档",
description: "未识别资源里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
async run(c) {
@@ -246,7 +246,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
archived += n;
if (n < size) break;
}
return `归档 ${archived} 条待识别条目`;
return `归档 ${archived} 条未识别条目`;
},
},
{
+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))
+1 -1
View File
@@ -158,7 +158,7 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
decisionId,
kind: "identity_unresolved",
severity: "info",
title: "待识别",
title: "未识别",
});
});
const deadline = Date.now() + 20_000;
+1 -1
View File
@@ -93,7 +93,7 @@ const optionsScript = (goal: string): FakeScript => ({
},
});
// 审批:两个目标各用一个版本目标(拒绝与批准互不影响);ID 在 beforeAll 里填
// 审批:两个目标各用一个收录版本(拒绝与批准互不影响);ID 在 beforeAll 里填
const T: Record<"approve" | "reject", { slotId: string; releaseId: string }> = {
approve: { slotId: ZERO, releaseId: ZERO },
reject: { slotId: ZERO, releaseId: ZERO },
@@ -15,7 +15,7 @@ type Any = any;
const hasPg = await pgAvailable();
const HASH = "ab".repeat(20);
const SURVEY = "盘点订阅、待处理与下载";
const LOOP = (at: string) => `巡检库存(${at}):逐个作品查看版本目标`;
const LOOP = (at: string) => `巡检库存(${at}):逐个作品查看收录版本`;
describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => {
const scripts: FakeScript[] = [];
+1 -1
View File
@@ -394,7 +394,7 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
const r4 = await newRun(null);
const d4 = await drive(env, h.ctx.capabilities, r4, new AbortController().signal);
expect(d4).toMatchObject({ status: "failed" });
expect(d4.message).toMatch(/未配置 Agent 上游/);
expect(d4.message).toMatch(/未配置模型服务/);
await setDefaultUpstream(h.ctx, fake.id);
});
});
+2 -2
View File
@@ -154,7 +154,7 @@ describe.skipIf(!hasPg)("规则组 API(真实 PG)", () => {
expect(draft.body.data.items[0]).toMatchObject({ passed: true, pri: 100 });
});
it("评估接入:版本目标引用规则组 → 硬排除拒绝、pri 进候选评估 trace;被引用的组不能删", async () => {
it("评估接入:收录版本引用规则组 → 硬排除拒绝、pri 进候选评估 trace;被引用的组不能删", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "规则片", year: 2024 });
const slot = await seedSlot(h.db, { entityId: e });
const g = await h.api("POST", "/rules", { name: "片源", ruleString: "4K > 1080P", hardExcludes: ["REMUX"] });
@@ -285,7 +285,7 @@ describe.skipIf(!hasPg)("规则组 API(真实 PG)", () => {
expect(plan.evaluation).toMatchObject({ pri: 100 });
});
it("默认链:订阅默认组有层时对未引用规则组的版本目标生效;PUT /rules/defaults 校验类型", async () => {
it("默认链:订阅默认组有层时对未引用规则组的收录版本生效;PUT /rules/defaults 校验类型", async () => {
const defs = (await h.api("GET", "/rules/defaults")).body.data;
const dflt = defs.acquisition[0];
expect((await h.api("PUT", "/rules/defaults", { ...defs, acquisition: defs.upgrade })).status).toBe(400);
+3 -3
View File
@@ -293,7 +293,7 @@ describe.skipIf(!up)("全局搜索", () => {
expect(a.body.data.items[0]).toMatchObject({ siteId: sites.mteam, siteReleaseId: "912234" });
});
it("库存叠加:已识别资源 → resolved + 订阅/版本目标缺口;未识别但标题命中唯一作品别名 → alias", async () => {
it("库存叠加:已识别资源 → resolved + 订阅/收录版本缺口;未识别但标题命中唯一作品别名 → alias", async () => {
const entity = (
await db
.insertInto("media_entity")
@@ -346,7 +346,7 @@ describe.skipIf(!up)("全局搜索", () => {
const mt = items.find((i) => i.siteReleaseId === "990001");
expect(mt).toMatchObject({ mediaEntityId: entity, entityMatch: "alias", inventory: { state: "gap" } });
// 本地作品组:别名精确命中 + 版本目标状态摘要
// 本地作品组:别名精确命中 + 收录版本状态摘要
const all = await req("/search?q=沙丘2&scope=media");
const local = all.body.data.media.local.items;
expect(local[0]).toMatchObject({
@@ -428,7 +428,7 @@ describe.skipIf(!up)("全局搜索", () => {
expect(unresolved.status).toBe(412);
expect(unresolved.body.error.code).toBe("precondition_failed");
// 已识别但作品没有版本目标 → submitted=false + no_slot
// 已识别但作品没有收录版本 → submitted=false + no_slot
const other = (
await db
.insertInto("media_entity")
+1 -1
View File
@@ -167,7 +167,7 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
sites.c = await addSite("hdhome", "家园", null, true);
await db.updateTable("site").set({ enabled: false }).where("id", "=", sites.c).execute();
// 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的版本目标
// 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的收录版本
dune = (
await db
.insertInto("media_entity")
@@ -177,7 +177,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
expect(ds).toHaveLength(1);
const d = ds[0]!;
expect(d.severity).toBe("warning");
expect(d.title).toMatch(/^来源「.+」的 3 条资源待确认身份:Twins$/);
expect(d.title).toMatch(/^来源「.+」的 3 条资源没识别出作品:Twins$/);
const ev = d.evidence as { count: number; releases: { sourceReleaseId: string }[] };
expect(ev.count).toBe(3);
expect(ev.releases.map((x) => x.sourceReleaseId).sort()).toEqual([...ids].sort());
@@ -226,7 +226,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
expect(after.status).toBe("open");
expect((after.evidence as { count: number }).count).toBe(1);
expect(after.subject_id).toBe(pool[1]!.source_release_id);
expect(after.title).toMatch(/^待确认身份:Nightfall\.1995\./);
expect(after.title).toMatch(/^没识别出作品:Nightfall\.1995\./);
// 最后一条忽略 → 事项关闭
const last = await h.db
.selectFrom("identity_candidate")
@@ -296,7 +296,7 @@ describe.skipIf(!pg)("迁移 0047:存量 identity_unresolved 清理", () => {
.values({
kind: "identity_unresolved",
severity: "info",
title: `待识别:${title}`,
title: `未识别:${title}`,
subject_type: "source_release",
subject_id: r.id,
dedupe_key: `identity:${r.id}`,
@@ -26,7 +26,7 @@ import {
presetConditions,
} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 版本目标、
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 收录版本、
// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。
const hasPg = await pgAvailable();
@@ -182,7 +182,7 @@ describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
expect(again[0]!.hard).toEqual({ exclude: ["CAM"] });
});
it("评估链纳入私有组:包含没命中 / 排除命中都拒绝;命中包含建计划;与版本目标自己的包含取 AND", async () => {
it("评估链纳入私有组:包含没命中 / 排除命中都拒绝;命中包含建计划;与收录版本自己的包含取 AND", async () => {
const e = await seedEntity(db(), { kind: "movie", title: "Filter Film", year: 2024 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
@@ -207,7 +207,7 @@ describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
const ok = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP");
const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok });
expect(r3.plans, JSON.stringify(r3)).toHaveLength(1);
// 版本目标自己也要求 "GRP2":与私有组的 CHS 取 AND
// 收录版本自己也要求 "GRP2":与私有组的 CHS 取 AND
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", r3.plans[0]!).execute();
await db()
.updateTable("acquisition_slot")
@@ -223,7 +223,7 @@ describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
expect((await evaluateAcquisition(h.d, { sourceReleaseId: both })).plans).toHaveLength(1);
});
it("MP 分辨率 / 来源 / 特效:新建时建默认版本目标承载预设;改预设覆盖同一字段,手改过的字段保留;无法识别 400", async () => {
it("MP 分辨率 / 来源 / 特效:新建时建默认收录版本承载预设;改预设覆盖同一字段,手改过的字段保留;无法识别 400", async () => {
const e = await seedEntity(db(), { kind: "series", title: "预设剧", year: 2025 });
const bad = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
@@ -461,7 +461,7 @@ describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const subId = c.body.data.id as string;
// E01 已入库(单版本目标:作品下资产都算)
// E01 已入库(单收录版本:作品下资产都算)
const a = await db()
.insertInto("media_asset")
.values({
@@ -264,7 +264,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
expect((await h.api("GET", "/subscriptions/00000000-0000-0000-0000-000000000000/coverage")).status).toBe(404);
});
it("覆盖矩阵:集范围裁剪;总集数未知列到已见最高集并标 suspected;电影按内容版本 × 版本目标", async () => {
it("覆盖矩阵:集范围裁剪;总集数未知列到已见最高集并标 suspected;电影按内容版本 × 收录版本", async () => {
const e = await seedEntity(db(), { kind: "series", title: "范围剧", year: 2025 });
await season(e, 1, null);
const e3 = await episode(e, 1, 3);
@@ -325,7 +325,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
// 暂停
const e4 = await seedEntity(db(), { kind: "series", title: `${tag}丁`, year: 2022 });
const s4 = await sub(e4, { status: "paused" });
// 已完成(电影:唯一版本目标已入库)
// 已完成(电影:唯一收录版本已入库)
const e5 = await seedEntity(db(), { kind: "movie", title: `${tag}戊`, year: 2021 });
const a5 = await asset(e5);
const s5 = await sub(e5, { kind: "movie", mode: "once" });
@@ -584,7 +584,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
expect(r.status, JSON.stringify(bad)).toBe(400);
}
// PATCH:autoFill 布尔 → on/off;best_version → 版本目标升级策略
// PATCH:autoFill 布尔 → on/off;best_version → 收录版本升级策略
const sid = c.body.data.id;
const sl = await slot(e, sid, "main");
const u = await h.api("PATCH", `/subscriptions/${sid}`, {
@@ -611,7 +611,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
).toEqual({ enabled: false, mode: "full_pack" });
});
it("暂停 / 恢复 / 重新订阅 / 批量 / 版本目标排序", async () => {
it("暂停 / 恢复 / 重新订阅 / 批量 / 收录版本排序", async () => {
const e = await seedEntity(db(), { kind: "movie", title: "动作片", year: 2024 });
const s = await sub(e, { kind: "movie", mode: "once" });
const a = await slot(e, s, "A", { position: 0 });
+2 -2
View File
@@ -211,7 +211,7 @@ describe.skipIf(!hasPg)("CACHE-2 存储(真实 PG)", () => {
(${done}, 1, 'run_started', ${old}), (${done}, 2, 'run_started', ${now}), (${live}, 1, 'run_started', ${old})`.execute(
h.db,
);
expect(await job("agent_event")(now)).toMatch(/Agent 事件 1、/);
expect(await job("agent_event")(now)).toMatch(/智能体事件 1、/);
expect(await count(sql<{ n: number }>`select count(*)::int as n from agent_run_event`)).toBe(2);
await sql`delete from audit_event`.execute(h.db);
@@ -250,7 +250,7 @@ describe.skipIf(!hasPg)("CACHE-2 存储(真实 PG)", () => {
await cand("r1", "unmatched", 31);
await cand("r2", "unmatched", 29);
await cand("r3", "ambiguous", 31);
expect(await job("identity_pool")(now)).toBe("归档 1 条待识别条目");
expect(await job("identity_pool")(now)).toBe("归档 1 条未识别条目");
const st = await sql<{ t: string; s: string }>`select sr.title as t, c.status as s from identity_candidate c
join source_release sr on sr.id = c.source_release_id order by sr.title`.execute(h.db);
expect(st.rows).toEqual([
+1 -1
View File
@@ -15,7 +15,7 @@ class MemStore {
}
// 验收 S7:生产构建里 ?mock=1 不得把演示数据当真实运行展示
describe("Agent 演示开关", () => {
describe("智能体演示开关", () => {
it("只在开发构建或后端 provider=fake 时允许", () => {
expect(agentMockAllowed({ dev: true })).toBe(true);
expect(agentMockAllowed({ dev: false, provider: "fake" })).toBe(true);
+1 -1
View File
@@ -157,7 +157,7 @@ export const PAUSE_REASON_LABEL = {
user: "用户暂停",
threshold: "阈值暂停",
policy: "策略暂停",
agent: "Agent 暂停",
agent: "智能体暂停",
} as const;
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
+1 -1
View File
@@ -106,7 +106,7 @@ export function useFetchFeedNow() {
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
if (f.possibleGap) {
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "RSS 未关联站点,无法自动补漏"})`;
}
if (!f.enabled || !f.lastSuccessAt) return null;
const gapMs = now - new Date(f.lastSuccessAt).getTime();
+1 -1
View File
@@ -64,7 +64,7 @@ export function inventoryFlags(
});
}
if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) {
flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" });
flags.push({ key: "tracked", label: "已识别,无收录版本", tone: "neutral" });
}
return flags;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
cancelled: "neutral",
};
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "Agent" };
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "智能体" };
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
export function useArtifact(id: string | null | undefined) {
+4 -4
View File
@@ -99,14 +99,14 @@ export function useRuleSuppressions() {
export type RuleRefs = {
/** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */
subscriptions: Map<string, { id: string; title: string }[]>;
/** 规则组 id → 引用它的版本目标 */
/** 规则组 id → 引用它的收录版本 */
slots: Map<string, { id: string; name: string }[]>;
};
const SubPage = cursorPage(Subscription);
const SlotPage = cursorPage(AcquisitionSlot);
/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */
/** 引用关系:列表接口不带引用计数,前端从订阅与收录版本反查(各取前 200 条) */
export function useRuleRefs() {
return useQuery({
queryKey: ruleKeys.refs,
@@ -150,7 +150,7 @@ function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: ruleKeys.all });
// 旧偏好标签页与订阅/版本目标的规则组显示同源
// 旧偏好标签页与订阅/收录版本的规则组显示同源
void qc.invalidateQueries({ queryKey: ["preferences"] });
};
}
@@ -175,7 +175,7 @@ export function useUpdateRuleGroup() {
});
}
/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */
/** 删除:标记删除并写抑制记录;被收录版本/订阅引用时 409 */
export function useDeleteRuleGroup() {
const done = useInvalidate();
return useMutation({
+1 -1
View File
@@ -105,7 +105,7 @@ export function useSiteRequestStats(siteId: string | undefined) {
export function accessSummary(s: SiteRequestStats): string {
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
const parts = [
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
`抓取每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
];
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
+2 -2
View File
@@ -77,7 +77,7 @@ export function useUpdateSubscription() {
api.patch<unknown>(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
// best_version 写到版本目标的升级策略
// best_version 写到收录版本的升级策略
void qc.invalidateQueries({ queryKey: ["slots"] });
},
});
@@ -246,7 +246,7 @@ export function useFillSlot() {
});
}
/** 版本目标排序:第一个成为主版本 */
/** 收录版本排序:第一个成为主版本 */
export function useSetSlotOrder() {
const done = useInvalidateSubs();
return useMutation({
+2 -2
View File
@@ -23,7 +23,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
const DecisionPage = cursorPage(Decision);
const RESOLUTION_LABEL = {
unresolved: "待识别",
unresolved: "未识别",
resolved: "已识别",
ambiguous: "有歧义,待确认",
unidentifiable: "无法识别",
@@ -260,7 +260,7 @@ export function buildTimeline(input: {
key: `agent-${d.id}`,
stage: "agent",
at: d.updatedAt,
title: "Agent 已介入调查",
title: "智能体已介入调查",
tone: "neutral",
href: `/console/agent/${d.agentRunId}`,
});
@@ -249,8 +249,7 @@ export function ToolCallCard({
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
<dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ??
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
{audit?.idempotencyKey ?? (meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读操作不记录")}
</dd>
{audit?.grantId || audit?.approvalId ? (
<>
@@ -652,7 +651,7 @@ export function AgentConversation({
at: m.at,
role: "user",
foldable: false,
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 ? "已送达,等待智能体读取" : undefined} animateIn />,
});
entries.sort((a, b) => a.at.localeCompare(b.at));
const blocks = groupConversation(entries);
@@ -678,13 +677,13 @@ export function AgentConversation({
b.kind === "single" ? (
<div key={b.entry.key}>{b.entry.node}</div>
) : (
<Message key={b.key} from="assistant" aria-label="Agent 回合">
<Message key={b.key} from="assistant" aria-label="智能体回合">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent className="gap-2">
<MessageHeader>
<span className="font-medium text-foreground/80">Agent</span>
<span className="font-medium text-foreground/80">智能体</span>
<time dateTime={b.at}>{clockText(b.at)}</time>
</MessageHeader>
{b.parts.map((p) =>
@@ -72,7 +72,7 @@ function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
return (
<div
role="tablist"
aria-label="Agent 视图"
aria-label="智能体视图"
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
>
{TABS.map((t) => (
@@ -161,10 +161,10 @@ export function AgentDrawer() {
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
Agent
智能体
</SheetTitle>
<SheetDescription className={cn("break-words", history && "sr-only")}>
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
{context?.label ? `当前对象:${context.label}` : "交代目标,智能体在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭)
</span>
@@ -281,7 +281,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
: null;
return (
<section
aria-label="Agent 小窗"
aria-label="智能体小窗"
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
>
<div className="flex items-center gap-2">
@@ -290,7 +290,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开 Agent" title="展开" onClick={restoreAgentDrawer}>
<Button variant="ghost" size="icon-xs" aria-label="展开智能体" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
</Button>
<Button
@@ -325,7 +325,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
<Button
variant="ghost"
size="icon-sm"
aria-label="打开 Agent"
aria-label="打开智能体"
title={`Agent(${agentShortcutLabel()})`}
className={className}
onClick={() =>
@@ -107,7 +107,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
const optionId = picked === NONE ? null : picked;
if (!picked) return;
if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,Agent 按它调整");
toast.error("写一句不要的原因,智能体按它调整");
return;
}
choose.mutate(
@@ -115,7 +115,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
{
onSuccess: () => {
setSent({ optionId, reason: reason.trim() });
toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案");
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案");
},
onError: (e) => toast.error(errorMessage(e)),
},
@@ -129,8 +129,8 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
: sent
? sent.optionId
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待 Agent 继续`
: `都不要:${sent.reason},等待 Agent 调整`
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
: `都不要:${sent.reason},等待智能体调整`
: !waiting
? "会话已不在等待这个选择"
: null;
@@ -232,7 +232,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
</Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "Agent 停止"}。
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
</span>
</div>
)}
@@ -285,7 +285,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
},
{
onSuccess: () => toast.success(s === "approved" ? "已批准,Agent 继续执行" : "已拒绝,Agent 按理由调整"),
onSuccess: () => toast.success(s === "approved" ? "已批准,智能体继续执行" : "已拒绝,智能体按理由调整"),
onError: (e) => toast.error(errorMessage(e)),
},
);
@@ -332,7 +332,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),Agent 按它调整"
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
@@ -114,8 +114,8 @@ export function GoalComposer({
/>
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{proposeFirst
? "Agent 先调查并给出方案,你选定后才执行。"
: "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
? "智能体先调查并给出方案,你选定后才执行。"
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
Enter 发送,Shift+Enter 换行。
</p>
{suggestions?.length ? (
@@ -400,7 +400,7 @@ export function AgentRunPanel({
viewportClassName="pr-1"
contentClassName={column}
busy={streaming}
label="Agent 会话记录"
label="智能体会话记录"
>
<AgentConversation
run={r}
@@ -196,7 +196,7 @@ export function AgentSessionList({
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
return (
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5">
<Input
type="search"
+2 -2
View File
@@ -48,7 +48,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
};
const steps: AgentPlanStep[] = [
{ id: "s1", title: "查本地库存和版本目标", status: "running", note: null },
{ id: "s1", title: "查本地库存和收录版本", status: "running", note: null },
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
@@ -63,7 +63,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
};
push(0, { type: "run_started", goal, resumed: false });
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
say("先确认本地库存与版本目标,再去站点找候选。");
say("先确认本地库存与收录版本,再去站点找候选。");
push(400, {
type: "tool_call_start",
toolCallId: "t1",
+3 -3
View File
@@ -42,15 +42,15 @@ const RULES: Rule[] = [
},
{
match: /^\/console\/identity/,
goals: ["识别待识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
},
{
match: /^\/console\/subscriptions/,
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"],
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"],
},
{
match: /^\/console\/sources/,
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 Feed 的抓取状态", "最近一天各来源抓到了什么"],
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"],
},
{
match: /^\/console\/engine/,
+1 -1
View File
@@ -18,7 +18,7 @@ export interface MessageProps extends ComponentPropsWithRef<"article"> {
animateIn?: boolean;
}
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "Agent 消息", system: "系统消息" };
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "智能体消息", system: "系统消息" };
export function Message({ from, animateIn = false, className, children, ...props }: MessageProps) {
return (
@@ -32,11 +32,11 @@ describe("原站视图坐标换算", () => {
describe("当前控制文案", () => {
const now = Date.parse("2026-09-29T10:00:00Z");
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「Agent」", () => {
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「智能体」", () => {
expect(controllerLabel({ controller: "automation", lastAutomationAt: null }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation" }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:50:00Z" }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe("Agent");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe("智能体");
expect(agentActive({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe(true);
});
it("人工接管 / 等待处理不看自动化时间", () => {
+2 -2
View File
@@ -41,7 +41,7 @@ export function controllerLabel(
s: { controller: BrowserController; lastAutomationAt?: string | null },
now = Date.now(),
): string {
return agentActive(s, now) ? "Agent" : CONTROLLER_LABEL[s.controller];
return agentActive(s, now) ? "智能体" : CONTROLLER_LABEL[s.controller];
}
const WAITING_TEXT: Record<string, string> = {
@@ -51,7 +51,7 @@ const WAITING_TEXT: Record<string, string> = {
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "接管超时未交还",
agent_request: "Agent 请求人工处理",
agent_request: "智能体请求人工处理",
human_released_waiting: "交还时选择继续等待",
};
@@ -229,7 +229,7 @@ function DesktopView({
control.release.mutate(s.id, {
onSuccess: () => {
refreshRole();
toast.success("已交还,Agent 会重新读取页面");
toast.success("已交还,智能体会重新读取页面");
},
onError: (e) => toast.error(errorMessage(e)),
})
@@ -276,8 +276,8 @@ function DesktopView({
{mine
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
: agentActive(s)
? "只读观看,Agent 正在操作。需要自己点或输入时先接管,Agent 会暂停。"
: "只读观看,目前没有 Agent 在操作。需要自己点或输入时先接管。"}
? "只读观看,智能体正在操作。需要自己点或输入时先接管,智能体会暂停。"
: "只读观看,目前没有智能体在操作。需要自己点或输入时先接管。"}
</p>
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
@@ -174,7 +174,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
disabled={busy}
onClick={() =>
control.release.mutate(s.id, {
onSuccess: () => toast.success("已交还,Agent 会重新读取页面"),
onSuccess: () => toast.success("已交还,智能体会重新读取页面"),
onError: (e) => toast.error(errorMessage(e)),
})
}
@@ -145,7 +145,7 @@ export function AgentLauncher() {
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
const label = open ? "收起 Agent" : count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent";
const label = open ? "收起智能体" : count ? `打开智能体(${count} 个会话进行中)` : "打开智能体";
return createPortal(
<button
type="button"
@@ -171,7 +171,7 @@ export function AgentLauncher() {
</span>
<span className="hidden min-w-0 flex-col items-start text-left md:flex">
<span className="flex items-center gap-2 text-control leading-5 font-semibold">
{open ? "收起" : "Agent"}
{open ? "收起" : "智能体"}
{count && !open ? <PulseDot /> : null}
{count && !open ? <CountBadge count={count} /> : null}
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium text-muted-foreground">
@@ -205,7 +205,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={activate}
title={title}
aria-label={count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent"}
aria-label={count ? `打开智能体(${count} 个会话进行中)` : "打开智能体"}
aria-keyshortcuts="Control+J Meta+J"
className="prism-ring relative mb-2 grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-xl text-foreground outline-none transition-transform duration-micro active:scale-[0.95] focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -228,7 +228,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 text-sm font-semibold">
Agent
智能体
{count ? <PulseDot /> : null}
</span>
<span className="truncate text-meta text-muted-foreground">
@@ -250,8 +250,8 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
export function AgentShortcutsHelp() {
const shortcut = agentShortcutLabel();
const rows: [string, string][] = [
[shortcut, "打开 / 收起 Agent,附带当前页面"],
["Esc", "关闭 Agent"],
[shortcut, "打开 / 收起智能体,附带当前页面"],
["Esc", "关闭智能体"],
["Enter", "发送目标;Shift+Enter 换行"],
];
return (
@@ -6,15 +6,15 @@ import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./dat
type Row = { id: string; status: string; name: string };
const rows: Row[] = [
{ id: "1", status: "待识别", name: "a.mkv" },
{ id: "1", status: "未识别", name: "a.mkv" },
{ id: "2", status: "已登记", name: "b.mkv" },
{ id: "3", status: "待识别", name: "c.mkv" },
{ id: "3", status: "未识别", name: "c.mkv" },
];
describe("DataTable groupBy:分组逻辑", () => {
it("按首次出现顺序分组,组内保持原顺序", () => {
expect(groupRows(rows, (r) => r.status).map((g) => [g.key, g.rows.map((r) => r.id)])).toEqual([
["待识别", ["1", "3"]],
["未识别", ["1", "3"]],
["已登记", ["2"]],
]);
});
@@ -24,7 +24,7 @@ describe("DataTable groupBy:分组逻辑", () => {
(r) => r.status,
(a, b) => a.localeCompare(b, "zh-CN"),
);
expect(g.map((x) => x.key)).toEqual(["待识别", "已登记"].sort((a, b) => a.localeCompare(b, "zh-CN")));
expect(g.map((x) => x.key)).toEqual(["未识别", "已登记"].sort((a, b) => a.localeCompare(b, "zh-CN")));
});
it("折叠切换返回新集合", () => {
const a = new Set<string>();
@@ -72,8 +72,8 @@ describe("DataTable groupBy:渲染", () => {
}),
);
expect(html.match(/aria-expanded="true"/g)).toHaveLength(2);
expect(html).toContain("待识别");
expect(html).toMatch(/待识别<\/span><span[^>]*>2<\/span>/);
expect(html).toContain("未识别");
expect(html).toMatch(/未识别<\/span><span[^>]*>2<\/span>/);
expect(html).toMatch(/已登记<\/span><span[^>]*>1<\/span>/);
for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n);
});
+2 -2
View File
@@ -19,8 +19,8 @@ describe("控制台导航", () => {
it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => {
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]);
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
["首页", "任务", "计划", "Agent 会话"],
["站点", "RSS 订阅"],
["首页", "任务", "计划", "智能体会话"],
["站点", "RSS"],
["订阅", "规则"],
["下载面板", "整理记录", "文件管理", "媒体概览"],
["设置", "审计", "运行状态"],
+8 -8
View File
@@ -52,19 +52,19 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/tasks",
label: "任务",
icon: ListTodo,
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里",
description: "每次运行都是一条任务:定时、事件、手动、智能体触发都在这里",
},
{
to: "/console/schedules",
label: "计划",
icon: CalendarCog,
description: "定时任务的开关、间隔和下次运行:自动任务与 Agent 巡检",
description: "定时任务的开关、间隔和下次运行:自动任务与智能体巡检",
},
{
to: "/console/agent",
label: "Agent 会话",
label: "智能体会话",
icon: Bot,
description: "交代目标,Agent 在授权内执行并汇报",
description: "交代目标,智能体在授权内执行并汇报",
},
],
},
@@ -75,9 +75,9 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/sites",
label: "站点",
icon: Globe,
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
description: "站点、凭证、账号数据与健康;资源、抓取与 RSS",
},
{ to: "/console/sources", label: "RSS 订阅", icon: Rss, description: "全部 Feed 与抓取状态" },
{ to: "/console/sources", label: "RSS", icon: Rss, description: "全部 RSS 与抓取状态" },
],
},
{
@@ -107,7 +107,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/library",
label: "媒体概览",
icon: Library,
description: "作品、季 / 集、版本目标、外部身份与待识别资源",
description: "作品、季 / 集、收录版本、外部身份与未识别资源",
},
],
},
@@ -118,7 +118,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/settings",
label: "设置",
icon: Settings,
description: "下载器、媒体服务器、库根、站点账号、Agent 上游与自动化",
description: "下载器、媒体服务器、媒体库目录、站点账号、模型服务与自动化",
},
{
to: "/console/audit",
@@ -30,10 +30,10 @@ export const TASK_KIND_LABEL: Record<TaskKind, string> = {
submitDownload: "提交下载",
verifyAndImport: "核验入库",
refreshMetadata: "刷新元数据",
reconcileLibrary: "资产对账",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待处理事项",
agentRun: "Agent 运行",
agentRun: "智能体运行",
};
export const TASK_STATUS: Record<
@@ -54,15 +54,15 @@ export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
export const SUBJECT_LABEL: Record<string, string> = {
download_task: "下载任务",
acquisition_plan: "下载计划",
acquisition_slot: "版本目标",
acquisition_slot: "收录版本",
organization_record: "整理记录",
organize_request: "整理请求",
media_entity: "作品",
media_asset: "资产",
media_asset: "文件",
source_release: "资源",
feed_source: "Feed",
feed_source: "RSS",
site: "站点",
library_root: "库根",
library_root: "媒体库目录",
cache_volume: "缓存卷",
};
@@ -13,7 +13,7 @@ export const STAGE_LABEL: Record<TimelineStage, string> = {
plan: "计划",
download: "下载",
organization: "整理",
agent: "Agent",
agent: "智能体",
engine: "任务",
};
@@ -113,7 +113,7 @@ export function CacheVolumes() {
</Button>
}
title="按规则清理下载缓存?"
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
description="只清理已入库、且满足保留期与 H&R 的本工具任务,其余不动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
@@ -51,7 +51,7 @@ export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
) : t.pauseReason ? (
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
{!t.managed ? <StatusBadge>未纳管</StatusBadge> : null}
{!t.managed ? <StatusBadge>外部任务</StatusBadge> : null}
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
</span>
);
@@ -123,7 +123,8 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
})
}
>
<Bot />问 Agent
<Bot />
问智能体
</Button>
</div>
</div>
@@ -131,7 +132,7 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{task.pauseReason === "threshold" ? (
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和 Agent 都不能绕过阈值。
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和智能体都不能绕过阈值。
</p>
) : null}
{task.noProgressSince ? (
+14 -9
View File
@@ -186,7 +186,12 @@ export function MkdirDialog({
const SUGGEST_REASON: Record<string, string> = {
identity_uncertain: "识别不可信,没有推荐名",
};
const TEMPLATE_SOURCE: Record<string, string> = { entity: "作品覆盖", root: "库根", global: "全局", override: "覆写" };
const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "媒体库目录",
global: "全局",
override: "覆写",
};
function suggestReasonText(r: string | null): string {
if (!r) return "没有推荐名";
@@ -434,7 +439,7 @@ export function RenameDialog({
))}
</ul>
{pv.assetLocations > 0 ? (
<Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice>
) : null}
{pv.bindings.length > 0 ? (
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
@@ -582,7 +587,7 @@ export function TransferDialog({
}}
className="max-h-72 md:max-h-96"
/>
<p className="text-meta text-muted-foreground">只能选可写库根下的目录。</p>
<p className="text-meta text-muted-foreground">只能选可写媒体库目录下的目录。</p>
</div>
<div className="min-w-0 space-y-3">
{target && targetRoot ? (
@@ -616,7 +621,7 @@ export function TransferDialog({
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
) : null}
{plan.pairs.some((p) => p.registered) ? (
<StatusBadge>含已登记资产,执行后同步位置</StatusBadge>
<StatusBadge>含已入库文件,执行后同步位置</StatusBadge>
) : null}
</div>
{hnrBlocked ? (
@@ -777,7 +782,7 @@ export function DeleteDialog({
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogDescription>已检查做种任务、已登记资产和路径绑定。下载器里的任务不受影响。</DialogDescription>
<DialogDescription>已检查做种任务、已入库文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
{check.isPending ? (
@@ -824,7 +829,7 @@ export function DeleteDialog({
</p>
))}
{c.assets.length ? (
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
<p>已入库文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
) : null}
{c.bindings.map((bd) => (
<p key={bd.bindingId}>
@@ -843,9 +848,9 @@ export function DeleteDialog({
<label className="flex items-start gap-2.5 text-caption">
<input type="checkbox" className="mt-1" checked={retire} onChange={(e) => setRetire(e.target.checked)} />
<span>
同时退役资产
同时从媒体库移除
<span className="block text-meta text-muted-foreground">
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
这些文件从媒体库记录中移除;已满足的收录版本退回待处理。
</span>
</span>
</label>
@@ -1042,7 +1047,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<p key={s.ref} className="text-meta text-warning">
{s.ref}:
{s.reason === "not_registered"
? "没有已登记资产"
? "没有已入库文件"
: s.reason === "no_entity"
? "不属于任何已登记作品"
: s.reason === "not_found"
+1 -1
View File
@@ -29,7 +29,7 @@ export function DirPicker({
onSelect={onChange}
/>
) : (
<p className="px-2 py-3 text-caption text-muted-foreground">没有可写的库根。在设置里把库根设为纳管后再选。</p>
<p className="px-2 py-3 text-caption text-muted-foreground">没有可写入的媒体库目录。先在设置里添加。</p>
)}
</div>
);
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more