Merge branch 'deai2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/server/src/modules/acquisition-organize/services/organize.ts # apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
commit
12bc4664b6
247 files changed
+2658
-2148
No files matched your search
@@ -72,7 +72,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
|
||||
for (const dryRun of [true, false]) {
|
||||
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.error.message).toContain("不在已登记的库根或下载器路径映射之下");
|
||||
expect(r.body.error.message).toContain("不在已登记的媒体库目录或下载器路径映射之下");
|
||||
}
|
||||
// `..` 即使规范化后仍在根内也拒绝
|
||||
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
|
||||
|
||||
@@ -77,7 +77,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
return { items } as never;
|
||||
});
|
||||
|
||||
// AG-3:订阅摘要(分组计数 + 条目),版本目标只给总数 / 已满足数,细节用 inventory.query
|
||||
// AG-3:订阅摘要(分组计数 + 条目),收录版本只给总数 / 已满足数,细节用 inventory.query
|
||||
registry.register(capabilitySpec("subscription.list"), async (input) => {
|
||||
const db = deps().ctx.db;
|
||||
let q = db
|
||||
@@ -253,7 +253,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
const d = deps();
|
||||
const db = d.ctx.db;
|
||||
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
|
||||
if (!slot) throw new CapabilityError("not_found", "版本目标不存在");
|
||||
if (!slot) throw new CapabilityError("not_found", "收录版本不存在");
|
||||
const rel = await db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "media_entity_id"])
|
||||
@@ -285,7 +285,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
|
||||
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
|
||||
if (rejected.length === 0)
|
||||
rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
|
||||
rejected.push({ code: "not_planned", message: "未生成计划:收录版本正忙或条件不满足" });
|
||||
}
|
||||
const fresh = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
@@ -324,8 +324,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
.where("id", "=", plan.slot_id)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
|
||||
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
|
||||
if (slot.revision + 1 !== plan.expected_revision) return "收录版本已变化,重新生成计划";
|
||||
if (BUSY.includes(slot.status)) return `收录版本正忙(${slot.status})`;
|
||||
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
|
||||
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
|
||||
st = transition(st, { type: "reserve" });
|
||||
@@ -348,7 +348,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留";
|
||||
if ((err as { code?: string }).code === "23505") return "收录版本已被其他计划预留";
|
||||
throw err;
|
||||
});
|
||||
if (ok) throw new CapabilityError("conflict", ok);
|
||||
|
||||
@@ -110,7 +110,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "downloads.sync",
|
||||
defaults: {
|
||||
name: "qB 同步",
|
||||
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
|
||||
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
|
||||
group: "download",
|
||||
schedule: every(syncMs),
|
||||
},
|
||||
@@ -129,8 +129,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
registerScheduledJob(ctx, {
|
||||
key: "cache.threshold",
|
||||
defaults: {
|
||||
name: "缓存阈值检查",
|
||||
description: "下载缓存超过硬阈值时入队清理",
|
||||
name: "下载盘检查",
|
||||
description: "下载盘超过暂停线时清理已入库的下载",
|
||||
group: "maintain",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "downloads.orchestrate",
|
||||
defaults: {
|
||||
name: "补提交与补入库",
|
||||
description: "补投已预留未提交的下载、已完成未入库的任务",
|
||||
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
|
||||
group: "download",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -159,7 +159,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "slots.reevaluate",
|
||||
defaults: {
|
||||
name: "到期重评",
|
||||
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
|
||||
description: "重新检查等待中的资源",
|
||||
group: "download",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -169,7 +169,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "subscriptions.search",
|
||||
defaults: {
|
||||
name: "订阅周期搜索",
|
||||
description: "到期的订阅主动搜索站点",
|
||||
description: "到期的订阅去站点搜索",
|
||||
group: "collect",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -190,8 +190,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
registerScheduledJob(ctx, {
|
||||
key: "library.reconcile",
|
||||
defaults: {
|
||||
name: "资产对账",
|
||||
description: "核对媒体库文件与资产记录",
|
||||
name: "文件核对",
|
||||
description: "检查媒体库里的文件是否还在",
|
||||
group: "organize",
|
||||
schedule: every(reconcileMs),
|
||||
},
|
||||
|
||||
@@ -30,19 +30,19 @@ const READ_ONLY: ReadOnly[] = [
|
||||
{
|
||||
...Api.mediaAssets,
|
||||
table: "media_asset",
|
||||
what: "资产",
|
||||
what: "文件",
|
||||
filters: { mediaEntityId: "media_entity_id", status: "status" },
|
||||
},
|
||||
{
|
||||
...Api.assetUnitCoverage,
|
||||
table: "asset_unit_coverage",
|
||||
what: "资产覆盖",
|
||||
what: "文件覆盖",
|
||||
filters: { mediaAssetId: "media_asset_id", contentUnitId: "content_unit_id" },
|
||||
},
|
||||
{
|
||||
...Api.assetLocations,
|
||||
table: "asset_location",
|
||||
what: "资产位置",
|
||||
what: "文件位置",
|
||||
filters: { mediaAssetId: "media_asset_id", status: "status" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,7 +143,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
|
||||
const notes: string[] = [];
|
||||
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
|
||||
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
|
||||
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
|
||||
if (dv.unmanagedGrowthRisk) notes.push("有外部任务仍在增长,暂停本工具的任务后磁盘仍会增长");
|
||||
const pct = usedPercent(v);
|
||||
volumes.push({
|
||||
volumeKey: v.volumeKey,
|
||||
|
||||
@@ -110,7 +110,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "post",
|
||||
path: "/library/reconcile",
|
||||
scope: "admin",
|
||||
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
|
||||
summary: "入队一轮资产对账(媒体库目录哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
|
||||
body: z.object({ libraryRootId: z.uuid().optional() }),
|
||||
response: accepted,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ export function registerNamingRoutes(app: ModuleApp, d: AoDeps) {
|
||||
};
|
||||
const rootRow = async (id: string) => {
|
||||
const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!r) throw notFound("库根");
|
||||
if (!r) throw notFound("媒体库目录");
|
||||
return r;
|
||||
};
|
||||
const audit = (
|
||||
@@ -209,7 +209,7 @@ async function loadSample(d: AoDeps, input: NamingPreviewInput): Promise<SampleS
|
||||
.where("a.id", "=", s.assetId)
|
||||
.orderBy("l.created_at", "desc")
|
||||
.executeTakeFirst();
|
||||
if (!asset) throw notFound("资产");
|
||||
if (!asset) throw notFound("文件");
|
||||
const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv";
|
||||
const e = asset.media_entity_id
|
||||
? await db.selectFrom("media_entity").selectAll().where("id", "=", asset.media_entity_id).executeTakeFirst()
|
||||
@@ -244,7 +244,7 @@ async function preview(
|
||||
let root: LibraryRootRow | null = null;
|
||||
if (rootId) {
|
||||
root = (await db.selectFrom("library_root").selectAll().where("id", "=", rootId).executeTakeFirst()) ?? null;
|
||||
if (!root && input.libraryRootId) throw notFound("库根");
|
||||
if (!root && input.libraryRootId) throw notFound("媒体库目录");
|
||||
}
|
||||
const eff = effectiveNaming(await namingSettings(), root, entity);
|
||||
const template = input.template ?? eff.templates[kind];
|
||||
|
||||
@@ -91,11 +91,11 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
|
||||
.where("active", "=", true)
|
||||
.executeTakeFirst();
|
||||
if (!sup) return;
|
||||
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
|
||||
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,智能体不能自动加回");
|
||||
await liftSuppressions(trx, fingerprint, by);
|
||||
}
|
||||
|
||||
/** 被版本目标 / 订阅显式引用的次数 */
|
||||
/** 被收录版本 / 订阅显式引用的次数 */
|
||||
async function referenceCount(trx: Trx, id: string): Promise<number> {
|
||||
const r = await sql<{ n: number }>`
|
||||
select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
|
||||
@@ -117,7 +117,7 @@ async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly stri
|
||||
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
|
||||
/** 收录版本 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
|
||||
export const assertRuleGroupIds = checkIdsExist;
|
||||
|
||||
const TRACKED_FIELDS = [
|
||||
@@ -528,7 +528,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.executeTakeFirst();
|
||||
if (!cur) notFound("规则组");
|
||||
const refs = await referenceCount(trx, cur!.id);
|
||||
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
|
||||
if (refs > 0) conflict(`规则组被 ${refs} 个收录版本或订阅引用,先解除引用`);
|
||||
const row = await trx
|
||||
.updateTable("rule_group")
|
||||
.set({
|
||||
@@ -597,7 +597,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 规则组改动后重评引用它的版本目标(显式引用;默认链改动由周期重评覆盖) */
|
||||
/** 规则组改动后重评引用它的收录版本(显式引用;默认链改动由周期重评覆盖) */
|
||||
async function kickReevaluate(d: AoDeps, groupId: string) {
|
||||
try {
|
||||
const slots = await d.ctx.db
|
||||
|
||||
@@ -136,7 +136,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
Api.acquisitionSlots.get,
|
||||
async (_c, { params }) => {
|
||||
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
return toApi<AcquisitionSlot>(r ?? notFound("版本目标"));
|
||||
return toApi<AcquisitionSlot>(r ?? notFound("收录版本"));
|
||||
},
|
||||
{ tag: TAG },
|
||||
);
|
||||
@@ -171,7 +171,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!r) conflict("该作品或内容单元下已有同名版本目标");
|
||||
if (!r) conflict("该作品或内容单元下已有同名收录版本");
|
||||
await kickEvaluate(d, { acquisitionSlotId: r!.id });
|
||||
return toApi<AcquisitionSlot>(r!);
|
||||
},
|
||||
@@ -208,7 +208,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.select("revision")
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!exists) notFound("版本目标");
|
||||
if (!exists) notFound("收录版本");
|
||||
conflict(`revision 不匹配(当前 ${exists!.revision})`);
|
||||
}
|
||||
await kickEvaluate(d, { acquisitionSlotId: params.id });
|
||||
|
||||
@@ -49,7 +49,7 @@ import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from
|
||||
import { mount, paginate } from "./helpers.ts";
|
||||
|
||||
// W9-E 订阅 API(docs/design/subscriptions.md §1–§3、§5):总览(分组/排序/筛选)、覆盖矩阵、表单字段规范化、
|
||||
// 暂停/恢复/重新订阅、批量、手动补全某版本、版本目标排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
|
||||
// 暂停/恢复/重新订阅、批量、手动补全某版本、收录版本排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
|
||||
|
||||
const TAG = "subscriptions";
|
||||
const OVERVIEW_MAX = 2000;
|
||||
@@ -106,12 +106,12 @@ async function assertNoDuplicate(
|
||||
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
|
||||
}
|
||||
|
||||
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
|
||||
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个收录版本(预设随后写入) */
|
||||
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
||||
|
||||
/**
|
||||
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
|
||||
* 该订阅自己的版本目标;MP total_episode → 季总集数;best_version → 版本目标升级策略。
|
||||
* 该订阅自己的收录版本;MP total_episode → 季总集数;best_version → 收录版本升级策略。
|
||||
*/
|
||||
async function applyDerived(
|
||||
exec: Exec,
|
||||
@@ -616,7 +616,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
{ tag: TAG },
|
||||
);
|
||||
|
||||
// ── 版本目标:手动补全 / 排序 ──
|
||||
// ── 收录版本:手动补全 / 排序 ──
|
||||
mount(
|
||||
app,
|
||||
Api.subscriptions.fillSlot,
|
||||
@@ -632,7 +632,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.execute()
|
||||
: [];
|
||||
const slot = slotsOfSub(slots, sub).find((s) => s.id === params.slotId);
|
||||
if (!slot) notFound("版本目标");
|
||||
if (!slot) notFound("收录版本");
|
||||
invalidateCoverage(sub.id);
|
||||
try {
|
||||
return await d.ctx.engine.enqueue(
|
||||
@@ -668,7 +668,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
new Set(want).size !== want.length ||
|
||||
!want.every((id) => mine.some((s) => s.id === id))
|
||||
)
|
||||
throw new ApiException("bad_request", "slotIds 必须恰好是该订阅的全部版本目标");
|
||||
throw new ApiException("bad_request", "slotIds 必须恰好是该订阅的全部收录版本");
|
||||
for (const [i, id] of want.entries())
|
||||
await trx.updateTable("acquisition_slot").set({ position: i }).where("id", "=", id).execute();
|
||||
return (await trx.selectFrom("acquisition_slot").selectAll().where("id", "in", want).execute()).sort(
|
||||
|
||||
@@ -200,7 +200,7 @@ export async function createSlotsFromTemplates(
|
||||
await exec
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
// 同一事务建的版本目标 created_at 相同:按模板顺序给 position,第一个为主版本
|
||||
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
|
||||
position: i,
|
||||
media_entity_id: input.entityId,
|
||||
subscription_id: input.subscriptionId,
|
||||
@@ -556,7 +556,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
}
|
||||
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
|
||||
const ctx = contextOf(rel, now);
|
||||
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
|
||||
// 规则组:收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
|
||||
c.rules ??= loadRuleCatalog(db);
|
||||
const chain = resolveRuleChain(await c.rules, {
|
||||
kind: "acquisition",
|
||||
@@ -568,7 +568,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
siteId: rel.site_id,
|
||||
subscriptionId: sub?.id,
|
||||
});
|
||||
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件
|
||||
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入收录版本硬条件
|
||||
spec.hard = mergeHard(spec.hard, chain.hard);
|
||||
if (chain.groups.length) {
|
||||
spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
||||
@@ -811,7 +811,7 @@ async function unitQuality(
|
||||
exec: Exec,
|
||||
slot: AcquisitionSlotRow,
|
||||
keys: readonly string[],
|
||||
/** 版本目标引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
|
||||
/** 收录版本引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
|
||||
priOf?: (assetId: string) => Promise<number | undefined>,
|
||||
): Promise<Record<string, QualityFacts | undefined>> {
|
||||
const rows = await exec
|
||||
|
||||
@@ -25,9 +25,9 @@ import { defaultChainIds, loadRuleCatalog, type RuleCatalog, resolveRuleChain }
|
||||
import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
|
||||
|
||||
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
|
||||
// 一批订阅一次取齐 版本目标 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
|
||||
// 在内存里按版本目标算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
|
||||
// 版本目标覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个版本目标时,作品下全部资产都算)。
|
||||
// 一批订阅一次取齐 收录版本 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
|
||||
// 在内存里按收录版本算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
|
||||
// 收录版本覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个收录版本时,作品下全部资产都算)。
|
||||
|
||||
const MAX_EPISODES = 2000;
|
||||
const COVERAGE_TTL_MS = 30_000;
|
||||
@@ -111,11 +111,11 @@ export function subscriptionAutoFill(
|
||||
};
|
||||
}
|
||||
|
||||
/** 版本目标排序:position → created_at → id;第一个为主版本 */
|
||||
/** 收录版本排序:position → created_at → id;第一个为主版本 */
|
||||
export const slotOrder = (a: AcquisitionSlotRow, b: AcquisitionSlotRow) =>
|
||||
a.position - b.position || a.created_at.getTime() - b.created_at.getTime() || a.id.localeCompare(b.id);
|
||||
|
||||
/** 订阅关联的版本目标:同作品、属于该订阅或不属于任何订阅 */
|
||||
/** 订阅关联的收录版本:同作品、属于该订阅或不属于任何订阅 */
|
||||
export const slotsOfSub = (all: readonly AcquisitionSlotRow[], sub: Pick<SubscriptionRow, "id" | "media_entity_id">) =>
|
||||
all
|
||||
.filter(
|
||||
@@ -287,7 +287,7 @@ export async function buildSubscriptionModels(
|
||||
if (p?.episode !== undefined || /^d\d{8}$/.test(k)) put(k, { assetId: id, quality: q, pri });
|
||||
}
|
||||
}
|
||||
// 分集账本优先(质量与 pri 按版本目标记)
|
||||
// 分集账本优先(质量与 pri 按收录版本记)
|
||||
for (const r of ledger) {
|
||||
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
|
||||
if (r.kind === "episode" && r.episode_number !== null)
|
||||
@@ -542,7 +542,7 @@ export async function buildSubscriptionModels(
|
||||
upgrade: m.upgrade,
|
||||
});
|
||||
if (isMovie) {
|
||||
// 电影:该版本目标不要求的内容版本不算缺
|
||||
// 电影:该收录版本不要求的内容版本不算缺
|
||||
for (const c of r.cells)
|
||||
if (c.state === "missing" && !m.requiredEditions.includes(c.unitKey.slice("edition:".length))) {
|
||||
c.state = "skipped";
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function namingByVersion(exec: Exec, version: number): Promise<Nami
|
||||
return { movie: t.movie, tv: t.tv, variety: t.variety, version: row.version };
|
||||
}
|
||||
|
||||
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 版本目标名 */
|
||||
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 收录版本名 */
|
||||
export async function loadNamingEntity(
|
||||
exec: Exec,
|
||||
entity: MediaEntityRow,
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
|
||||
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
|
||||
}
|
||||
if (!roots.some((root) => within(r, root)))
|
||||
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下:${p}`, p);
|
||||
throw new PathGuardError(`源路径不在已登记的媒体库目录或下载器路径映射之下:${p}`, p);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
statSafe,
|
||||
} from "@umt/adapters";
|
||||
import type { ActorType, OrganizationRecord, OrganizationTrigger, PathBinding } from "@umt/contracts";
|
||||
import { reasonSentence, reasonText } from "@umt/contracts";
|
||||
import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecordRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
@@ -415,7 +416,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {});
|
||||
await decide_({
|
||||
kind: "identity_ambiguous",
|
||||
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
||||
title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
||||
summary: takeoverSummary(takeover),
|
||||
subjectType: "organization_record",
|
||||
subjectId: rid.recordId,
|
||||
@@ -500,8 +501,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
await decide_({
|
||||
kind: "other",
|
||||
severity: "critical",
|
||||
title: `库根离线:${root.name}`,
|
||||
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
|
||||
title: `媒体库目录离线:${root.name}`,
|
||||
summary: "媒体库目录不可访问或未挂载,整理已暂停。文件不会因此被判为缺失",
|
||||
subjectType: "library_root",
|
||||
subjectId: root.id,
|
||||
dedupeKey: `library_root_offline:${root.id}`,
|
||||
@@ -759,9 +760,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
const existsConflict = blocking.some((c) => c.kind === "target_exists");
|
||||
await upsertDecision(d, trx, {
|
||||
kind: hnrConflict ? "approval_required" : "path_conflict",
|
||||
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
|
||||
title: hnrConflict
|
||||
? `做种义务未满足,已阻止移动:${entity.title}`
|
||||
: `${reasonText(blocking[0]?.kind)?.text ?? "整理冲突"}:${entity.title}`,
|
||||
summary: blocking
|
||||
.map((c) => c.detail ?? c.kind)
|
||||
.map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind)
|
||||
.join(";")
|
||||
.slice(0, 500),
|
||||
subjectType: "organization_record",
|
||||
@@ -911,7 +914,7 @@ export async function executeRecord(
|
||||
const fsr = fsRuntime(d);
|
||||
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
||||
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
||||
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
|
||||
if (health.kind === "offline") throw new Error(`媒体库目录离线(${health.reason}),稍后重试`);
|
||||
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
|
||||
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
||||
if (fsr.partialMaxAgeMs > 0)
|
||||
@@ -1730,7 +1733,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
|
||||
}
|
||||
|
||||
/** 关闭下载任务的"无法确定作品 / 整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
|
||||
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
|
||||
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
|
||||
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
|
||||
const rows = await trx
|
||||
@@ -1857,7 +1860,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
||||
.join(";");
|
||||
const reason = (code: string | null, err: unknown) =>
|
||||
code
|
||||
? { code: code.split(":")[0]!, message: conflictText(err) || code }
|
||||
? { code: code.split(":")[0]!, message: conflictText(err) || reasonSentence(code, code) }
|
||||
: err
|
||||
? {
|
||||
code: (err as { code?: string }).code ?? "error",
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ApiException } from "../../../http/errors.ts";
|
||||
import { hashJson, toApi } from "../util.ts";
|
||||
import type { AoDeps, Exec } from "./common.ts";
|
||||
|
||||
// 规则组(W9-D):目录加载、引用链解析(版本目标 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
|
||||
// 规则组(W9-D):目录加载、引用链解析(收录版本 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
|
||||
|
||||
export interface RuleCatalog {
|
||||
/** 未删除的全部规则组(含停用,解析链时再过滤) */
|
||||
@@ -132,17 +132,17 @@ export function defaultChainIds(cat: RuleCatalog, kind: RuleGroupKind): string[]
|
||||
}
|
||||
|
||||
export interface ResolveInput extends RuleTarget {
|
||||
/** 版本目标 rule_group_ids(非空时优先) */
|
||||
/** 收录版本 rule_group_ids(非空时优先) */
|
||||
slotRuleGroupIds?: readonly string[] | undefined;
|
||||
/** 订阅 params.ruleGroupIds */
|
||||
subRuleGroupIds?: readonly string[] | undefined;
|
||||
kind: "acquisition" | "search";
|
||||
/** 版本目标开了升级:叠加洗版默认链 */
|
||||
/** 收录版本开了升级:叠加洗版默认链 */
|
||||
upgrade?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
|
||||
* 引用链:订阅私有组 → (收录版本 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
|
||||
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
|
||||
*/
|
||||
export function resolveRuleChain(
|
||||
@@ -190,7 +190,7 @@ export function chainHard(rows: readonly Pick<RuleGroupRow, "hard">[]): SlotHard
|
||||
return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) };
|
||||
}
|
||||
|
||||
/** 版本目标硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
|
||||
/** 收录版本硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
|
||||
export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions {
|
||||
if (!extra.include?.length && !extra.exclude?.length) return base;
|
||||
const out: SlotHardConditions = { ...base };
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TRACKING_SUB_STATUSES } from "./subscriptions.ts";
|
||||
|
||||
// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date,
|
||||
// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。
|
||||
// 状态取主版本那一行的格(没有版本目标时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
|
||||
// 状态取主版本那一行的格(没有收录版本时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
|
||||
// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。
|
||||
// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { INFLIGHT_PLAN_STATUSES } from "./subscriptions.ts";
|
||||
|
||||
// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。
|
||||
// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled,
|
||||
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉版本目标的延后记录,标记立即重评。
|
||||
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉收录版本的延后记录,标记立即重评。
|
||||
// 已入库资产、分集账本、总集数(含锁定)不动。
|
||||
|
||||
/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */
|
||||
|
||||
@@ -19,10 +19,10 @@ import { allOfAny } from "./rules.ts";
|
||||
// total_episode (+ manual_total_episode) → 保存后写季总集数(manual 锁定)
|
||||
// include / exclude(字符串或数组)→ include[] / exclude[](正则白名单校验)
|
||||
// keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds
|
||||
// best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片)
|
||||
// best_version / best_version_full → 订阅收录版本的升级策略(按集 / 整季;电影 = 单片)
|
||||
// autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay}
|
||||
// resolution / quality(resource_type) / effect(MP 标签或正则)→ resolution / source / effect 质量预设
|
||||
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的版本目标。
|
||||
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的收录版本。
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface NormalizedParams {
|
||||
params: SubscriptionParams;
|
||||
/** MP total_episode:保存后写入订阅季的总集数 */
|
||||
episodeTotal?: { total: number; locked: boolean };
|
||||
/** MP best_version:写到订阅版本目标的升级策略 */
|
||||
/** MP best_version:写到订阅收录版本的升级策略 */
|
||||
upgrade?: UpgradeStrategy;
|
||||
/** 被改写的兼容字段(调试/审计用) */
|
||||
mapped: string[];
|
||||
@@ -141,7 +141,7 @@ export function parseMpEffect(v: string): SubscriptionEffect | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 质量预设 → 版本目标硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
|
||||
/** 质量预设 → 收录版本硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
|
||||
export interface PresetConditions {
|
||||
hard: Record<string, unknown>;
|
||||
soft: Record<string, unknown>;
|
||||
@@ -214,8 +214,8 @@ export function presetConditions(p: Pick<SubscriptionParams, "resolution" | "sou
|
||||
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/**
|
||||
* 质量预设写到该订阅自己的版本目标:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
|
||||
* 只有当版本目标上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
|
||||
* 质量预设写到该订阅自己的收录版本:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
|
||||
* 只有当收录版本上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
|
||||
*/
|
||||
export async function applySlotPreset(
|
||||
exec: Exec,
|
||||
@@ -461,7 +461,7 @@ export async function normalizeSubscriptionParams(
|
||||
// ── 库根存在性与正则白名单 ──
|
||||
if (p.libraryRootId) {
|
||||
const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst();
|
||||
if (!r) bad(`库根不存在:${p.libraryRootId}`);
|
||||
if (!r) bad(`媒体库目录不存在:${p.libraryRootId}`);
|
||||
}
|
||||
for (const k of ["include", "exclude"] as const)
|
||||
for (const pat of p[k] ?? []) {
|
||||
@@ -481,7 +481,7 @@ export async function normalizeSubscriptionParams(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** best_version 等 → 订阅版本目标的升级策略(保留其余升级参数;revision+1) */
|
||||
/** best_version 等 → 订阅收录版本的升级策略(保留其余升级参数;revision+1) */
|
||||
export async function applySlotUpgrade(
|
||||
exec: Exec,
|
||||
input: { subscriptionId: string; entityId: string; strategy: UpgradeStrategy },
|
||||
|
||||
@@ -709,7 +709,7 @@ export async function sweepMetadataRefresh(d: AoDeps): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅指定的库根(params.libraryRootId):下载计划所属版本目标的订阅优先,否则取该作品最近的未取消订阅。
|
||||
* 订阅指定的库根(params.libraryRootId):下载计划所属收录版本的订阅优先,否则取该作品最近的未取消订阅。
|
||||
* 整理时只在作品没有路径绑定时使用(首次建绑定)。
|
||||
*/
|
||||
export async function subscriptionLibraryRoot(
|
||||
|
||||
@@ -204,13 +204,13 @@ export function createAuthorizer(deps: {
|
||||
if (d.id === "agent.spawn") {
|
||||
return runId && (ctx.source === "agent" || ctx.source === "task")
|
||||
? { allowed: true }
|
||||
: { allowed: false, reason: "只有 Agent 会话可以派生子会话" };
|
||||
: { allowed: false, reason: "只有智能体会话可以派生子会话" };
|
||||
}
|
||||
// agent.propose_options 只登记一个待用户选择的事项(无外部副作用);能否问用户由处理器校验
|
||||
if (d.id === "agent.propose_options") {
|
||||
return runId && (ctx.source === "agent" || ctx.source === "task")
|
||||
? { allowed: true }
|
||||
: { allowed: false, reason: "只有 Agent 会话可以让用户选方案" };
|
||||
: { allowed: false, reason: "只有智能体会话可以让用户选方案" };
|
||||
}
|
||||
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
|
||||
if (d.id === "metadata.enrich") return { allowed: true };
|
||||
|
||||
@@ -62,7 +62,7 @@ export function registerDownloadRecoveryCapabilities(
|
||||
.executeTakeFirst();
|
||||
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
|
||||
if (!dt.managed || dt.removed_at || dt.status === "removed")
|
||||
throw new CapabilityError("precondition", "未纳管或已移除的下载任务不做自动恢复");
|
||||
throw new CapabilityError("precondition", "外部或已移除的下载任务不做自动恢复");
|
||||
const status = dt.status as DownloadStatus;
|
||||
if (input.action === "recheck" && !RECHECK_FROM.includes(status))
|
||||
throw new CapabilityError("precondition", `当前状态 ${status} 不需要重新校验`);
|
||||
|
||||
@@ -59,7 +59,8 @@ export async function upstreamComplete(
|
||||
sessionId: `umt-enrich-${randomUUID()}`,
|
||||
},
|
||||
);
|
||||
if (res.stopReason === "error" || res.stopReason === "aborted") throw new Error(res.errorMessage ?? "上游返回错误");
|
||||
if (res.stopReason === "error" || res.stopReason === "aborted")
|
||||
throw new Error(res.errorMessage ?? "模型服务返回错误");
|
||||
const text = res.content
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
@@ -87,7 +88,7 @@ export function registerMetadataCapabilities(
|
||||
const agent = await ctx.settings.get("agent").catch(() => null);
|
||||
const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) };
|
||||
if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search"))
|
||||
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → Agent → 网页搜索里开启后再补全");
|
||||
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启后再补全");
|
||||
// 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖
|
||||
const sub = (scope: string) => ({
|
||||
actorId: scope,
|
||||
|
||||
@@ -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()}`}`;
|
||||
|
||||
@@ -52,7 +52,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
|
||||
key: "auth.cleanup",
|
||||
defaults: {
|
||||
name: "登录会话清理",
|
||||
description: "删除 7 天前的登录尝试和 30 天前过期或吊销的会话",
|
||||
description: "删除 7 天前的登录尝试和 30 天前失效的会话",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs },
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@ const test = (path: string, summary: string, tag: string) => ({
|
||||
|
||||
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
|
||||
const NOUN: Record<Table, string> = {
|
||||
library_root: "库根",
|
||||
library_root: "媒体库目录",
|
||||
downloader_instance: "下载器实例",
|
||||
media_server_instance: "媒体服务器实例",
|
||||
path_mapping: "路径映射",
|
||||
@@ -72,7 +72,7 @@ const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"]
|
||||
|
||||
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
|
||||
|
||||
const LR = crudDefs("/library-roots", "库根", {
|
||||
const LR = crudDefs("/library-roots", "媒体库目录", {
|
||||
row: LibraryRoot,
|
||||
create: LibraryRootCreate,
|
||||
update: LibraryRootUpdate,
|
||||
@@ -182,7 +182,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
} catch (e) {
|
||||
throw new ApiException(
|
||||
"precondition_failed",
|
||||
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
|
||||
`媒体库目录不可写(需要目录存在且可写入标记文件 ${name}):${errMessage(e)}`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
@@ -216,7 +216,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
|
||||
endpoint(
|
||||
app,
|
||||
test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"),
|
||||
test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 哨兵文件)", "library-roots"),
|
||||
async ({ params, actor }) => {
|
||||
const row = await getRow("library_root", params.id);
|
||||
const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
|
||||
|
||||
@@ -149,7 +149,7 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
|
||||
expect(files).toHaveLength(1);
|
||||
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
|
||||
|
||||
// 识别到作品后再下载:走同一流程(无版本目标 → no_slot);同一文件第二次捕获标 duplicate、不提交
|
||||
// 识别到作品后再下载:走同一流程(无收录版本 → no_slot);同一文件第二次捕获标 duplicate、不提交
|
||||
const entity = await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
||||
|
||||
@@ -1579,7 +1579,7 @@ export class BrowserService {
|
||||
const give = opts.wantImage && !blocked && (!sensitive || opts.sensitiveAllowed);
|
||||
let note: string | null = null;
|
||||
if (blocked) note = "页面要求二次验证 / 短信 / 本人设备确认:不自动处理,会话已转等待处理,由人在原站视图接管";
|
||||
else if (!opts.wantImage) note = "当前上游模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
|
||||
else if (!opts.wantImage) note = "当前模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
|
||||
else if (sensitive && !opts.sensitiveAllowed)
|
||||
note =
|
||||
"登录页 / 含密码框的截图属于敏感截图,默认不交给模型(需要 browser.sensitive_screenshot 持续授权并传 allowSensitive);下面是已脱敏的文字快照";
|
||||
@@ -1801,7 +1801,7 @@ const REASON_TEXT: Record<string, string> = {
|
||||
challenge_unsolved: "自动处理页面挑战未成功",
|
||||
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
||||
lease_expired: "人工接管超时未交还",
|
||||
agent_request: "Agent 请求人工处理",
|
||||
agent_request: "智能体请求人工处理",
|
||||
human_released_waiting: "人工交还时选择继续等待",
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -183,7 +183,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
key: "cache.sweep",
|
||||
defaults: {
|
||||
name: "缓存清理",
|
||||
description: "删除 PG 缓存里已过期的条目(TMDB 请求与识别缓存)",
|
||||
description: "删除过期的 TMDB 与识别缓存",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 10 * 60_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -194,7 +194,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
key: "cache.images_clean",
|
||||
defaults: {
|
||||
name: "图片缓存清理",
|
||||
description: "删除超过保留天数的图片代理缓存文件(对照 MP 启动时的 clear_temp)",
|
||||
description: "删除过期的海报缓存",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||
},
|
||||
@@ -208,7 +208,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
key: "cache.images_lru",
|
||||
defaults: {
|
||||
name: "图片缓存容量控制",
|
||||
description: "图片代理缓存超过容量预算(存储设置,缺省 20 GB)时,按最久未访问删除到预算的 90%",
|
||||
description: "海报缓存超过容量上限时,删掉最久没用的",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 30 * 60_000 },
|
||||
firstRun: "after_interval",
|
||||
|
||||
@@ -231,7 +231,7 @@ export async function organizeFiles(
|
||||
if (it.targetDir) {
|
||||
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
|
||||
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
|
||||
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录:${it.targetDir.path || "/"}`);
|
||||
throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`);
|
||||
if (!(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
|
||||
targetDir = join(t.def.hostPath, t.rel);
|
||||
@@ -264,10 +264,10 @@ export async function organizeFiles(
|
||||
if (slotId) {
|
||||
const s = slots.get(slotId);
|
||||
if (!s || s.media_entity_id !== entityId) {
|
||||
warnings.push(`${r.rel}:版本目标 ${slotId} 不属于该作品,未应用`);
|
||||
warnings.push(`${r.rel}:收录版本 ${slotId} 不属于该作品,未应用`);
|
||||
slotId = null;
|
||||
} else if (BUSY_SLOT.has(s.status)) {
|
||||
warnings.push(`${r.rel}:版本目标正在获取中(${s.status}),不改其状态`);
|
||||
warnings.push(`${r.rel}:收录版本正在获取中(${s.status}),不改其状态`);
|
||||
slotId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function scrape(
|
||||
.where("media_entity_id", "=", e.id)
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirst();
|
||||
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
|
||||
if (!registered) throw new FilesError("bad_request", "作品没有已入库文件。先识别并整理入库,再刮削");
|
||||
const { runId } = await d.ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: e.id },
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function transfer(
|
||||
const t = await resolveIn(d, body.target.root, body.target.path);
|
||||
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
|
||||
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录`);
|
||||
|
||||
const sources: Resolved[] = [];
|
||||
for (const it of body.items) {
|
||||
|
||||
@@ -157,7 +157,7 @@ const BLOCK_ERR: Record<
|
||||
> = {
|
||||
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
||||
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
||||
needs_retire: ["needs_retire", "含已登记资产,需勾选「同时退役资产」"],
|
||||
needs_retire: ["needs_retire", "含已入库文件,需勾选「同时从媒体库移除」"],
|
||||
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
|
||||
root_unavailable: ["root_unavailable", "根不可用"],
|
||||
not_found: ["not_found", "不存在"],
|
||||
@@ -276,8 +276,8 @@ export async function deleteFiles(
|
||||
for (const s of slots)
|
||||
await upsertDecision(d.ao, trx, {
|
||||
kind: "other",
|
||||
title: `版本目标的文件已删除:${s.name}`,
|
||||
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
|
||||
title: `收录版本的文件已删除:${s.name}`,
|
||||
summary: "满足该收录版本的文件已在文件管理中删除,收录版本改为需关注。确认是否重新获取",
|
||||
subjectType: "acquisition_slot",
|
||||
subjectId: s.id,
|
||||
dedupeKey: `files_delete_slot:${s.id}`,
|
||||
@@ -509,7 +509,7 @@ export async function restoreTrash(
|
||||
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
|
||||
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
|
||||
if (target && !(await isWritableTarget(target.def, target.rootReal)))
|
||||
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
|
||||
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 可写的媒体库目录`);
|
||||
for (const id of body.ids) {
|
||||
try {
|
||||
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
|
||||
@@ -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: "订阅登录快过期时提前续期",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
||||
key: "maintain.backup",
|
||||
defaults: {
|
||||
name: "备份",
|
||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
||||
description: "数据库与数据目录备份,目前在服务器上用命令执行",
|
||||
group: "maintain",
|
||||
schedule: { type: "cron", cron: "30 3 * * *" },
|
||||
enabled: false,
|
||||
|
||||
@@ -296,7 +296,7 @@ export class Scheduler {
|
||||
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
|
||||
limit 50`.execute(this.db);
|
||||
for (const r of rows.rows) {
|
||||
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
|
||||
const summary = (r.summary ?? "").trim() || `智能体运行结束:${r.status}`;
|
||||
await this.db
|
||||
.updateTable("scheduled_job_run")
|
||||
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
|
||||
|
||||
@@ -23,7 +23,7 @@ const action = (id: string, group: string, title: string, href: string, keywords
|
||||
export const SEARCH_ACTIONS: readonly SearchAction[] = [
|
||||
page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]),
|
||||
page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]),
|
||||
page("agent", "概览", "Agent 会话", "/console/agent", ["agent", "会话"]),
|
||||
page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]),
|
||||
page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]),
|
||||
page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]),
|
||||
page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]),
|
||||
@@ -34,13 +34,13 @@ export const SEARCH_ACTIONS: readonly SearchAction[] = [
|
||||
page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]),
|
||||
page("files", "整理", "文件管理", "/console/files", ["files", "文件"]),
|
||||
page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]),
|
||||
page("library", "整理", "媒体概览", "/console/library", ["library", "媒体库", "作品"]),
|
||||
page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]),
|
||||
page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]),
|
||||
page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]),
|
||||
page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]),
|
||||
page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]),
|
||||
action("new-subscription", "订阅", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]),
|
||||
action("new-agent-run", "概览", "新建 Agent 会话", "/console/agent?new=1", ["agent", "委托"]),
|
||||
action("new-agent-run", "概览", "新建智能体会话", "/console/agent?new=1", ["agent", "委托"]),
|
||||
action("add-site", "资源", "添加站点", "/console/sites?new=1", ["site", "站点"]),
|
||||
action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]),
|
||||
action("new-schedule", "概览", "新建巡检", "/console/tasks?tab=plan&new=1", ["巡检", "agent_check"]),
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function localInfo(ctx: ServerContext, ids: string[]): Promise<Map<
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 各作品版本目标按状态计数 */
|
||||
/** 各作品收录版本按状态计数 */
|
||||
export async function slotCountsFor(ctx: ServerContext, ids: string[]): Promise<Map<string, SlotStatusCounts>> {
|
||||
const out = new Map<string, SlotStatusCounts>();
|
||||
if (ids.length === 0) return out;
|
||||
|
||||
@@ -61,15 +61,15 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
return { summary: `${await fn(rt)} 项` };
|
||||
},
|
||||
});
|
||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
||||
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
|
||||
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
||||
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
|
||||
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
|
||||
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
||||
registerScheduledJob(ctx, {
|
||||
key: "sites.user_data",
|
||||
defaults: {
|
||||
name: "站点数据刷新",
|
||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
||||
description: "刷新站点账号的上传、下载和分享率;登录失效的账密账号自动重登一次",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -83,7 +83,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.health_check",
|
||||
defaults: {
|
||||
name: "站点健康检测",
|
||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
||||
description: "检查到期的站点能否打开、是否还在登录(每个站点按自己的检测间隔),结果在站点页",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -95,8 +95,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: BACKFILL_LANGUAGES_JOB,
|
||||
defaults: {
|
||||
name: "元数据语言回刷",
|
||||
description:
|
||||
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
|
||||
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||
firstRun: "now",
|
||||
|
||||
@@ -79,7 +79,7 @@ export function enrichRoutes(app: ModuleApp, rt: () => SourcesRuntime): void {
|
||||
path: "/identity-pool/{id}/enrich/apply",
|
||||
scope: "admin",
|
||||
auth: "session",
|
||||
summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联待识别资源(只接受控制台管理员会话)",
|
||||
summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联未识别资源(只接受控制台管理员会话)",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: EnrichApplyInput,
|
||||
|
||||
@@ -546,7 +546,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/identity-pool",
|
||||
scope: "read",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
summary: "未识别资源(可按原始标题检索;候选不建正式作品)",
|
||||
tags: T_ID,
|
||||
query: IdentityPoolQuery,
|
||||
response: cursorPage(IdentityPoolItem),
|
||||
@@ -623,7 +623,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/assign",
|
||||
scope: "organize.execute",
|
||||
summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份",
|
||||
summary: "为未识别资源手动指定候选 / 本地作品 / 外部身份",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: ReleaseIdentityAssign,
|
||||
@@ -635,7 +635,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.select(["source_release_id"])
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
||||
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
|
||||
return assignReleaseIdentity(rt(), pool.source_release_id, body, actor);
|
||||
},
|
||||
);
|
||||
@@ -645,7 +645,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/dismiss",
|
||||
scope: "organize.execute",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
summary: "忽略未识别资源(不再自动复查)",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: z.object({ note: z.string().max(2000).optional() }),
|
||||
@@ -670,7 +670,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.select(["source_release_id", "attempts"])
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
||||
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
// 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径)
|
||||
|
||||
@@ -43,7 +43,7 @@ const MAX_SEARCHES = 2;
|
||||
const SITE_FILTER_DOMAINS = 6;
|
||||
/** 交给模型的每页正文上限 */
|
||||
const PAGE_CHARS_FOR_MODEL = 6000;
|
||||
export const ENRICH_NOTICE = "字段来自网页与 Agent 抽取,不是 TMDB 数据;应用前核对出处";
|
||||
export const ENRICH_NOTICE = "字段来自网页与智能体抽取,不是 TMDB 数据;应用前核对出处";
|
||||
|
||||
export class EnrichError extends Error {
|
||||
constructor(
|
||||
@@ -411,7 +411,7 @@ async function resolveQuery(ctx: Pick<ServerContext, "db">, input: MetadataEnric
|
||||
.select(["p.id", "p.query", "r.title as release_title"])
|
||||
.where("p.id", "=", input.poolEntryId)
|
||||
.executeTakeFirst();
|
||||
if (!p) throw new EnrichError("not_found", "待识别条目不存在");
|
||||
if (!p) throw new EnrichError("not_found", "未识别条目不存在");
|
||||
const q = (p.query ?? {}) as { title?: string | null; titleCn?: string | null; year?: number | null };
|
||||
const title = (q.titleCn || q.title || "").trim();
|
||||
if (!title) throw new EnrichError("precondition", "这条资源没有解析出标题,无法补全");
|
||||
@@ -737,7 +737,7 @@ export async function applyEnrichment(
|
||||
.select(["id", "source_release_id", "status"])
|
||||
.where("id", "=", poolEntryId)
|
||||
.executeTakeFirst();
|
||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
||||
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
|
||||
const row = await ctx.db
|
||||
.selectFrom("metadata_enrichment")
|
||||
.selectAll()
|
||||
|
||||
@@ -44,9 +44,12 @@ async function entityForExternal(
|
||||
const hit = await entityByExternal(exec, ref);
|
||||
if (hit) return { entityId: hit, created: false };
|
||||
if (ref.provider !== "tmdb")
|
||||
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
|
||||
throw new ApiException(
|
||||
"bad_request",
|
||||
`未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 加入媒体库`,
|
||||
);
|
||||
const tmdb = await rt.tmdb();
|
||||
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
|
||||
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部编号加入媒体库");
|
||||
let detail: Awaited<ReturnType<typeof tmdb.details>>;
|
||||
try {
|
||||
detail = await tmdb.details(ref.entityType, ref.externalId);
|
||||
@@ -480,7 +483,7 @@ export async function dismissPoolEntry(rt: SourcesRuntime, poolId: string, actor
|
||||
.where("id", "=", poolId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "待识别条目不存在");
|
||||
if (!row) throw new ApiException("not_found", "未识别条目不存在");
|
||||
await trx
|
||||
.updateTable("source_release")
|
||||
.set({ resolution_status: "rejected" })
|
||||
|
||||
@@ -25,7 +25,7 @@ function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
|
||||
{
|
||||
key: "manual_tmdb",
|
||||
label: "手动指定 TMDB ID",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
description: "按 TMDB 电影或剧集 ID 加入媒体库并关联,加人工锁",
|
||||
input: { sourceReleaseId, requires: "tmdb" },
|
||||
},
|
||||
{
|
||||
@@ -79,7 +79,7 @@ function titleFor(
|
||||
one: string,
|
||||
work: string | null,
|
||||
) {
|
||||
const what = kind === "identity_ambiguous" ? "待确认身份" : "待识别";
|
||||
const what = kind === "identity_ambiguous" ? "没识别出作品" : "未识别";
|
||||
if (n <= 1) return `${what}:${one}`;
|
||||
return `来源「${siteName}」的 ${n} 条资源${what}${work ? `:${work}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
key: "system.heartbeat_cleanup",
|
||||
defaults: {
|
||||
name: "进程心跳清理",
|
||||
description: "删除 24 小时没有心跳的进程行和超过保留期(存储设置,缺省 24 小时)的心跳采样",
|
||||
description: "删除过期的进程状态记录",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs },
|
||||
},
|
||||
|
||||
@@ -105,8 +105,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "observation",
|
||||
jobId: "storage.retention.observation",
|
||||
name: "观察记录清理",
|
||||
description: "删除超过保留期的 Feed 观察记录(observation,按 created_at)",
|
||||
name: "RSS 抓取记录清理",
|
||||
description: "删除过期的 RSS 抓取记录",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.observation} 天`,
|
||||
async run(c) {
|
||||
@@ -118,8 +118,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "sse_event",
|
||||
jobId: "storage.retention.sse_event",
|
||||
name: "SSE 事件清理",
|
||||
description: "删除超过保留期的 SSE 补读事件(sse_event,按 at;发布方另按条数裁剪)",
|
||||
name: "实时事件清理",
|
||||
description: "删除过期的页面实时推送记录",
|
||||
everyMs: HOUR,
|
||||
retention: (s) => `${s.retentionDays.sseEvent} 天`,
|
||||
async run(c) {
|
||||
@@ -132,7 +132,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "task_run",
|
||||
jobId: "storage.retention.task_run",
|
||||
name: "任务运行清理",
|
||||
description: "删除超过保留期、已结束的业务任务(task)与引擎 run(含步骤、运行元数据)",
|
||||
description: "删除过期的已结束任务",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.taskRun} 天`,
|
||||
async run(c) {
|
||||
@@ -167,8 +167,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "agent_event",
|
||||
jobId: "storage.retention.agent_event",
|
||||
name: "Agent 事件清理",
|
||||
description: "删除超过保留期、会话已结束的 Agent 事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
|
||||
name: "智能体事件清理",
|
||||
description: "删除已结束会话的过期消息,会话本身保留",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.agentEvent} 天`,
|
||||
async run(c) {
|
||||
@@ -182,14 +182,14 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
sql`created_at < ${cutoff} and status = 'consumed' and ${ended}`,
|
||||
c.batch,
|
||||
);
|
||||
return `Agent 事件 ${ev.deleted}、消息 ${msg.deleted} 行已删除${ev.truncated || msg.truncated ? ",未删完,下次继续" : ""}`;
|
||||
return `智能体事件 ${ev.deleted}、消息 ${msg.deleted} 行已删除${ev.truncated || msg.truncated ? ",未删完,下次继续" : ""}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
target: "audit",
|
||||
jobId: "storage.retention.audit",
|
||||
name: "审计清理",
|
||||
description: "删除超过保留期的审计事件(audit_event,能力调用审计按能力统计的保留期另算)",
|
||||
description: "删除过期的审计日志",
|
||||
everyMs: 24 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.audit} 天`,
|
||||
async run(c) {
|
||||
@@ -207,7 +207,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "capability_stats",
|
||||
jobId: "storage.retention.capability_stats",
|
||||
name: "能力统计清理",
|
||||
description: "删除超过保留期的能力调用审计(audit_event target_type=capability)与已结束的能力调用记录",
|
||||
description: "删除过期的能力调用记录",
|
||||
everyMs: 24 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
|
||||
async run(c) {
|
||||
@@ -230,8 +230,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "identity_pool",
|
||||
jobId: "storage.retention.identity_pool",
|
||||
name: "待识别池归档",
|
||||
description: "待识别池里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
|
||||
name: "未识别资源归档",
|
||||
description: "长期没有新线索的未识别资源归档,仍可手动指定",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
|
||||
async run(c) {
|
||||
@@ -246,14 +246,14 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
archived += n;
|
||||
if (n < size) break;
|
||||
}
|
||||
return `归档 ${archived} 条待识别条目`;
|
||||
return `归档 ${archived} 条未识别条目`;
|
||||
},
|
||||
},
|
||||
{
|
||||
target: "analyze",
|
||||
jobId: "storage.analyze",
|
||||
name: "统计信息更新",
|
||||
description: "对全库执行 ANALYZE,刷新查询规划用的统计信息(每周)",
|
||||
name: "数据库维护",
|
||||
description: "每周刷新数据库统计信息",
|
||||
everyMs: 7 * 24 * HOUR,
|
||||
retention: () => null,
|
||||
async run(c) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -158,7 +158,7 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
|
||||
decisionId,
|
||||
kind: "identity_unresolved",
|
||||
severity: "info",
|
||||
title: "待识别",
|
||||
title: "未识别",
|
||||
});
|
||||
});
|
||||
const deadline = Date.now() + 20_000;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
@@ -512,7 +512,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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -179,7 +179,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());
|
||||
@@ -228,7 +228,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")
|
||||
@@ -336,7 +336,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 });
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -155,9 +155,9 @@ export const DOWNLOAD_STATUS_TONE: Record<DownloadStatus, "success" | "warning"
|
||||
|
||||
export const PAUSE_REASON_LABEL = {
|
||||
user: "用户暂停",
|
||||
threshold: "阈值暂停",
|
||||
threshold: "下载盘满暂停",
|
||||
policy: "策略暂停",
|
||||
agent: "Agent 暂停",
|
||||
agent: "智能体暂停",
|
||||
} as const;
|
||||
|
||||
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
type TaskStatus,
|
||||
} from "@umt/contracts";
|
||||
import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
|
||||
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
||||
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
|
||||
import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts";
|
||||
import { apiParsed } from "./parse.ts";
|
||||
|
||||
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
||||
@@ -23,7 +24,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
|
||||
const DecisionPage = cursorPage(Decision);
|
||||
|
||||
const RESOLUTION_LABEL = {
|
||||
unresolved: "待识别",
|
||||
unresolved: "未识别",
|
||||
resolved: "已识别",
|
||||
ambiguous: "有歧义,待确认",
|
||||
unidentifiable: "无法识别",
|
||||
@@ -173,7 +174,7 @@ export function buildTimeline(input: {
|
||||
stage: "plan",
|
||||
at: plan.createdAt,
|
||||
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
||||
detail: plan.statusReason,
|
||||
detail: humanizeReason(plan.statusReason),
|
||||
tone: "neutral",
|
||||
});
|
||||
if (plan.updatedAt !== plan.createdAt)
|
||||
@@ -182,7 +183,7 @@ export function buildTimeline(input: {
|
||||
stage: "plan",
|
||||
at: plan.updatedAt,
|
||||
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
||||
detail: plan.statusReason,
|
||||
detail: humanizeReason(plan.statusReason),
|
||||
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
||||
});
|
||||
}
|
||||
@@ -199,7 +200,7 @@ export function buildTimeline(input: {
|
||||
key: "dl-pause",
|
||||
stage: "download",
|
||||
at: task.updatedAt,
|
||||
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
|
||||
title: task.pauseReason === "threshold" ? "下载盘超过暂停线,已暂停" : PAUSE_REASON_LABEL[task.pauseReason],
|
||||
tone: "warning",
|
||||
ongoing: task.status === "paused",
|
||||
});
|
||||
@@ -239,7 +240,15 @@ export function buildTimeline(input: {
|
||||
stage: "organization",
|
||||
at: r.startedAt ?? r.createdAt,
|
||||
title: `整理 ${organizationActionLabel(r.action)}:${organizationLabel(r.status).label}`,
|
||||
detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`,
|
||||
detail:
|
||||
[
|
||||
c.total ? `${c.succeeded}/${c.total} 个文件成功` : null,
|
||||
c.failed ? `${c.failed} 个失败` : null,
|
||||
c.skipped ? `${c.skipped} 个跳过` : null,
|
||||
r.reason ? reasonLine(r.reason) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || null,
|
||||
tone: RECORD_TONE[r.status],
|
||||
href: `/console/organize/${r.id}`,
|
||||
ongoing: r.status === "running" || r.status === "queued",
|
||||
@@ -260,7 +269,7 @@ export function buildTimeline(input: {
|
||||
key: `agent-${d.id}`,
|
||||
stage: "agent",
|
||||
at: d.updatedAt,
|
||||
title: "Agent 已介入调查",
|
||||
title: "智能体已介入调查",
|
||||
tone: "neutral",
|
||||
href: `/console/agent/${d.agentRunId}`,
|
||||
});
|
||||
@@ -275,7 +284,9 @@ export function buildTimeline(input: {
|
||||
at: t.startedAt ?? t.createdAt,
|
||||
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`,
|
||||
detail:
|
||||
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
|
||||
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, humanizeReason(t.waitReason), humanizeReason(err)]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || null,
|
||||
tone: TASK_STATUS_TONE[t.status],
|
||||
href: `/console/tasks/${encodeURIComponent(t.id)}`,
|
||||
ongoing: ACTIVE_TASK.includes(t.status),
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
useDecideApproval,
|
||||
useIsAdminSession,
|
||||
} from "~/api/agent.ts";
|
||||
import { DOWNLOAD_STATUS_LABEL, PLAN_STATUS_LABEL } from "~/api/downloads.ts";
|
||||
import { RECORD_STATUS_LABEL } from "~/api/records.ts";
|
||||
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
|
||||
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
|
||||
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
||||
@@ -51,8 +53,11 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
|
||||
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { formatUsd } from "~/components/schedules/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||
import { ToolCallIo } from "./tool-call-card-io.tsx";
|
||||
@@ -103,16 +108,13 @@ function CapabilityBadges({ id, extra }: { id: string; extra?: ReactNode }) {
|
||||
const m = capabilityMeta(id);
|
||||
return (
|
||||
<>
|
||||
{/* 只标需要注意的:会改动数据的动作和中高风险;只读、scope、幂等进技术信息 */}
|
||||
{m ? (
|
||||
<>
|
||||
<StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge>
|
||||
<StatusBadge>scope · {m.scope}</StatusBadge>
|
||||
<RiskBadge level={m.riskLevel} />
|
||||
{m.idempotent ? <StatusBadge>幂等</StatusBadge> : null}
|
||||
{m.kind !== "read" ? <StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge> : null}
|
||||
{m.riskLevel !== "low" ? <RiskBadge level={m.riskLevel} /> : null}
|
||||
</>
|
||||
) : (
|
||||
<StatusBadge>未登记能力</StatusBadge>
|
||||
)}
|
||||
) : null}
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
@@ -197,20 +199,21 @@ export function ToolCallCard({
|
||||
<ToolApproval
|
||||
status="rejected"
|
||||
tool={call.capabilityId}
|
||||
title={`服务端拒绝了「${meta?.title ?? call.capabilityId}」`}
|
||||
title={`未执行:${meta?.title ?? "这个操作"}`}
|
||||
badges={
|
||||
<CapabilityBadges
|
||||
id={call.capabilityId}
|
||||
extra={<StatusBadge tone="destructive">{authReason ? "授权校验未通过" : "白名单拦截"}</StatusBadge>}
|
||||
extra={<StatusBadge tone="destructive">{authReason ? "需要你批准" : "本次没有开放"}</StatusBadge>}
|
||||
/>
|
||||
}
|
||||
reason={authReason ?? call.reason ?? "未授权"}
|
||||
explanation={
|
||||
authReason
|
||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
|
||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
||||
? "会改动数据的操作要你批准:在对应步骤的审批卡上批准,或在设置 → 智能体里给它持续授权。"
|
||||
: "这次会话没有开放这个操作,或已用完调用次数。"
|
||||
}
|
||||
parameters={[
|
||||
{ id: "cap", label: "能力", value: call.capabilityId },
|
||||
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
||||
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
|
||||
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
|
||||
@@ -222,9 +225,8 @@ export function ToolCallCard({
|
||||
return (
|
||||
<ToolResult
|
||||
status={status}
|
||||
tool={call.capabilityId}
|
||||
tool={meta ? null : call.capabilityId}
|
||||
title={meta?.title ?? call.capabilityId}
|
||||
meta={durationText(call.startedAt, call.endedAt) ?? undefined}
|
||||
badges={
|
||||
<CapabilityBadges
|
||||
id={call.capabilityId}
|
||||
@@ -244,24 +246,39 @@ export function ToolCallCard({
|
||||
) : call.status === "running" ? (
|
||||
<p className="text-meta text-muted-foreground">等待结果…</p>
|
||||
) : null}
|
||||
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
||||
<dt className="text-muted-foreground">调用 ID</dt>
|
||||
<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" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
|
||||
</dd>
|
||||
{audit?.grantId || audit?.approvalId ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">授权依据</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
||||
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<ToolCallIo call={call} />
|
||||
<TechInfo>
|
||||
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
||||
<dt className="text-muted-foreground">能力</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
||||
{call.capabilityId}
|
||||
{meta
|
||||
? ` · ${KIND_LABEL[meta.kind] ?? meta.kind} · scope ${meta.scope}${meta.idempotent ? " · 幂等" : ""}`
|
||||
: ""}
|
||||
</dd>
|
||||
{durationText(call.startedAt, call.endedAt) ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">耗时</dt>
|
||||
<dd className="min-w-0 text-foreground/80">{durationText(call.startedAt, call.endedAt)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt className="text-muted-foreground">调用 ID</dt>
|
||||
<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" ? "服务端已生成,审计里没有取到" : "只读操作不记录")}
|
||||
</dd>
|
||||
{audit?.grantId || audit?.approvalId ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">授权依据</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
||||
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<ToolCallIo call={call} />
|
||||
</TechInfo>
|
||||
</ToolResult>
|
||||
);
|
||||
}
|
||||
@@ -652,7 +669,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 +695,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) =>
|
||||
@@ -773,35 +790,60 @@ export function BudgetBar({
|
||||
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
|
||||
: undefined);
|
||||
return (
|
||||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}>
|
||||
<Meter
|
||||
label="工具调用"
|
||||
used={toolCalls}
|
||||
max={run.budget.maxToolCalls}
|
||||
format={(n) => String(n)}
|
||||
exceeded={exceeded === "tool_calls"}
|
||||
/>
|
||||
<Meter
|
||||
label="token"
|
||||
used={u.totalTokens}
|
||||
max={run.budget.maxTokens}
|
||||
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
|
||||
exceeded={exceeded === "tokens"}
|
||||
/>
|
||||
<Meter
|
||||
label="时长"
|
||||
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
||||
max={run.budget.maxDurationSec}
|
||||
format={(n) => `${n}s`}
|
||||
exceeded={exceeded === "duration"}
|
||||
/>
|
||||
<Meter
|
||||
label="费用(估算)"
|
||||
used={u.costUsd}
|
||||
max={run.budget.maxCostUsd}
|
||||
format={(n) => `$${n.toFixed(3)}`}
|
||||
exceeded={exceeded === "cost"}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
|
||||
<Meter
|
||||
label="费用(估算)"
|
||||
used={u.costUsd}
|
||||
max={run.budget.maxCostUsd}
|
||||
format={(n) => formatUsd(n)}
|
||||
exceeded={exceeded === "cost"}
|
||||
/>
|
||||
<Meter
|
||||
label="时长"
|
||||
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
||||
max={run.budget.maxDurationSec}
|
||||
format={(n) => `${n} 秒`}
|
||||
exceeded={exceeded === "duration"}
|
||||
/>
|
||||
</div>
|
||||
{/* 调用次数与 token 超限时直接显示,否则收进技术信息 */}
|
||||
{exceeded === "tool_calls" || exceeded === "tokens" ? (
|
||||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
|
||||
<Meter
|
||||
label="工具调用"
|
||||
used={toolCalls}
|
||||
max={run.budget.maxToolCalls}
|
||||
format={(n) => String(n)}
|
||||
exceeded={exceeded === "tool_calls"}
|
||||
/>
|
||||
<Meter
|
||||
label="token"
|
||||
used={u.totalTokens}
|
||||
max={run.budget.maxTokens}
|
||||
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
|
||||
exceeded={exceeded === "tokens"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TechInfo
|
||||
items={[
|
||||
{
|
||||
label: "工具调用",
|
||||
value: `${toolCalls}${run.budget.maxToolCalls ? ` / ${run.budget.maxToolCalls}` : ""}`,
|
||||
},
|
||||
{
|
||||
label: "token",
|
||||
value:
|
||||
u.totalTokens === undefined
|
||||
? null
|
||||
: `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
|
||||
},
|
||||
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
|
||||
{ label: "会话 ID", value: run.id, wide: true },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -947,17 +989,25 @@ export function FinalReport({
|
||||
...report.downloads.map((d) => ({
|
||||
id: `d${d.downloadTaskId}`,
|
||||
icon: <Download />,
|
||||
title: d.name ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`,
|
||||
title: d.name ?? "下载任务",
|
||||
domain: "下载任务",
|
||||
meta: <StatusBadge tone={PLAN_STATUS_TONE(d.status)}>{d.status}</StatusBadge>,
|
||||
meta: (
|
||||
<StatusBadge tone={PLAN_STATUS_TONE(d.status)}>
|
||||
{DOWNLOAD_STATUS_LABEL[d.status as keyof typeof DOWNLOAD_STATUS_LABEL] ?? d.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
to: `/console/downloads/${d.downloadTaskId}`,
|
||||
})),
|
||||
...report.organization.map((o) => ({
|
||||
id: `o${o.recordId}`,
|
||||
icon: <FolderInput />,
|
||||
title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`,
|
||||
title: o.targetPath ?? "整理记录",
|
||||
domain: "整理记录",
|
||||
meta: <StatusBadge tone={PLAN_STATUS_TONE(o.status)}>{o.status}</StatusBadge>,
|
||||
meta: (
|
||||
<StatusBadge tone={PLAN_STATUS_TONE(o.status)}>
|
||||
{RECORD_STATUS_LABEL[o.status as keyof typeof RECORD_STATUS_LABEL] ?? o.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
to: `/console/organize/${o.recordId}`,
|
||||
})),
|
||||
]
|
||||
@@ -976,7 +1026,6 @@ export function FinalReport({
|
||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||
</StreamingResponse>
|
||||
</LongText>
|
||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
@@ -985,19 +1034,19 @@ export function FinalReport({
|
||||
) : (
|
||||
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
系统核实的结果
|
||||
实际结果
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
||||
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
|
||||
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
|
||||
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
|
||||
{denied ? <StatusBadge tone="destructive">被拒绝 {denied}</StatusBadge> : null}
|
||||
</div>
|
||||
{state || count("failed") || denied ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
||||
{count("failed") ? <StatusBadge tone="destructive">{count("failed")} 步失败</StatusBadge> : null}
|
||||
{denied ? <StatusBadge tone="destructive">{denied} 步未执行</StatusBadge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{report ? (
|
||||
<>
|
||||
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
|
||||
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
|
||||
<dl className="mt-2 flex flex-wrap gap-2 text-center">
|
||||
{(
|
||||
[
|
||||
["已提交", report.submitted],
|
||||
@@ -1005,20 +1054,26 @@ export function FinalReport({
|
||||
["进行中", report.inProgress],
|
||||
["失败", report.failed],
|
||||
] as const
|
||||
).map(([k, v]) => (
|
||||
<div key={k} className="flex flex-col-reverse rounded-lg border border-border/60 px-1 py-1.5">
|
||||
<dt className="text-meta text-muted-foreground">{k}</dt>
|
||||
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => (
|
||||
<div
|
||||
key={k}
|
||||
className="flex min-w-16 flex-col-reverse rounded-lg border border-border/60 px-2 py-1.5"
|
||||
>
|
||||
<dt className="text-meta text-muted-foreground">{k}</dt>
|
||||
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{report.plans.length ? (
|
||||
<ul className="mt-2 space-y-1 text-meta">
|
||||
{report.plans.map((p) => (
|
||||
<li key={p.planId} className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-muted-foreground">下载计划</span>
|
||||
<code className="min-w-0 truncate font-mono text-foreground/80">{p.planId.slice(0, 8)}</code>
|
||||
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>{p.status}</StatusBadge>
|
||||
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>
|
||||
{PLAN_STATUS_LABEL[p.status as keyof typeof PLAN_STATUS_LABEL] ?? p.status}
|
||||
</StatusBadge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1037,18 +1092,17 @@ export function FinalReport({
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
<p className="mt-2 text-meta text-muted-foreground">
|
||||
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
|
||||
)。「已提交」指已进入下载器,整理成功才算入库。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
|
||||
)}
|
||||
{run.error ? (
|
||||
<p className="mt-2 text-caption break-words text-destructive">
|
||||
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
|
||||
</p>
|
||||
<>
|
||||
<p className="mt-2 text-caption break-words text-destructive">
|
||||
{humanizeReason(String((run.error as { message?: unknown }).message ?? "")) || "会话出错"}
|
||||
</p>
|
||||
<TechInfo className="mt-1" items={[{ label: "原始错误", value: JSON.stringify(run.error), wide: true }]} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
setAgentDrawerOpen,
|
||||
setAgentDrawerRun,
|
||||
setAgentDrawerTab,
|
||||
shortGoalTitle,
|
||||
showAgentRunInDrawer,
|
||||
splitContextGoal,
|
||||
useAgentDrawer,
|
||||
} from "./agent-store.ts";
|
||||
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
||||
@@ -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) => (
|
||||
@@ -108,7 +108,7 @@ function HistoryRunView({ runId }: { runId: string }) {
|
||||
返回列表
|
||||
</Button>
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
|
||||
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
|
||||
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
|
||||
</p>
|
||||
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
|
||||
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
@@ -161,13 +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 在授权范围内执行并汇报。"}
|
||||
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
|
||||
({agentShortcutLabel()} 开关,Esc 关闭)
|
||||
</span>
|
||||
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}>
|
||||
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`}
|
||||
</SheetDescription>
|
||||
{context && !history ? <ContextChips context={context} /> : null}
|
||||
<DrawerTabs tab={tab} />
|
||||
@@ -281,16 +278,16 @@ 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">
|
||||
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
||||
{status ? <RunStatusBadge status={status} /> : null}
|
||||
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
|
||||
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
|
||||
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
|
||||
</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,8 +322,8 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="打开 Agent"
|
||||
title={`Agent(${agentShortcutLabel()})`}
|
||||
aria-label="打开智能体"
|
||||
title={`智能体(${agentShortcutLabel()})`}
|
||||
className={className}
|
||||
onClick={() =>
|
||||
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
toolSummary,
|
||||
} from "./agent-conversation.tsx";
|
||||
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
|
||||
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
|
||||
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
const call = (p: Partial<ToolCallView>): ToolCallView => ({
|
||||
@@ -185,3 +185,20 @@ describe("会话历史筛选", () => {
|
||||
expect(rangeStart("", now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortGoalTitle(会话短标题)", () => {
|
||||
it("去 UUID、JSON 参数,能力 ID 换中文名,取第一句", () => {
|
||||
const t = shortGoalTitle(
|
||||
'在浏览器会话 16be7338-1111-4222-8333-944445555666 上直接调用一次 browser.solve_challenge,参数:{"sessionId":"x","mode":"auto"}',
|
||||
);
|
||||
expect(t).not.toMatch(/16be7338|\{|sessionId/);
|
||||
expect(t.startsWith("在浏览器会话上直接调用一次")).toBe(true);
|
||||
});
|
||||
it("去上下文前缀,超长截断", () => {
|
||||
expect(shortGoalTitle("[上下文 作品:沙丘] 把沙丘:第二部的 4K 收进来。然后再看看别的")).toBe(
|
||||
"把沙丘:第二部的 4K 收进来",
|
||||
);
|
||||
expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`);
|
||||
expect(shortGoalTitle(" ")).toBe("未命名会话");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PromptInput } from "~/components/agentui/prompt-input.tsx";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { formatUsd } from "~/components/schedules/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Label } from "~/components/ui/label.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
@@ -114,9 +115,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 ? (
|
||||
<div className="pt-2">
|
||||
@@ -143,10 +143,6 @@ export function GoalComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function shortTokens(n: number | undefined) {
|
||||
return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`;
|
||||
}
|
||||
|
||||
/** 状态 + 来源 + 时间 + 模型 */
|
||||
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
|
||||
return (
|
||||
@@ -163,7 +159,6 @@ function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunSta
|
||||
</div>
|
||||
<p className="text-meta break-words text-muted-foreground">
|
||||
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
|
||||
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -194,8 +189,7 @@ function RunOverviewBar({
|
||||
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
|
||||
const failed = steps.some((s) => s.status === "failed");
|
||||
const u = { ...r.usage, ...(live.usage ?? {}) };
|
||||
const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
|
||||
const usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
|
||||
return (
|
||||
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
|
||||
<button
|
||||
@@ -240,7 +234,7 @@ function RunOverviewBar({
|
||||
<RunPlan plan={live.plan} active={active} fill />
|
||||
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
||||
<div>
|
||||
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
||||
<p className="mb-2 text-meta text-muted-foreground">用量</p>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,7 +394,7 @@ export function AgentRunPanel({
|
||||
viewportClassName="pr-1"
|
||||
contentClassName={column}
|
||||
busy={streaming}
|
||||
label="Agent 会话记录"
|
||||
label="智能体会话记录"
|
||||
>
|
||||
<AgentConversation
|
||||
run={r}
|
||||
@@ -462,8 +456,8 @@ export function AgentRunPanel({
|
||||
<RunMeta run={r} status={shownStatus} />
|
||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
|
||||
<SubagentTree snapshot={run.data} className="p-2.5" />
|
||||
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
||||
<section aria-label="用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-2 text-meta text-muted-foreground">用量</p>
|
||||
<BudgetBar run={r} live={live} stacked />
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -4,13 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
|
||||
import { Link } from "react-router";
|
||||
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { formatUsd } from "~/components/schedules/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { splitContextGoal } from "./agent-store.ts";
|
||||
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||
|
||||
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
|
||||
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
|
||||
@@ -57,16 +58,9 @@ export function matchesHistory(
|
||||
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
|
||||
}
|
||||
|
||||
/** 列表副行:只写费用(token 与调用次数在会话的技术信息里) */
|
||||
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
|
||||
const parts = [`${r.toolCallCount} 次调用`];
|
||||
if (r.usage.totalTokens !== undefined)
|
||||
parts.push(
|
||||
r.usage.totalTokens >= 1000
|
||||
? `${(r.usage.totalTokens / 1000).toFixed(1)}k token`
|
||||
: `${r.usage.totalTokens} token`,
|
||||
);
|
||||
if (r.usage.costUsd !== undefined) parts.push(`$${r.usage.costUsd.toFixed(3)}`);
|
||||
return parts.join(" · ");
|
||||
return r.usage.costUsd ? `费用约 ${formatUsd(r.usage.costUsd)}` : "";
|
||||
}
|
||||
|
||||
const ALL = "__all__";
|
||||
@@ -115,7 +109,7 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
|
||||
{goal.text}
|
||||
{shortGoalTitle(r.goal)}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
@@ -130,10 +124,11 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
|
||||
{relTime(r.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
|
||||
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
|
||||
{usageText(r)}
|
||||
</p>
|
||||
{goal.context || usageText(r) ? (
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
|
||||
{[goal.context?.split(" · ")[0], usageText(r)].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -196,7 +191,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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
|
||||
@@ -192,3 +193,29 @@ export function splitContextGoal(goal: string): { context: string | null; text:
|
||||
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
|
||||
return m ? { context: m[1] ?? null, text: goal.slice(m[0].length) } : { context: null, text: goal };
|
||||
}
|
||||
|
||||
const capabilityTitle = (id: string): string | null =>
|
||||
(CAPABILITY_CATALOG as Record<string, { title?: string }>)[id as KnownCapabilityId]?.title ?? null;
|
||||
|
||||
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
||||
const CAP_RE = /\b[a-z][a-z_]*(?:\.[a-z_]+)+\b/g;
|
||||
|
||||
/**
|
||||
* 会话标题:从目标生成短标题(DEAI-2)。去掉上下文前缀、UUID、JSON 参数,能力 ID 换成中文名,
|
||||
* 取第一句,超过 40 字截断。完整目标仍在会话里第一条消息。
|
||||
*/
|
||||
export function shortGoalTitle(goal: string, max = 40): string {
|
||||
let t = splitContextGoal(goal).text;
|
||||
t = t.replace(/\s*[{[]\s*["{[][\s\S]*$/, ""); // JSON 参数到结尾
|
||||
t = t.replace(UUID_RE, "");
|
||||
t = t.replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{0,4})*…?/gi, ""); // 截断的短 ID
|
||||
t = t.replace(CAP_RE, (id) => capabilityTitle(id) ?? id);
|
||||
t = t.split(/[\n。!?!?]/)[0] ?? "";
|
||||
t = t
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、)])/gu, "")
|
||||
.replace(/[,,::;;、\s]*(?:参数|args?)?[::]?\s*$/i, "")
|
||||
.trim();
|
||||
if (!t) return "未命名会话";
|
||||
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Link } from "react-router";
|
||||
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { splitContextGoal } from "./agent-store.ts";
|
||||
import { shortGoalTitle } from "./agent-store.ts";
|
||||
|
||||
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
|
||||
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
|
||||
@@ -70,7 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
||||
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink>
|
||||
<RunLink id={node.id}>{shortGoalTitle(node.goal)}</RunLink>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={node.status} />
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
@@ -116,7 +116,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
|
||||
<CornerLeftUp aria-hidden className="size-3.5" />
|
||||
父会话
|
||||
</p>
|
||||
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink>
|
||||
<RunLink id={parent.id}>{shortGoalTitle(parent.goal)}</RunLink>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
||||
{budgetText(run.budget as Record<string, number | undefined>)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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("人工接管 / 等待处理不看自动化时间", () => {
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AGENT_LAUNCHER_ATTR } from "~/components/agent/agent-drawer.tsx";
|
||||
import {
|
||||
agentShortcutLabel,
|
||||
isAgentShortcut,
|
||||
splitContextGoal,
|
||||
shortGoalTitle,
|
||||
toggleAgentDrawer,
|
||||
useAgentDrawer,
|
||||
} from "~/components/agent/agent-store.ts";
|
||||
@@ -24,7 +24,7 @@ export function useActiveAgentRuns() {
|
||||
const latest = active[0];
|
||||
return {
|
||||
count: active.length,
|
||||
summary: latest ? `${RUN_STATUS_LABEL[latest.status]} · ${splitContextGoal(latest.goal).text}` : null,
|
||||
summary: latest ? `${RUN_STATUS_LABEL[latest.status]} · ${shortGoalTitle(latest.goal)}` : null,
|
||||
};
|
||||
}, [runs.data]);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ function useHideOnScroll(): boolean {
|
||||
/**
|
||||
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
|
||||
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
|
||||
* - 抽屉打开时变成"收起",停在抽屉左侧(窄屏抽屉占满全宽,用抽屉自带的关闭钮,FAB 隐藏)。
|
||||
* - 只在没有侧栏的窄屏显示(xl 起侧栏顶部就是入口);抽屉打开时隐藏,用抽屉自带的关闭钮。
|
||||
*/
|
||||
export function AgentLauncher() {
|
||||
useAgentHotkey();
|
||||
@@ -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"
|
||||
@@ -154,14 +154,15 @@ export function AgentLauncher() {
|
||||
aria-label={label}
|
||||
aria-keyshortcuts="Control+J Meta+J"
|
||||
aria-expanded={open}
|
||||
title={`${open ? "收起" : "打开"} Agent(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
|
||||
title={`${open ? "收起" : "打开"}智能体(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
|
||||
style={{ bottom: `calc(${BASE_BOTTOM + lift}px + env(safe-area-inset-bottom))` }}
|
||||
className={cn(
|
||||
"pg-float prism-ring group pointer-events-auto fixed right-4 z-40 flex h-14 min-w-14 cursor-pointer items-center justify-center gap-3 rounded-full text-foreground outline-none select-none",
|
||||
"transition-[transform,bottom,right] duration-layout ease-spring active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
|
||||
"md:px-2 md:pr-5",
|
||||
open && "z-[60] hidden md:right-[calc(36rem+1rem)] md:flex",
|
||||
// 桌面(xl 起)侧栏已有入口,抽屉打开时用抽屉自己的关闭钮:这两种情况都不显示悬浮按钮
|
||||
open ? "hidden" : "xl:hidden",
|
||||
!open && scrolledAway && "translate-x-[calc(100%+2rem)] focus-visible:translate-x-0",
|
||||
)}
|
||||
>
|
||||
@@ -171,7 +172,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">
|
||||
@@ -179,7 +180,7 @@ export function AgentLauncher() {
|
||||
</kbd>
|
||||
</span>
|
||||
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
|
||||
{open ? "Esc 关闭" : (summary ?? "交代目标,边做边汇报")}
|
||||
{open ? "Esc 关闭" : (summary ?? "")}
|
||||
</span>
|
||||
</span>
|
||||
</button>,
|
||||
@@ -196,7 +197,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
|
||||
onNavigate?.();
|
||||
toggleAgentDrawer(pathname);
|
||||
};
|
||||
const title = `Agent(${shortcut})${summary ? `\n${summary}` : ""}`;
|
||||
const title = `智能体(${shortcut})${summary ? `\n${summary}` : ""}`;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
@@ -205,7 +206,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,12 +229,10 @@ 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">
|
||||
{count ? `${count} 个会话进行中` : "交代目标,边做边汇报"}
|
||||
</span>
|
||||
{count ? <span className="truncate text-meta text-muted-foreground">{count} 个会话进行中</span> : null}
|
||||
</span>
|
||||
{count ? (
|
||||
<CountBadge count={count} />
|
||||
@@ -250,8 +249,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);
|
||||
});
|
||||
|
||||
@@ -19,10 +19,10 @@ 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"],
|
||||
["订阅", "规则"],
|
||||
["下载面板", "整理记录", "文件管理", "媒体概览"],
|
||||
["下载面板", "整理记录", "文件管理", "媒体库"],
|
||||
["设置", "审计", "运行状态"],
|
||||
]);
|
||||
// IA-3 逐项清单 14 项 + FE-6 计划页
|
||||
@@ -44,7 +44,7 @@ describe("控制台导航", () => {
|
||||
it("按最长前缀匹配当前项", () => {
|
||||
expect(findNavItem("/console")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/library/abc")?.label).toBe("媒体概览");
|
||||
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
|
||||
expect(findNavItem("/console/tasks/r1")?.label).toBe("任务");
|
||||
expect(findNavItem("/console/schedules")?.label).toBe("计划");
|
||||
expect(findNavItem("/console/downloads")?.label).toBe("下载面板");
|
||||
|
||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user