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:
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 个作品。演示模型不提交下载;要真实执行,在设置里配置模型服务。`;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 允许列表里";
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function createMcpServer(
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
instructions:
|
||||
"工具与控制台、内置 Agent 共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
|
||||
"工具与控制台、内置智能体共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
|
||||
},
|
||||
);
|
||||
const limiter = sharedInvokeLimiter();
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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) })
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -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} 条未识别条目`;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user