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]) {
|
for (const dryRun of [true, false]) {
|
||||||
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
|
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
|
||||||
expect(r.status).toBe(400);
|
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");
|
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
|||||||
return { items } as never;
|
return { items } as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
// AG-3:订阅摘要(分组计数 + 条目),版本目标只给总数 / 已满足数,细节用 inventory.query
|
// AG-3:订阅摘要(分组计数 + 条目),收录版本只给总数 / 已满足数,细节用 inventory.query
|
||||||
registry.register(capabilitySpec("subscription.list"), async (input) => {
|
registry.register(capabilitySpec("subscription.list"), async (input) => {
|
||||||
const db = deps().ctx.db;
|
const db = deps().ctx.db;
|
||||||
let q = db
|
let q = db
|
||||||
@@ -253,7 +253,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
|||||||
const d = deps();
|
const d = deps();
|
||||||
const db = d.ctx.db;
|
const db = d.ctx.db;
|
||||||
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
|
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
|
const rel = await db
|
||||||
.selectFrom("source_release")
|
.selectFrom("source_release")
|
||||||
.select(["id", "media_entity_id"])
|
.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.rejected ?? []) rejected.push({ code: r.code, message: r.message });
|
||||||
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
|
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
|
||||||
if (rejected.length === 0)
|
if (rejected.length === 0)
|
||||||
rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
|
rejected.push({ code: "not_planned", message: "未生成计划:收录版本正忙或条件不满足" });
|
||||||
}
|
}
|
||||||
const fresh = await db
|
const fresh = await db
|
||||||
.selectFrom("acquisition_slot")
|
.selectFrom("acquisition_slot")
|
||||||
@@ -324,8 +324,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
|||||||
.where("id", "=", plan.slot_id)
|
.where("id", "=", plan.slot_id)
|
||||||
.forUpdate()
|
.forUpdate()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
|
if (slot.revision + 1 !== plan.expected_revision) return "收录版本已变化,重新生成计划";
|
||||||
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
|
if (BUSY.includes(slot.status)) return `收录版本正忙(${slot.status})`;
|
||||||
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
|
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
|
||||||
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
|
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
|
||||||
st = transition(st, { type: "reserve" });
|
st = transition(st, { type: "reserve" });
|
||||||
@@ -348,7 +348,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留";
|
if ((err as { code?: string }).code === "23505") return "收录版本已被其他计划预留";
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (ok) throw new CapabilityError("conflict", ok);
|
if (ok) throw new CapabilityError("conflict", ok);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "downloads.sync",
|
key: "downloads.sync",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "qB 同步",
|
name: "qB 同步",
|
||||||
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
|
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(syncMs),
|
schedule: every(syncMs),
|
||||||
},
|
},
|
||||||
@@ -129,8 +129,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "cache.threshold",
|
key: "cache.threshold",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "缓存阈值检查",
|
name: "下载盘检查",
|
||||||
description: "下载缓存超过硬阈值时入队清理",
|
description: "下载盘超过暂停线时清理已入库的下载",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -145,7 +145,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "downloads.orchestrate",
|
key: "downloads.orchestrate",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "补提交与补入库",
|
name: "补提交与补入库",
|
||||||
description: "补投已预留未提交的下载、已完成未入库的任务",
|
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -159,7 +159,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "slots.reevaluate",
|
key: "slots.reevaluate",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "到期重评",
|
name: "到期重评",
|
||||||
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
|
description: "重新检查等待中的资源",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -169,7 +169,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "subscriptions.search",
|
key: "subscriptions.search",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "订阅周期搜索",
|
name: "订阅周期搜索",
|
||||||
description: "到期的订阅主动搜索站点",
|
description: "到期的订阅去站点搜索",
|
||||||
group: "collect",
|
group: "collect",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -190,8 +190,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "library.reconcile",
|
key: "library.reconcile",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "资产对账",
|
name: "文件核对",
|
||||||
description: "核对媒体库文件与资产记录",
|
description: "检查媒体库里的文件是否还在",
|
||||||
group: "organize",
|
group: "organize",
|
||||||
schedule: every(reconcileMs),
|
schedule: every(reconcileMs),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,19 +30,19 @@ const READ_ONLY: ReadOnly[] = [
|
|||||||
{
|
{
|
||||||
...Api.mediaAssets,
|
...Api.mediaAssets,
|
||||||
table: "media_asset",
|
table: "media_asset",
|
||||||
what: "资产",
|
what: "文件",
|
||||||
filters: { mediaEntityId: "media_entity_id", status: "status" },
|
filters: { mediaEntityId: "media_entity_id", status: "status" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...Api.assetUnitCoverage,
|
...Api.assetUnitCoverage,
|
||||||
table: "asset_unit_coverage",
|
table: "asset_unit_coverage",
|
||||||
what: "资产覆盖",
|
what: "文件覆盖",
|
||||||
filters: { mediaAssetId: "media_asset_id", contentUnitId: "content_unit_id" },
|
filters: { mediaAssetId: "media_asset_id", contentUnitId: "content_unit_id" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...Api.assetLocations,
|
...Api.assetLocations,
|
||||||
table: "asset_location",
|
table: "asset_location",
|
||||||
what: "资产位置",
|
what: "文件位置",
|
||||||
filters: { mediaAssetId: "media_asset_id", status: "status" },
|
filters: { mediaAssetId: "media_asset_id", status: "status" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
|
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
|
||||||
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
|
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
|
||||||
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
|
if (dv.unmanagedGrowthRisk) notes.push("有外部任务仍在增长,暂停本工具的任务后磁盘仍会增长");
|
||||||
const pct = usedPercent(v);
|
const pct = usedPercent(v);
|
||||||
volumes.push({
|
volumes.push({
|
||||||
volumeKey: v.volumeKey,
|
volumeKey: v.volumeKey,
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/library/reconcile",
|
path: "/library/reconcile",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
|
summary: "入队一轮资产对账(媒体库目录哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
|
||||||
body: z.object({ libraryRootId: z.uuid().optional() }),
|
body: z.object({ libraryRootId: z.uuid().optional() }),
|
||||||
response: accepted,
|
response: accepted,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function registerNamingRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
};
|
};
|
||||||
const rootRow = async (id: string) => {
|
const rootRow = async (id: string) => {
|
||||||
const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst();
|
const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst();
|
||||||
if (!r) throw notFound("库根");
|
if (!r) throw notFound("媒体库目录");
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
const audit = (
|
const audit = (
|
||||||
@@ -209,7 +209,7 @@ async function loadSample(d: AoDeps, input: NamingPreviewInput): Promise<SampleS
|
|||||||
.where("a.id", "=", s.assetId)
|
.where("a.id", "=", s.assetId)
|
||||||
.orderBy("l.created_at", "desc")
|
.orderBy("l.created_at", "desc")
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!asset) throw notFound("资产");
|
if (!asset) throw notFound("文件");
|
||||||
const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv";
|
const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv";
|
||||||
const e = asset.media_entity_id
|
const e = asset.media_entity_id
|
||||||
? await db.selectFrom("media_entity").selectAll().where("id", "=", asset.media_entity_id).executeTakeFirst()
|
? 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;
|
let root: LibraryRootRow | null = null;
|
||||||
if (rootId) {
|
if (rootId) {
|
||||||
root = (await db.selectFrom("library_root").selectAll().where("id", "=", rootId).executeTakeFirst()) ?? null;
|
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 eff = effectiveNaming(await namingSettings(), root, entity);
|
||||||
const template = input.template ?? eff.templates[kind];
|
const template = input.template ?? eff.templates[kind];
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
|
|||||||
.where("active", "=", true)
|
.where("active", "=", true)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!sup) return;
|
if (!sup) return;
|
||||||
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
|
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,智能体不能自动加回");
|
||||||
await liftSuppressions(trx, fingerprint, by);
|
await liftSuppressions(trx, fingerprint, by);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 被版本目标 / 订阅显式引用的次数 */
|
/** 被收录版本 / 订阅显式引用的次数 */
|
||||||
async function referenceCount(trx: Trx, id: string): Promise<number> {
|
async function referenceCount(trx: Trx, id: string): Promise<number> {
|
||||||
const r = await sql<{ n: number }>`
|
const r = await sql<{ n: number }>`
|
||||||
select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
|
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(", ")}`);
|
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
|
/** 收录版本 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
|
||||||
export const assertRuleGroupIds = checkIdsExist;
|
export const assertRuleGroupIds = checkIdsExist;
|
||||||
|
|
||||||
const TRACKED_FIELDS = [
|
const TRACKED_FIELDS = [
|
||||||
@@ -528,7 +528,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!cur) notFound("规则组");
|
if (!cur) notFound("规则组");
|
||||||
const refs = await referenceCount(trx, cur!.id);
|
const refs = await referenceCount(trx, cur!.id);
|
||||||
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
|
if (refs > 0) conflict(`规则组被 ${refs} 个收录版本或订阅引用,先解除引用`);
|
||||||
const row = await trx
|
const row = await trx
|
||||||
.updateTable("rule_group")
|
.updateTable("rule_group")
|
||||||
.set({
|
.set({
|
||||||
@@ -597,7 +597,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 规则组改动后重评引用它的版本目标(显式引用;默认链改动由周期重评覆盖) */
|
/** 规则组改动后重评引用它的收录版本(显式引用;默认链改动由周期重评覆盖) */
|
||||||
async function kickReevaluate(d: AoDeps, groupId: string) {
|
async function kickReevaluate(d: AoDeps, groupId: string) {
|
||||||
try {
|
try {
|
||||||
const slots = await d.ctx.db
|
const slots = await d.ctx.db
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
Api.acquisitionSlots.get,
|
Api.acquisitionSlots.get,
|
||||||
async (_c, { params }) => {
|
async (_c, { params }) => {
|
||||||
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
|
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 },
|
{ tag: TAG },
|
||||||
);
|
);
|
||||||
@@ -171,7 +171,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
.onConflict((oc) => oc.doNothing())
|
.onConflict((oc) => oc.doNothing())
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!r) conflict("该作品或内容单元下已有同名版本目标");
|
if (!r) conflict("该作品或内容单元下已有同名收录版本");
|
||||||
await kickEvaluate(d, { acquisitionSlotId: r!.id });
|
await kickEvaluate(d, { acquisitionSlotId: r!.id });
|
||||||
return toApi<AcquisitionSlot>(r!);
|
return toApi<AcquisitionSlot>(r!);
|
||||||
},
|
},
|
||||||
@@ -208,7 +208,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
.select("revision")
|
.select("revision")
|
||||||
.where("id", "=", params.id)
|
.where("id", "=", params.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!exists) notFound("版本目标");
|
if (!exists) notFound("收录版本");
|
||||||
conflict(`revision 不匹配(当前 ${exists!.revision})`);
|
conflict(`revision 不匹配(当前 ${exists!.revision})`);
|
||||||
}
|
}
|
||||||
await kickEvaluate(d, { acquisitionSlotId: params.id });
|
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";
|
import { mount, paginate } from "./helpers.ts";
|
||||||
|
|
||||||
// W9-E 订阅 API(docs/design/subscriptions.md §1–§3、§5):总览(分组/排序/筛选)、覆盖矩阵、表单字段规范化、
|
// W9-E 订阅 API(docs/design/subscriptions.md §1–§3、§5):总览(分组/排序/筛选)、覆盖矩阵、表单字段规范化、
|
||||||
// 暂停/恢复/重新订阅、批量、手动补全某版本、版本目标排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
|
// 暂停/恢复/重新订阅、批量、手动补全某版本、收录版本排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
|
||||||
|
|
||||||
const TAG = "subscriptions";
|
const TAG = "subscriptions";
|
||||||
const OVERVIEW_MAX = 2000;
|
const OVERVIEW_MAX = 2000;
|
||||||
@@ -106,12 +106,12 @@ async function assertNoDuplicate(
|
|||||||
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
|
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
|
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个收录版本(预设随后写入) */
|
||||||
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
|
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
|
||||||
* 该订阅自己的版本目标;MP total_episode → 季总集数;best_version → 版本目标升级策略。
|
* 该订阅自己的收录版本;MP total_episode → 季总集数;best_version → 收录版本升级策略。
|
||||||
*/
|
*/
|
||||||
async function applyDerived(
|
async function applyDerived(
|
||||||
exec: Exec,
|
exec: Exec,
|
||||||
@@ -616,7 +616,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
{ tag: TAG },
|
{ tag: TAG },
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 版本目标:手动补全 / 排序 ──
|
// ── 收录版本:手动补全 / 排序 ──
|
||||||
mount(
|
mount(
|
||||||
app,
|
app,
|
||||||
Api.subscriptions.fillSlot,
|
Api.subscriptions.fillSlot,
|
||||||
@@ -632,7 +632,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
.execute()
|
.execute()
|
||||||
: [];
|
: [];
|
||||||
const slot = slotsOfSub(slots, sub).find((s) => s.id === params.slotId);
|
const slot = slotsOfSub(slots, sub).find((s) => s.id === params.slotId);
|
||||||
if (!slot) notFound("版本目标");
|
if (!slot) notFound("收录版本");
|
||||||
invalidateCoverage(sub.id);
|
invalidateCoverage(sub.id);
|
||||||
try {
|
try {
|
||||||
return await d.ctx.engine.enqueue(
|
return await d.ctx.engine.enqueue(
|
||||||
@@ -668,7 +668,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
new Set(want).size !== want.length ||
|
new Set(want).size !== want.length ||
|
||||||
!want.every((id) => mine.some((s) => s.id === id))
|
!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())
|
for (const [i, id] of want.entries())
|
||||||
await trx.updateTable("acquisition_slot").set({ position: i }).where("id", "=", id).execute();
|
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(
|
return (await trx.selectFrom("acquisition_slot").selectAll().where("id", "in", want).execute()).sort(
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export async function createSlotsFromTemplates(
|
|||||||
await exec
|
await exec
|
||||||
.insertInto("acquisition_slot")
|
.insertInto("acquisition_slot")
|
||||||
.values({
|
.values({
|
||||||
// 同一事务建的版本目标 created_at 相同:按模板顺序给 position,第一个为主版本
|
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
|
||||||
position: i,
|
position: i,
|
||||||
media_entity_id: input.entityId,
|
media_entity_id: input.entityId,
|
||||||
subscription_id: input.subscriptionId,
|
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 spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
|
||||||
const ctx = contextOf(rel, now);
|
const ctx = contextOf(rel, now);
|
||||||
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
|
// 规则组:收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
|
||||||
c.rules ??= loadRuleCatalog(db);
|
c.rules ??= loadRuleCatalog(db);
|
||||||
const chain = resolveRuleChain(await c.rules, {
|
const chain = resolveRuleChain(await c.rules, {
|
||||||
kind: "acquisition",
|
kind: "acquisition",
|
||||||
@@ -568,7 +568,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
|||||||
siteId: rel.site_id,
|
siteId: rel.site_id,
|
||||||
subscriptionId: sub?.id,
|
subscriptionId: sub?.id,
|
||||||
});
|
});
|
||||||
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件
|
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入收录版本硬条件
|
||||||
spec.hard = mergeHard(spec.hard, chain.hard);
|
spec.hard = mergeHard(spec.hard, chain.hard);
|
||||||
if (chain.groups.length) {
|
if (chain.groups.length) {
|
||||||
spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
||||||
@@ -811,7 +811,7 @@ async function unitQuality(
|
|||||||
exec: Exec,
|
exec: Exec,
|
||||||
slot: AcquisitionSlotRow,
|
slot: AcquisitionSlotRow,
|
||||||
keys: readonly string[],
|
keys: readonly string[],
|
||||||
/** 版本目标引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
|
/** 收录版本引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
|
||||||
priOf?: (assetId: string) => Promise<number | undefined>,
|
priOf?: (assetId: string) => Promise<number | undefined>,
|
||||||
): Promise<Record<string, QualityFacts | undefined>> {
|
): Promise<Record<string, QualityFacts | undefined>> {
|
||||||
const rows = await exec
|
const rows = await exec
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import { defaultChainIds, loadRuleCatalog, type RuleCatalog, resolveRuleChain }
|
|||||||
import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
|
import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
|
||||||
|
|
||||||
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
|
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
|
||||||
// 一批订阅一次取齐 版本目标 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
|
// 一批订阅一次取齐 收录版本 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
|
||||||
// 在内存里按版本目标算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
|
// 在内存里按收录版本算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
|
||||||
// 版本目标覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个版本目标时,作品下全部资产都算)。
|
// 收录版本覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个收录版本时,作品下全部资产都算)。
|
||||||
|
|
||||||
const MAX_EPISODES = 2000;
|
const MAX_EPISODES = 2000;
|
||||||
const COVERAGE_TTL_MS = 30_000;
|
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) =>
|
export const slotOrder = (a: AcquisitionSlotRow, b: AcquisitionSlotRow) =>
|
||||||
a.position - b.position || a.created_at.getTime() - b.created_at.getTime() || a.id.localeCompare(b.id);
|
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">) =>
|
export const slotsOfSub = (all: readonly AcquisitionSlotRow[], sub: Pick<SubscriptionRow, "id" | "media_entity_id">) =>
|
||||||
all
|
all
|
||||||
.filter(
|
.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 });
|
if (p?.episode !== undefined || /^d\d{8}$/.test(k)) put(k, { assetId: id, quality: q, pri });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 分集账本优先(质量与 pri 按版本目标记)
|
// 分集账本优先(质量与 pri 按收录版本记)
|
||||||
for (const r of ledger) {
|
for (const r of ledger) {
|
||||||
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
|
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
|
||||||
if (r.kind === "episode" && r.episode_number !== null)
|
if (r.kind === "episode" && r.episode_number !== null)
|
||||||
@@ -542,7 +542,7 @@ export async function buildSubscriptionModels(
|
|||||||
upgrade: m.upgrade,
|
upgrade: m.upgrade,
|
||||||
});
|
});
|
||||||
if (isMovie) {
|
if (isMovie) {
|
||||||
// 电影:该版本目标不要求的内容版本不算缺
|
// 电影:该收录版本不要求的内容版本不算缺
|
||||||
for (const c of r.cells)
|
for (const c of r.cells)
|
||||||
if (c.state === "missing" && !m.requiredEditions.includes(c.unitKey.slice("edition:".length))) {
|
if (c.state === "missing" && !m.requiredEditions.includes(c.unitKey.slice("edition:".length))) {
|
||||||
c.state = "skipped";
|
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 };
|
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(
|
export async function loadNamingEntity(
|
||||||
exec: Exec,
|
exec: Exec,
|
||||||
entity: MediaEntityRow,
|
entity: MediaEntityRow,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
|
|||||||
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
|
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
|
||||||
}
|
}
|
||||||
if (!roots.some((root) => within(r, root)))
|
if (!roots.some((root) => within(r, root)))
|
||||||
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下:${p}`, p);
|
throw new PathGuardError(`源路径不在已登记的媒体库目录或下载器路径映射之下:${p}`, p);
|
||||||
return r;
|
return r;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
statSafe,
|
statSafe,
|
||||||
} from "@umt/adapters";
|
} from "@umt/adapters";
|
||||||
import type { ActorType, OrganizationRecord, OrganizationTrigger, PathBinding } from "@umt/contracts";
|
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 type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecordRow } from "@umt/db";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
import {
|
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 } : {});
|
const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {});
|
||||||
await decide_({
|
await decide_({
|
||||||
kind: "identity_ambiguous",
|
kind: "identity_ambiguous",
|
||||||
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
||||||
summary: takeoverSummary(takeover),
|
summary: takeoverSummary(takeover),
|
||||||
subjectType: "organization_record",
|
subjectType: "organization_record",
|
||||||
subjectId: rid.recordId,
|
subjectId: rid.recordId,
|
||||||
@@ -500,8 +501,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
|||||||
await decide_({
|
await decide_({
|
||||||
kind: "other",
|
kind: "other",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: `库根离线:${root.name}`,
|
title: `媒体库目录离线:${root.name}`,
|
||||||
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
|
summary: "媒体库目录不可访问或未挂载,整理已暂停。文件不会因此被判为缺失",
|
||||||
subjectType: "library_root",
|
subjectType: "library_root",
|
||||||
subjectId: root.id,
|
subjectId: root.id,
|
||||||
dedupeKey: `library_root_offline:${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");
|
const existsConflict = blocking.some((c) => c.kind === "target_exists");
|
||||||
await upsertDecision(d, trx, {
|
await upsertDecision(d, trx, {
|
||||||
kind: hnrConflict ? "approval_required" : "path_conflict",
|
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
|
summary: blocking
|
||||||
.map((c) => c.detail ?? c.kind)
|
.map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind)
|
||||||
.join(";")
|
.join(";")
|
||||||
.slice(0, 500),
|
.slice(0, 500),
|
||||||
subjectType: "organization_record",
|
subjectType: "organization_record",
|
||||||
@@ -911,7 +914,7 @@ export async function executeRecord(
|
|||||||
const fsr = fsRuntime(d);
|
const fsr = fsRuntime(d);
|
||||||
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
||||||
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
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 });
|
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
|
||||||
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
||||||
if (fsr.partialMaxAgeMs > 0)
|
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 };
|
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) {
|
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 by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
|
||||||
const rows = await trx
|
const rows = await trx
|
||||||
@@ -1857,7 +1860,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
.join(";");
|
.join(";");
|
||||||
const reason = (code: string | null, err: unknown) =>
|
const reason = (code: string | null, err: unknown) =>
|
||||||
code
|
code
|
||||||
? { code: code.split(":")[0]!, message: conflictText(err) || code }
|
? { code: code.split(":")[0]!, message: conflictText(err) || reasonSentence(code, code) }
|
||||||
: err
|
: err
|
||||||
? {
|
? {
|
||||||
code: (err as { code?: string }).code ?? "error",
|
code: (err as { code?: string }).code ?? "error",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { ApiException } from "../../../http/errors.ts";
|
|||||||
import { hashJson, toApi } from "../util.ts";
|
import { hashJson, toApi } from "../util.ts";
|
||||||
import type { AoDeps, Exec } from "./common.ts";
|
import type { AoDeps, Exec } from "./common.ts";
|
||||||
|
|
||||||
// 规则组(W9-D):目录加载、引用链解析(版本目标 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
|
// 规则组(W9-D):目录加载、引用链解析(收录版本 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
|
||||||
|
|
||||||
export interface RuleCatalog {
|
export interface RuleCatalog {
|
||||||
/** 未删除的全部规则组(含停用,解析链时再过滤) */
|
/** 未删除的全部规则组(含停用,解析链时再过滤) */
|
||||||
@@ -132,17 +132,17 @@ export function defaultChainIds(cat: RuleCatalog, kind: RuleGroupKind): string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveInput extends RuleTarget {
|
export interface ResolveInput extends RuleTarget {
|
||||||
/** 版本目标 rule_group_ids(非空时优先) */
|
/** 收录版本 rule_group_ids(非空时优先) */
|
||||||
slotRuleGroupIds?: readonly string[] | undefined;
|
slotRuleGroupIds?: readonly string[] | undefined;
|
||||||
/** 订阅 params.ruleGroupIds */
|
/** 订阅 params.ruleGroupIds */
|
||||||
subRuleGroupIds?: readonly string[] | undefined;
|
subRuleGroupIds?: readonly string[] | undefined;
|
||||||
kind: "acquisition" | "search";
|
kind: "acquisition" | "search";
|
||||||
/** 版本目标开了升级:叠加洗版默认链 */
|
/** 收录版本开了升级:叠加洗版默认链 */
|
||||||
upgrade?: boolean | undefined;
|
upgrade?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
|
* 引用链:订阅私有组 → (收录版本 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
|
||||||
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
|
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
|
||||||
*/
|
*/
|
||||||
export function resolveRuleChain(
|
export function resolveRuleChain(
|
||||||
@@ -190,7 +190,7 @@ export function chainHard(rows: readonly Pick<RuleGroupRow, "hard">[]): SlotHard
|
|||||||
return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) };
|
return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 版本目标硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
|
/** 收录版本硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
|
||||||
export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions {
|
export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions {
|
||||||
if (!extra.include?.length && !extra.exclude?.length) return base;
|
if (!extra.include?.length && !extra.exclude?.length) return base;
|
||||||
const out: SlotHardConditions = { ...base };
|
const out: SlotHardConditions = { ...base };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { TRACKING_SUB_STATUSES } from "./subscriptions.ts";
|
|||||||
|
|
||||||
// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date,
|
// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date,
|
||||||
// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。
|
// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。
|
||||||
// 状态取主版本那一行的格(没有版本目标时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
|
// 状态取主版本那一行的格(没有收录版本时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
|
||||||
// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。
|
// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。
|
||||||
// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。
|
// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { INFLIGHT_PLAN_STATUSES } from "./subscriptions.ts";
|
|||||||
|
|
||||||
// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。
|
// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。
|
||||||
// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled,
|
// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled,
|
||||||
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉版本目标的延后记录,标记立即重评。
|
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉收录版本的延后记录,标记立即重评。
|
||||||
// 已入库资产、分集账本、总集数(含锁定)不动。
|
// 已入库资产、分集账本、总集数(含锁定)不动。
|
||||||
|
|
||||||
/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */
|
/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import { allOfAny } from "./rules.ts";
|
|||||||
// total_episode (+ manual_total_episode) → 保存后写季总集数(manual 锁定)
|
// total_episode (+ manual_total_episode) → 保存后写季总集数(manual 锁定)
|
||||||
// include / exclude(字符串或数组)→ include[] / exclude[](正则白名单校验)
|
// include / exclude(字符串或数组)→ include[] / exclude[](正则白名单校验)
|
||||||
// keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds
|
// 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}
|
// autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay}
|
||||||
// resolution / quality(resource_type) / effect(MP 标签或正则)→ resolution / source / effect 质量预设
|
// 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;
|
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;
|
params: SubscriptionParams;
|
||||||
/** MP total_episode:保存后写入订阅季的总集数 */
|
/** MP total_episode:保存后写入订阅季的总集数 */
|
||||||
episodeTotal?: { total: number; locked: boolean };
|
episodeTotal?: { total: number; locked: boolean };
|
||||||
/** MP best_version:写到订阅版本目标的升级策略 */
|
/** MP best_version:写到订阅收录版本的升级策略 */
|
||||||
upgrade?: UpgradeStrategy;
|
upgrade?: UpgradeStrategy;
|
||||||
/** 被改写的兼容字段(调试/审计用) */
|
/** 被改写的兼容字段(调试/审计用) */
|
||||||
mapped: string[];
|
mapped: string[];
|
||||||
@@ -141,7 +141,7 @@ export function parseMpEffect(v: string): SubscriptionEffect | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 质量预设 → 版本目标硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
|
/** 质量预设 → 收录版本硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
|
||||||
export interface PresetConditions {
|
export interface PresetConditions {
|
||||||
hard: Record<string, unknown>;
|
hard: Record<string, unknown>;
|
||||||
soft: 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);
|
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 质量预设写到该订阅自己的版本目标:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
|
* 质量预设写到该订阅自己的收录版本:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
|
||||||
* 只有当版本目标上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
|
* 只有当收录版本上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
|
||||||
*/
|
*/
|
||||||
export async function applySlotPreset(
|
export async function applySlotPreset(
|
||||||
exec: Exec,
|
exec: Exec,
|
||||||
@@ -461,7 +461,7 @@ export async function normalizeSubscriptionParams(
|
|||||||
// ── 库根存在性与正则白名单 ──
|
// ── 库根存在性与正则白名单 ──
|
||||||
if (p.libraryRootId) {
|
if (p.libraryRootId) {
|
||||||
const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst();
|
const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst();
|
||||||
if (!r) bad(`库根不存在:${p.libraryRootId}`);
|
if (!r) bad(`媒体库目录不存在:${p.libraryRootId}`);
|
||||||
}
|
}
|
||||||
for (const k of ["include", "exclude"] as const)
|
for (const k of ["include", "exclude"] as const)
|
||||||
for (const pat of p[k] ?? []) {
|
for (const pat of p[k] ?? []) {
|
||||||
@@ -481,7 +481,7 @@ export async function normalizeSubscriptionParams(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** best_version 等 → 订阅版本目标的升级策略(保留其余升级参数;revision+1) */
|
/** best_version 等 → 订阅收录版本的升级策略(保留其余升级参数;revision+1) */
|
||||||
export async function applySlotUpgrade(
|
export async function applySlotUpgrade(
|
||||||
exec: Exec,
|
exec: Exec,
|
||||||
input: { subscriptionId: string; entityId: string; strategy: UpgradeStrategy },
|
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(
|
export async function subscriptionLibraryRoot(
|
||||||
|
|||||||
@@ -204,13 +204,13 @@ export function createAuthorizer(deps: {
|
|||||||
if (d.id === "agent.spawn") {
|
if (d.id === "agent.spawn") {
|
||||||
return runId && (ctx.source === "agent" || ctx.source === "task")
|
return runId && (ctx.source === "agent" || ctx.source === "task")
|
||||||
? { allowed: true }
|
? { allowed: true }
|
||||||
: { allowed: false, reason: "只有 Agent 会话可以派生子会话" };
|
: { allowed: false, reason: "只有智能体会话可以派生子会话" };
|
||||||
}
|
}
|
||||||
// agent.propose_options 只登记一个待用户选择的事项(无外部副作用);能否问用户由处理器校验
|
// agent.propose_options 只登记一个待用户选择的事项(无外部副作用);能否问用户由处理器校验
|
||||||
if (d.id === "agent.propose_options") {
|
if (d.id === "agent.propose_options") {
|
||||||
return runId && (ctx.source === "agent" || ctx.source === "task")
|
return runId && (ctx.source === "agent" || ctx.source === "task")
|
||||||
? { allowed: true }
|
? { allowed: true }
|
||||||
: { allowed: false, reason: "只有 Agent 会话可以让用户选方案" };
|
: { allowed: false, reason: "只有智能体会话可以让用户选方案" };
|
||||||
}
|
}
|
||||||
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
|
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
|
||||||
if (d.id === "metadata.enrich") return { allowed: true };
|
if (d.id === "metadata.enrich") return { allowed: true };
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function registerDownloadRecoveryCapabilities(
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
|
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
|
||||||
if (!dt.managed || dt.removed_at || dt.status === "removed")
|
if (!dt.managed || dt.removed_at || dt.status === "removed")
|
||||||
throw new CapabilityError("precondition", "未纳管或已移除的下载任务不做自动恢复");
|
throw new CapabilityError("precondition", "外部或已移除的下载任务不做自动恢复");
|
||||||
const status = dt.status as DownloadStatus;
|
const status = dt.status as DownloadStatus;
|
||||||
if (input.action === "recheck" && !RECHECK_FROM.includes(status))
|
if (input.action === "recheck" && !RECHECK_FROM.includes(status))
|
||||||
throw new CapabilityError("precondition", `当前状态 ${status} 不需要重新校验`);
|
throw new CapabilityError("precondition", `当前状态 ${status} 不需要重新校验`);
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export async function upstreamComplete(
|
|||||||
sessionId: `umt-enrich-${randomUUID()}`,
|
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
|
const text = res.content
|
||||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||||
.map((b) => b.text)
|
.map((b) => b.text)
|
||||||
@@ -87,7 +88,7 @@ export function registerMetadataCapabilities(
|
|||||||
const agent = await ctx.settings.get("agent").catch(() => null);
|
const agent = await ctx.settings.get("agent").catch(() => null);
|
||||||
const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) };
|
const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) };
|
||||||
if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search"))
|
if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search"))
|
||||||
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → Agent → 网页搜索里开启后再补全");
|
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启后再补全");
|
||||||
// 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖
|
// 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖
|
||||||
const sub = (scope: string) => ({
|
const sub = (scope: string) => ({
|
||||||
actorId: scope,
|
actorId: scope,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ async function solveChallenge(env: AgentEnv, registry: CapabilityRegistry, input
|
|||||||
reason = "页面要求二次验证 / 短信 / 本人设备确认,不自动处理";
|
reason = "页面要求二次验证 / 短信 / 本人设备确认,不自动处理";
|
||||||
} else if (r.kind === "no_vision") {
|
} else if (r.kind === "no_vision") {
|
||||||
status = "needs_human";
|
status = "needs_human";
|
||||||
reason = "当前 Agent 上游模型不支持图片输入,不能做视觉自动化;换支持视觉的模型或人工处理";
|
reason = "当前模型不支持图片输入,不能做视觉自动化;换支持视觉的模型或人工处理";
|
||||||
} else if (r.kind === "no_upstream") {
|
} else if (r.kind === "no_upstream") {
|
||||||
status = "needs_human";
|
status = "needs_human";
|
||||||
reason = r.message;
|
reason = r.message;
|
||||||
@@ -230,7 +230,7 @@ async function runChild(
|
|||||||
childRunId: null,
|
childRunId: null,
|
||||||
steps: 0,
|
steps: 0,
|
||||||
outcome: null,
|
outcome: null,
|
||||||
message: "未配置 Agent 上游,不能做视觉自动化",
|
message: "未配置模型服务,不能做视觉自动化",
|
||||||
};
|
};
|
||||||
const fake = upstream.row.provider === "fake";
|
const fake = upstream.row.provider === "fake";
|
||||||
const s = await ctx.settings.all();
|
const s = await ctx.settings.all();
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const DEMO_SCRIPTS: FakeScript[] = [
|
|||||||
finalText: (results) => {
|
finalText: (results) => {
|
||||||
const r = results.find((x) => x.capabilityId === "inventory.query");
|
const r = results.find((x) => x.capabilityId === "inventory.query");
|
||||||
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
|
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
|
||||||
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
|
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置模型服务。`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -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> {
|
async function runOf(ctx: ServerContext, c: HandlerContext | InvokeContext): Promise<AgentRunRow> {
|
||||||
const id = c.agentRunId && isUuid(c.agentRunId) ? c.agentRunId : null;
|
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();
|
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;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ export async function inlineApproval(
|
|||||||
const summary = await planSummary(db, plan.planType, plan.planId);
|
const summary = await planSummary(db, plan.planType, plan.planId);
|
||||||
const decision = await insertDecision(sctx, {
|
const decision = await insertDecision(sctx, {
|
||||||
kind: "approval_required",
|
kind: "approval_required",
|
||||||
title: `Agent 请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
|
title: `智能体请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
|
||||||
summary,
|
summary,
|
||||||
runId,
|
runId,
|
||||||
dedupeKey: `${APPROVAL_KEY}:${runId}:${inv}`,
|
dedupeKey: `${APPROVAL_KEY}:${runId}:${inv}`,
|
||||||
@@ -625,7 +625,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
|||||||
const stepId = await currentStepId(ctx, live, run.id);
|
const stepId = await currentStepId(ctx, live, run.id);
|
||||||
d = await insertDecision(ctx, {
|
d = await insertDecision(ctx, {
|
||||||
kind: "other",
|
kind: "other",
|
||||||
title: `Agent 等你选方案:${clip(input.question, 80)}`,
|
title: `智能体等你选方案:${clip(input.question, 80)}`,
|
||||||
summary: input.options.map((o) => `${o.id}. ${o.title}`).join(";"),
|
summary: input.options.map((o) => `${o.id}. ${o.title}`).join(";"),
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
dedupeKey,
|
dedupeKey,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function registerAgentRoutes(
|
|||||||
|
|
||||||
route(app, Api.agentRuns.get, async ({ params, query }) => {
|
route(app, Api.agentRuns.get, async ({ params, query }) => {
|
||||||
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||||
if (!run) throw new ApiException("not_found", "Agent 运行不存在");
|
if (!run) throw new ApiException("not_found", "智能体运行不存在");
|
||||||
const events = await db()
|
const events = await db()
|
||||||
.selectFrom("agent_run_event")
|
.selectFrom("agent_run_event")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -222,9 +222,9 @@ export function registerAgentRoutes(
|
|||||||
|
|
||||||
route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => {
|
route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => {
|
||||||
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||||
if (!run) throw new ApiException("not_found", "Agent 运行不存在");
|
if (!run) throw new ApiException("not_found", "智能体运行不存在");
|
||||||
if (body.optionId === null && !body.reason?.trim())
|
if (body.optionId === null && !body.reason?.trim())
|
||||||
throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案");
|
throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案");
|
||||||
const r = await answerOptions(ctx, run.id, body, actorId);
|
const r = await answerOptions(ctx, run.id, body, actorId);
|
||||||
if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
|
if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
|
||||||
if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
|
if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
|
||||||
@@ -427,7 +427,7 @@ export function registerAgentRoutes(
|
|||||||
let slotIds: string[];
|
let slotIds: string[];
|
||||||
if (body.slotId) {
|
if (body.slotId) {
|
||||||
const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst();
|
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];
|
slotIds = [s.id];
|
||||||
} else {
|
} else {
|
||||||
const entityId = body.entityId ?? rel.media_entity_id;
|
const entityId = body.entityId ?? rel.media_entity_id;
|
||||||
@@ -444,7 +444,7 @@ export function registerAgentRoutes(
|
|||||||
}
|
}
|
||||||
const rejected: { slotId: string | null; code: string; message: string }[] = [];
|
const rejected: { slotId: string | null; code: string; message: string }[] = [];
|
||||||
if (slotIds.length === 0)
|
if (slotIds.length === 0)
|
||||||
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" });
|
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有收录版本,先在订阅里新建" });
|
||||||
const invokeCtx = {
|
const invokeCtx = {
|
||||||
actorId,
|
actorId,
|
||||||
taskId: `http:${c.get("requestId") ?? "req"}`,
|
taskId: `http:${c.get("requestId") ?? "req"}`,
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ function systemPromptFor(
|
|||||||
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
|
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
|
||||||
const opts = sessionOptions(run);
|
const opts = sessionOptions(run);
|
||||||
return buildSystemPrompt({
|
return buildSystemPrompt({
|
||||||
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
|
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化智能体",
|
||||||
goal: run.goal,
|
goal: run.goal,
|
||||||
constraints,
|
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);
|
from agent_run where started_at >= ${day} and id <> ${run.id}`.execute(env.ctx.db);
|
||||||
const n = r.rows[0]?.n ?? 0;
|
const n = r.rows[0]?.n ?? 0;
|
||||||
const cost = r.rows[0]?.cost ?? 0;
|
const cost = r.rows[0]?.cost ?? 0;
|
||||||
if (n >= s.budget.agentMaxRunsPerDay) return `今日 Agent 运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`;
|
if (n >= s.budget.agentMaxRunsPerDay) return `今日智能体运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`;
|
||||||
if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日 Agent 费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`;
|
if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日智能体费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ export async function drive(
|
|||||||
let settings: AgentSettings;
|
let settings: AgentSettings;
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveRunUpstream(ctx, run.upstream_id);
|
const resolved = await resolveRunUpstream(ctx, run.upstream_id);
|
||||||
if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
|
if (!resolved) throw new Error("未配置模型服务(设置 → 智能体 → 模型服务)");
|
||||||
upstream = resolved;
|
upstream = resolved;
|
||||||
fake = upstream.row.provider === "fake";
|
fake = upstream.row.provider === "fake";
|
||||||
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
|
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ async function enqueueRun(ctx: ServerContext, run: AgentRunRow, key: string) {
|
|||||||
/** 追加用户消息:运行中由当前会话在本轮结束后消费;已结束/等待中则唤醒续跑(新的 runAgent run) */
|
/** 追加用户消息:运行中由当前会话在本轮结束后消费;已结束/等待中则唤醒续跑(新的 runAgent run) */
|
||||||
export async function appendMessage(ctx: ServerContext, runId: string, text: string, by: string): Promise<AgentRunRow> {
|
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();
|
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
|
const msg = await ctx.db
|
||||||
.insertInto("agent_run_message")
|
.insertInto("agent_run_message")
|
||||||
.values({ run_id: runId, text, created_by: by })
|
.values({ run_id: runId, text, created_by: by })
|
||||||
@@ -173,7 +173,7 @@ export async function appendMessage(ctx: ServerContext, runId: string, text: str
|
|||||||
/** 中止:只写取消标志,由会话监视器中止模型并走 finalize(不取消引擎 run,保证终态落库) */
|
/** 中止:只写取消标志,由会话监视器中止模型并走 finalize(不取消引擎 run,保证终态落库) */
|
||||||
export async function abortRun(ctx: ServerContext, runId: string, reason: string | undefined, by: string) {
|
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();
|
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;
|
if (TERMINAL.includes(run.status)) return run;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const row = await ctx.db
|
const row = await ctx.db
|
||||||
@@ -353,7 +353,7 @@ export async function finalize(
|
|||||||
// 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建
|
// 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建
|
||||||
await writeDecision(ctx, {
|
await writeDecision(ctx, {
|
||||||
kind: "other",
|
kind: "other",
|
||||||
title: "Agent 运行超出预算",
|
title: "智能体运行超出预算",
|
||||||
summary: res.message ?? "超出预算",
|
summary: res.message ?? "超出预算",
|
||||||
subjectId: runId,
|
subjectId: runId,
|
||||||
dedupeKey: await budgetDedupeKey(ctx, run),
|
dedupeKey: await budgetDedupeKey(ctx, run),
|
||||||
|
|||||||
@@ -707,9 +707,9 @@ export interface SpawnDeps {
|
|||||||
|
|
||||||
async function parentOf(ctx: ServerContext, c: HandlerContext): Promise<AgentRunRow> {
|
async function parentOf(ctx: ServerContext, c: HandlerContext): Promise<AgentRunRow> {
|
||||||
const id = c.agentRunId;
|
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();
|
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;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +719,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
|
|||||||
const parent = await parentOf(ctx, c);
|
const parent = await parentOf(ctx, c);
|
||||||
const s = await ctx.settings.all();
|
const s = await ctx.settings.all();
|
||||||
const cfg = subagentSettings(s);
|
const cfg = subagentSettings(s);
|
||||||
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)");
|
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → 智能体 → 允许子会话)");
|
||||||
if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
|
if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
|
||||||
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
|
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
|
||||||
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
|
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
|
|||||||
key: "auth.cleanup",
|
key: "auth.cleanup",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "登录会话清理",
|
name: "登录会话清理",
|
||||||
description: "删除 7 天前的登录尝试和 30 天前过期或吊销的会话",
|
description: "删除 7 天前的登录尝试和 30 天前失效的会话",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs },
|
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";
|
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
|
||||||
const NOUN: Record<Table, string> = {
|
const NOUN: Record<Table, string> = {
|
||||||
library_root: "库根",
|
library_root: "媒体库目录",
|
||||||
downloader_instance: "下载器实例",
|
downloader_instance: "下载器实例",
|
||||||
media_server_instance: "媒体服务器实例",
|
media_server_instance: "媒体服务器实例",
|
||||||
path_mapping: "路径映射",
|
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 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,
|
row: LibraryRoot,
|
||||||
create: LibraryRootCreate,
|
create: LibraryRootCreate,
|
||||||
update: LibraryRootUpdate,
|
update: LibraryRootUpdate,
|
||||||
@@ -182,7 +182,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiException(
|
throw new ApiException(
|
||||||
"precondition_failed",
|
"precondition_failed",
|
||||||
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
|
`媒体库目录不可写(需要目录存在且可写入标记文件 ${name}):${errMessage(e)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
@@ -216,7 +216,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
|||||||
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
|
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
|
||||||
endpoint(
|
endpoint(
|
||||||
app,
|
app,
|
||||||
test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"),
|
test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 哨兵文件)", "library-roots"),
|
||||||
async ({ params, actor }) => {
|
async ({ params, actor }) => {
|
||||||
const row = await getRow("library_root", params.id);
|
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);
|
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(files).toHaveLength(1);
|
||||||
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
|
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
|
const entity = await h.db
|
||||||
.insertInto("media_entity")
|
.insertInto("media_entity")
|
||||||
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
||||||
|
|||||||
@@ -1579,7 +1579,7 @@ export class BrowserService {
|
|||||||
const give = opts.wantImage && !blocked && (!sensitive || opts.sensitiveAllowed);
|
const give = opts.wantImage && !blocked && (!sensitive || opts.sensitiveAllowed);
|
||||||
let note: string | null = null;
|
let note: string | null = null;
|
||||||
if (blocked) note = "页面要求二次验证 / 短信 / 本人设备确认:不自动处理,会话已转等待处理,由人在原站视图接管";
|
if (blocked) note = "页面要求二次验证 / 短信 / 本人设备确认:不自动处理,会话已转等待处理,由人在原站视图接管";
|
||||||
else if (!opts.wantImage) note = "当前上游模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
|
else if (!opts.wantImage) note = "当前模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
|
||||||
else if (sensitive && !opts.sensitiveAllowed)
|
else if (sensitive && !opts.sensitiveAllowed)
|
||||||
note =
|
note =
|
||||||
"登录页 / 含密码框的截图属于敏感截图,默认不交给模型(需要 browser.sensitive_screenshot 持续授权并传 allowSensitive);下面是已脱敏的文字快照";
|
"登录页 / 含密码框的截图属于敏感截图,默认不交给模型(需要 browser.sensitive_screenshot 持续授权并传 allowSensitive);下面是已脱敏的文字快照";
|
||||||
@@ -1801,7 +1801,7 @@ const REASON_TEXT: Record<string, string> = {
|
|||||||
challenge_unsolved: "自动处理页面挑战未成功",
|
challenge_unsolved: "自动处理页面挑战未成功",
|
||||||
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
||||||
lease_expired: "人工接管超时未交还",
|
lease_expired: "人工接管超时未交还",
|
||||||
agent_request: "Agent 请求人工处理",
|
agent_request: "智能体请求人工处理",
|
||||||
human_released_waiting: "人工交还时选择继续等待",
|
human_released_waiting: "人工交还时选择继续等待",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -183,7 +183,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
key: "cache.sweep",
|
key: "cache.sweep",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "缓存清理",
|
name: "缓存清理",
|
||||||
description: "删除 PG 缓存里已过期的条目(TMDB 请求与识别缓存)",
|
description: "删除过期的 TMDB 与识别缓存",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 10 * 60_000 },
|
schedule: { type: "interval", everyMs: 10 * 60_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
@@ -194,7 +194,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
key: "cache.images_clean",
|
key: "cache.images_clean",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "图片缓存清理",
|
name: "图片缓存清理",
|
||||||
description: "删除超过保留天数的图片代理缓存文件(对照 MP 启动时的 clear_temp)",
|
description: "删除过期的海报缓存",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||||
},
|
},
|
||||||
@@ -208,7 +208,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
key: "cache.images_lru",
|
key: "cache.images_lru",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "图片缓存容量控制",
|
name: "图片缓存容量控制",
|
||||||
description: "图片代理缓存超过容量预算(存储设置,缺省 20 GB)时,按最久未访问删除到预算的 90%",
|
description: "海报缓存超过容量上限时,删掉最久没用的",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 30 * 60_000 },
|
schedule: { type: "interval", everyMs: 30 * 60_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export async function organizeFiles(
|
|||||||
if (it.targetDir) {
|
if (it.targetDir) {
|
||||||
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
|
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
|
||||||
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
|
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)))
|
if (!(await isWritableTarget(t.def, t.rootReal)))
|
||||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
|
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
|
||||||
targetDir = join(t.def.hostPath, t.rel);
|
targetDir = join(t.def.hostPath, t.rel);
|
||||||
@@ -264,10 +264,10 @@ export async function organizeFiles(
|
|||||||
if (slotId) {
|
if (slotId) {
|
||||||
const s = slots.get(slotId);
|
const s = slots.get(slotId);
|
||||||
if (!s || s.media_entity_id !== entityId) {
|
if (!s || s.media_entity_id !== entityId) {
|
||||||
warnings.push(`${r.rel}:版本目标 ${slotId} 不属于该作品,未应用`);
|
warnings.push(`${r.rel}:收录版本 ${slotId} 不属于该作品,未应用`);
|
||||||
slotId = null;
|
slotId = null;
|
||||||
} else if (BUSY_SLOT.has(s.status)) {
|
} else if (BUSY_SLOT.has(s.status)) {
|
||||||
warnings.push(`${r.rel}:版本目标正在获取中(${s.status}),不改其状态`);
|
warnings.push(`${r.rel}:收录版本正在获取中(${s.status}),不改其状态`);
|
||||||
slotId = null;
|
slotId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export async function scrape(
|
|||||||
.where("media_entity_id", "=", e.id)
|
.where("media_entity_id", "=", e.id)
|
||||||
.where("status", "=", "active")
|
.where("status", "=", "active")
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
|
if (!registered) throw new FilesError("bad_request", "作品没有已入库文件。先识别并整理入库,再刮削");
|
||||||
const { runId } = await d.ctx.engine.enqueue(
|
const { runId } = await d.ctx.engine.enqueue(
|
||||||
"refreshMetadata",
|
"refreshMetadata",
|
||||||
{ mediaEntityId: e.id },
|
{ mediaEntityId: e.id },
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function transfer(
|
|||||||
const t = await resolveIn(d, body.target.root, body.target.path);
|
const t = await resolveIn(d, body.target.root, body.target.path);
|
||||||
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
|
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
|
||||||
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
|
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[] = [];
|
const sources: Resolved[] = [];
|
||||||
for (const it of body.items) {
|
for (const it of body.items) {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const BLOCK_ERR: Record<
|
|||||||
> = {
|
> = {
|
||||||
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
||||||
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
||||||
needs_retire: ["needs_retire", "含已登记资产,需勾选「同时退役资产」"],
|
needs_retire: ["needs_retire", "含已入库文件,需勾选「同时从媒体库移除」"],
|
||||||
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
|
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
|
||||||
root_unavailable: ["root_unavailable", "根不可用"],
|
root_unavailable: ["root_unavailable", "根不可用"],
|
||||||
not_found: ["not_found", "不存在"],
|
not_found: ["not_found", "不存在"],
|
||||||
@@ -276,8 +276,8 @@ export async function deleteFiles(
|
|||||||
for (const s of slots)
|
for (const s of slots)
|
||||||
await upsertDecision(d.ao, trx, {
|
await upsertDecision(d.ao, trx, {
|
||||||
kind: "other",
|
kind: "other",
|
||||||
title: `版本目标的文件已删除:${s.name}`,
|
title: `收录版本的文件已删除:${s.name}`,
|
||||||
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
|
summary: "满足该收录版本的文件已在文件管理中删除,收录版本改为需关注。确认是否重新获取",
|
||||||
subjectType: "acquisition_slot",
|
subjectType: "acquisition_slot",
|
||||||
subjectId: s.id,
|
subjectId: s.id,
|
||||||
dedupeKey: `files_delete_slot:${s.id}`,
|
dedupeKey: `files_delete_slot:${s.id}`,
|
||||||
@@ -509,7 +509,7 @@ export async function restoreTrash(
|
|||||||
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
|
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
|
||||||
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
|
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
|
||||||
if (target && !(await isWritableTarget(target.def, target.rootReal)))
|
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) {
|
for (const id of body.ids) {
|
||||||
try {
|
try {
|
||||||
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
|
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",
|
uri: "umt://settings/summary",
|
||||||
name: "settings-summary",
|
name: "settings-summary",
|
||||||
title: "设置概要",
|
title: "设置概要",
|
||||||
description: "自动化总开关、只读对账、预算与 Agent 运行上限。不含凭证。",
|
description: "自动化总开关、只读对账、预算与智能体运行上限。不含凭证。",
|
||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
async load(ctx) {
|
async load(ctx) {
|
||||||
const s = await ctx.settings.all();
|
const s = await ctx.settings.all();
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function createMcpConfigStore(db: () => Kysely<DB>): McpConfigStore {
|
|||||||
|
|
||||||
/** 这个 Key 能不能连 MCP:总开关开着,且在允许列表里(列表为 null 表示不限) */
|
/** 这个 Key 能不能连 MCP:总开关开着,且在允许列表里(列表为 null 表示不限) */
|
||||||
export function keyAllowed(cfg: McpServerConfig, principal: { kind: string; id: string }): string | 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)) {
|
if (principal.kind === "api_key" && cfg.allowedApiKeyIds && !cfg.allowedApiKeyIds.includes(principal.id)) {
|
||||||
return "这个 API Key 不在 MCP 允许列表里";
|
return "这个 API Key 不在 MCP 允许列表里";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function createMcpServer(
|
|||||||
prompts: { listChanged: false },
|
prompts: { listChanged: false },
|
||||||
},
|
},
|
||||||
instructions:
|
instructions:
|
||||||
"工具与控制台、内置 Agent 共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
|
"工具与控制台、内置智能体共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const limiter = sharedInvokeLimiter();
|
const limiter = sharedInvokeLimiter();
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export function createPiModule(
|
|||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "pi.token_refresh",
|
key: "pi.token_refresh",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "上游登录刷新",
|
name: "模型服务登录刷新",
|
||||||
description: "订阅登录即将过期的 Agent 上游,提前刷新 token",
|
description: "订阅登录快过期时提前续期",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: interval },
|
schedule: { type: "interval", everyMs: interval },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ function keyOf(cred: UpstreamCredential | undefined): string | null {
|
|||||||
/** 按 provider / 认证方式挑模型列表接口 */
|
/** 按 provider / 认证方式挑模型列表接口 */
|
||||||
function planFor(spec: UpstreamSpec, cred: UpstreamCredential | undefined): Plan {
|
function planFor(spec: UpstreamSpec, cred: UpstreamCredential | undefined): Plan {
|
||||||
const extra = spec.extra ?? {};
|
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" };
|
if (spec.provider === "radius") return { kind: "pi" };
|
||||||
const custom = spec.provider === "openai-compatible";
|
const custom = spec.provider === "openai-compatible";
|
||||||
// 订阅登录只有 OpenRouter 换到的是普通 API Key,其它订阅 token 不能调公开的模型列表接口
|
// 订阅登录只有 OpenRouter 换到的是普通 API Key,其它订阅 token 不能调公开的模型列表接口
|
||||||
if (spec.authKind === "oauth" && spec.provider !== "openrouter")
|
if (spec.authKind === "oauth" && spec.provider !== "openrouter")
|
||||||
return { kind: "unsupported", reason: "订阅登录的上游没有公开的模型列表接口,用内置模型表" };
|
return { kind: "unsupported", reason: "订阅登录的模型服务没有公开的模型列表接口,用内置模型表" };
|
||||||
const key = keyOf(cred);
|
const key = keyOf(cred);
|
||||||
if (spec.authKind !== "none" && !key && !custom) throw new FetchError("auth", "还没有配置 API Key");
|
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[] {
|
function parseList(shape: Shape, body: unknown): Remote[] {
|
||||||
const b = obj(body);
|
const b = obj(body);
|
||||||
const raw = Array.isArray(body) ? body : Array.isArray(b.data) ? b.data : Array.isArray(b.models) ? b.models : null;
|
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 norm = shape === "anthropic" ? normalizeAnthropic : shape === "google" ? normalizeGoogle : normalizeOpenAI;
|
||||||
const out: Remote[] = [];
|
const out: Remote[] = [];
|
||||||
const seen = new Set<string>();
|
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 === "unsupported") throw new FetchError("unsupported", plan.reason);
|
||||||
if (plan.kind === "http") return await fetchList(plan.endpoint);
|
if (plan.kind === "http") return await fetchList(plan.endpoint);
|
||||||
// Radius:pi-ai 自带的动态刷新
|
// Radius:pi-ai 自带的动态刷新
|
||||||
if (!client) throw new FetchError("config", "上游配置不完整");
|
if (!client) throw new FetchError("config", "模型服务配置不完整");
|
||||||
const r = await client.listModels({ refresh: true });
|
const r = await client.listModels({ refresh: true });
|
||||||
if (!r.refreshed) throw toFetchError(new Error(r.refreshError ?? "刷新失败"));
|
if (!r.refreshed) throw toFetchError(new Error(r.refreshError ?? "刷新失败"));
|
||||||
return r.items.map(fromInfo);
|
return r.items.map(fromInfo);
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export class OAuthSessions {
|
|||||||
async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> {
|
async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> {
|
||||||
const info = providerInfo(row.provider);
|
const info = providerInfo(row.provider);
|
||||||
if (row.auth_kind !== "oauth" || !info?.oauth)
|
if (row.auth_kind !== "oauth" || !info?.oauth)
|
||||||
throw new ApiException("precondition_failed", "这个上游不是订阅登录类型");
|
throw new ApiException("precondition_failed", "这个模型服务不是订阅登录类型");
|
||||||
if (!row.enabled) throw new ApiException("precondition_failed", "上游已停用");
|
if (!row.enabled) throw new ApiException("precondition_failed", "模型服务已停用");
|
||||||
const mode = req.mode ?? info.oauth.modes[0]!;
|
const mode = req.mode ?? info.oauth.modes[0]!;
|
||||||
if (!info.oauth.modes.includes(mode))
|
if (!info.oauth.modes.includes(mode))
|
||||||
throw new ApiException(
|
throw new ApiException(
|
||||||
@@ -270,6 +270,6 @@ export class OAuthSessions {
|
|||||||
|
|
||||||
export async function requireRow(ctx: ServerContext, id: string): Promise<PiUpstreamRow> {
|
export async function requireRow(ctx: ServerContext, id: string): Promise<PiUpstreamRow> {
|
||||||
const row = await getRow(ctx, id);
|
const row = await getRow(ctx, id);
|
||||||
if (!row) throw new ApiException("not_found", "上游不存在");
|
if (!row) throw new ApiException("not_found", "模型服务不存在");
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -24,13 +24,13 @@ const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
|
|||||||
|
|
||||||
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
|
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
|
||||||
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
|
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) => {
|
await ctx.db.transaction().execute(async (trx) => {
|
||||||
const r = await sql<{ id: string; inserted: boolean }>`
|
const r = await sql<{ id: string; inserted: boolean }>`
|
||||||
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
|
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)},
|
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({ 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')
|
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
|
||||||
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
|
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
@@ -102,7 +102,7 @@ export async function refreshPiTokens(ctx: ServerContext, opts: RefreshOptions =
|
|||||||
if (r.ok) report.refreshed += 1;
|
if (r.ok) report.refreshed += 1;
|
||||||
else {
|
else {
|
||||||
report.failed.push({ upstreamId: row.id, message: r.message });
|
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;
|
return report;
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export async function createUpstream(
|
|||||||
.execute();
|
.execute();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (secretRef) await ctx.secrets.remove(secretRef).catch(() => {});
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
if (body.makeDefault || !(await defaultUpstreamId(ctx))) await setDefaultUpstream(ctx, id);
|
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;
|
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
|
||||||
validateShape({ provider: row.provider, authKind, baseUrl, defaultModel });
|
validateShape({ provider: row.provider, authKind, baseUrl, defaultModel });
|
||||||
if (body.apiKey && authKind !== "api_key")
|
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 kindChanged = authKind !== row.auth_kind;
|
||||||
const store = dbCredentialStore(ctx, row.id);
|
const store = dbCredentialStore(ctx, row.id);
|
||||||
@@ -167,7 +167,7 @@ export async function updateUpstream(
|
|||||||
.where("id", "=", row.id)
|
.where("id", "=", row.id)
|
||||||
.execute()
|
.execute()
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (isUnique(err)) throw new ApiException("conflict", `上游名称「${body.name}」已存在`);
|
if (isUnique(err)) throw new ApiException("conflict", `模型服务名称「${body.name}」已存在`);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (typeof body.apiKey === "string") {
|
if (typeof body.apiKey === "string") {
|
||||||
@@ -217,10 +217,11 @@ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string
|
|||||||
const id = upstreamId ?? (await defaultUpstreamId(ctx));
|
const id = upstreamId ?? (await defaultUpstreamId(ctx));
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const row = await getRow(ctx, id);
|
const row = await getRow(ctx, id);
|
||||||
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
|
if (!row)
|
||||||
if (!row.enabled) throw new Error(`上游「${row.name}」已停用`);
|
throw new Error(upstreamId ? `指定的模型服务 ${upstreamId} 不存在` : "默认模型服务已不存在,在设置里重新选择");
|
||||||
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
|
if (!row.enabled) throw new Error(`模型服务「${row.name}」已停用`);
|
||||||
|
if (row.status === "expired") throw new Error(`模型服务「${row.name}」登录已失效,在设置里重新登录`);
|
||||||
if (row.status === "auth_required")
|
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) };
|
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 {
|
try {
|
||||||
return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref));
|
return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref));
|
||||||
} catch (err) {
|
} 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;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ export function dbCredentialStore(ctx: Ctx, upstreamId: string): UpstreamCredent
|
|||||||
.where("id", "=", upstreamId)
|
.where("id", "=", upstreamId)
|
||||||
.forUpdate()
|
.forUpdate()
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!row) throw new Error("上游不存在或已删除");
|
if (!row) throw new Error("模型服务不存在或已删除");
|
||||||
const cur = await readSecret(ctx, row);
|
const cur = await readSecret(ctx, row);
|
||||||
const next = await fn(cur);
|
const next = await fn(cur);
|
||||||
if (next === undefined) return 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 === "stalled_downloads" && f.stalledDownloads === 0) ||
|
||||||
(cfg.triggerCondition === "subscriptions_active" && f.activeSubscriptions === 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 mode: "execute" | "plan_only" = "plan_only";
|
||||||
let allowlist = cfg.capabilityAllowlist.filter(isPlanOnly);
|
let allowlist = cfg.capabilityAllowlist.filter(isPlanOnly);
|
||||||
@@ -138,7 +138,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
|
|||||||
budget: cfg.budget,
|
budget: cfg.budget,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
summary: `${created ? "已入队" : "本时段已有"} Agent 运行(${mode === "execute" ? "授权内恢复" : "只读"})${note}`,
|
summary: `${created ? "已入队" : "本时段已有"} 智能体运行(${mode === "execute" ? "授权内恢复" : "只读"})${note}`,
|
||||||
agentRunId: run.id,
|
agentRunId: run.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
|||||||
key: "maintain.backup",
|
key: "maintain.backup",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "备份",
|
name: "备份",
|
||||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
description: "数据库与数据目录备份,目前在服务器上用命令执行",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "cron", cron: "30 3 * * *" },
|
schedule: { type: "cron", cron: "30 3 * * *" },
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export class Scheduler {
|
|||||||
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
|
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
|
||||||
limit 50`.execute(this.db);
|
limit 50`.execute(this.db);
|
||||||
for (const r of rows.rows) {
|
for (const r of rows.rows) {
|
||||||
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
|
const summary = (r.summary ?? "").trim() || `智能体运行结束:${r.status}`;
|
||||||
await this.db
|
await this.db
|
||||||
.updateTable("scheduled_job_run")
|
.updateTable("scheduled_job_run")
|
||||||
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
|
.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[] = [
|
export const SEARCH_ACTIONS: readonly SearchAction[] = [
|
||||||
page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]),
|
page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]),
|
||||||
page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]),
|
page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]),
|
||||||
page("agent", "概览", "Agent 会话", "/console/agent", ["agent", "会话"]),
|
page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]),
|
||||||
page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]),
|
page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]),
|
||||||
page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]),
|
page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]),
|
||||||
page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]),
|
page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]),
|
||||||
@@ -34,13 +34,13 @@ export const SEARCH_ACTIONS: readonly SearchAction[] = [
|
|||||||
page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]),
|
page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]),
|
||||||
page("files", "整理", "文件管理", "/console/files", ["files", "文件"]),
|
page("files", "整理", "文件管理", "/console/files", ["files", "文件"]),
|
||||||
page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]),
|
page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]),
|
||||||
page("library", "整理", "媒体概览", "/console/library", ["library", "媒体库", "作品"]),
|
page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]),
|
||||||
page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]),
|
page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]),
|
||||||
page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]),
|
page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]),
|
||||||
page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]),
|
page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]),
|
||||||
page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]),
|
page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]),
|
||||||
action("new-subscription", "订阅", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]),
|
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-site", "资源", "添加站点", "/console/sites?new=1", ["site", "站点"]),
|
||||||
action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]),
|
action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]),
|
||||||
action("new-schedule", "概览", "新建巡检", "/console/tasks?tab=plan&new=1", ["巡检", "agent_check"]),
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 各作品版本目标按状态计数 */
|
/** 各作品收录版本按状态计数 */
|
||||||
export async function slotCountsFor(ctx: ServerContext, ids: string[]): Promise<Map<string, SlotStatusCounts>> {
|
export async function slotCountsFor(ctx: ServerContext, ids: string[]): Promise<Map<string, SlotStatusCounts>> {
|
||||||
const out = new Map<string, SlotStatusCounts>();
|
const out = new Map<string, SlotStatusCounts>();
|
||||||
if (ids.length === 0) return out;
|
if (ids.length === 0) return out;
|
||||||
|
|||||||
@@ -61,15 +61,15 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
return { summary: `${await fn(rt)} 项` };
|
return { summary: `${await fn(rt)} 项` };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
|
||||||
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
||||||
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
|
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
|
||||||
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "sites.user_data",
|
key: "sites.user_data",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "站点数据刷新",
|
name: "站点数据刷新",
|
||||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
description: "刷新站点账号的上传、下载和分享率;登录失效的账密账号自动重登一次",
|
||||||
group: "collect",
|
group: "collect",
|
||||||
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
@@ -83,7 +83,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
key: "sites.health_check",
|
key: "sites.health_check",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "站点健康检测",
|
name: "站点健康检测",
|
||||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
description: "检查到期的站点能否打开、是否还在登录(每个站点按自己的检测间隔),结果在站点页",
|
||||||
group: "collect",
|
group: "collect",
|
||||||
schedule: { type: "interval", everyMs: 3_600_000 },
|
schedule: { type: "interval", everyMs: 3_600_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
@@ -95,8 +95,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
key: BACKFILL_LANGUAGES_JOB,
|
key: BACKFILL_LANGUAGES_JOB,
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "元数据语言回刷",
|
name: "元数据语言回刷",
|
||||||
description:
|
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行",
|
||||||
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
|
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||||
firstRun: "now",
|
firstRun: "now",
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function enrichRoutes(app: ModuleApp, rt: () => SourcesRuntime): void {
|
|||||||
path: "/identity-pool/{id}/enrich/apply",
|
path: "/identity-pool/{id}/enrich/apply",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
auth: "session",
|
auth: "session",
|
||||||
summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联待识别资源(只接受控制台管理员会话)",
|
summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联未识别资源(只接受控制台管理员会话)",
|
||||||
tags: T_ID,
|
tags: T_ID,
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
body: EnrichApplyInput,
|
body: EnrichApplyInput,
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/identity-pool",
|
path: "/identity-pool",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
summary: "未识别资源(可按原始标题检索;候选不建正式作品)",
|
||||||
tags: T_ID,
|
tags: T_ID,
|
||||||
query: IdentityPoolQuery,
|
query: IdentityPoolQuery,
|
||||||
response: cursorPage(IdentityPoolItem),
|
response: cursorPage(IdentityPoolItem),
|
||||||
@@ -623,7 +623,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/identity-pool/{id}/assign",
|
path: "/identity-pool/{id}/assign",
|
||||||
scope: "organize.execute",
|
scope: "organize.execute",
|
||||||
summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份",
|
summary: "为未识别资源手动指定候选 / 本地作品 / 外部身份",
|
||||||
tags: T_ID,
|
tags: T_ID,
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
body: ReleaseIdentityAssign,
|
body: ReleaseIdentityAssign,
|
||||||
@@ -635,7 +635,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
|||||||
.select(["source_release_id"])
|
.select(["source_release_id"])
|
||||||
.where("id", "=", params.id)
|
.where("id", "=", params.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
|
||||||
return assignReleaseIdentity(rt(), pool.source_release_id, body, actor);
|
return assignReleaseIdentity(rt(), pool.source_release_id, body, actor);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -645,7 +645,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/identity-pool/{id}/dismiss",
|
path: "/identity-pool/{id}/dismiss",
|
||||||
scope: "organize.execute",
|
scope: "organize.execute",
|
||||||
summary: "忽略待识别资源(不再自动复查)",
|
summary: "忽略未识别资源(不再自动复查)",
|
||||||
tags: T_ID,
|
tags: T_ID,
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
body: z.object({ note: z.string().max(2000).optional() }),
|
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"])
|
.select(["source_release_id", "attempts"])
|
||||||
.where("id", "=", params.id)
|
.where("id", "=", params.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
|
||||||
const r = await rt().ctx.engine.enqueue(
|
const r = await rt().ctx.engine.enqueue(
|
||||||
"resolveObservation",
|
"resolveObservation",
|
||||||
// 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径)
|
// 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const MAX_SEARCHES = 2;
|
|||||||
const SITE_FILTER_DOMAINS = 6;
|
const SITE_FILTER_DOMAINS = 6;
|
||||||
/** 交给模型的每页正文上限 */
|
/** 交给模型的每页正文上限 */
|
||||||
const PAGE_CHARS_FOR_MODEL = 6000;
|
const PAGE_CHARS_FOR_MODEL = 6000;
|
||||||
export const ENRICH_NOTICE = "字段来自网页与 Agent 抽取,不是 TMDB 数据;应用前核对出处";
|
export const ENRICH_NOTICE = "字段来自网页与智能体抽取,不是 TMDB 数据;应用前核对出处";
|
||||||
|
|
||||||
export class EnrichError extends Error {
|
export class EnrichError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -411,7 +411,7 @@ async function resolveQuery(ctx: Pick<ServerContext, "db">, input: MetadataEnric
|
|||||||
.select(["p.id", "p.query", "r.title as release_title"])
|
.select(["p.id", "p.query", "r.title as release_title"])
|
||||||
.where("p.id", "=", input.poolEntryId)
|
.where("p.id", "=", input.poolEntryId)
|
||||||
.executeTakeFirst();
|
.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 q = (p.query ?? {}) as { title?: string | null; titleCn?: string | null; year?: number | null };
|
||||||
const title = (q.titleCn || q.title || "").trim();
|
const title = (q.titleCn || q.title || "").trim();
|
||||||
if (!title) throw new EnrichError("precondition", "这条资源没有解析出标题,无法补全");
|
if (!title) throw new EnrichError("precondition", "这条资源没有解析出标题,无法补全");
|
||||||
@@ -737,7 +737,7 @@ export async function applyEnrichment(
|
|||||||
.select(["id", "source_release_id", "status"])
|
.select(["id", "source_release_id", "status"])
|
||||||
.where("id", "=", poolEntryId)
|
.where("id", "=", poolEntryId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
if (!pool) throw new ApiException("not_found", "未识别条目不存在");
|
||||||
const row = await ctx.db
|
const row = await ctx.db
|
||||||
.selectFrom("metadata_enrichment")
|
.selectFrom("metadata_enrichment")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ async function entityForExternal(
|
|||||||
const hit = await entityByExternal(exec, ref);
|
const hit = await entityByExternal(exec, ref);
|
||||||
if (hit) return { entityId: hit, created: false };
|
if (hit) return { entityId: hit, created: false };
|
||||||
if (ref.provider !== "tmdb")
|
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();
|
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>>;
|
let detail: Awaited<ReturnType<typeof tmdb.details>>;
|
||||||
try {
|
try {
|
||||||
detail = await tmdb.details(ref.entityType, ref.externalId);
|
detail = await tmdb.details(ref.entityType, ref.externalId);
|
||||||
@@ -480,7 +483,7 @@ export async function dismissPoolEntry(rt: SourcesRuntime, poolId: string, actor
|
|||||||
.where("id", "=", poolId)
|
.where("id", "=", poolId)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!row) throw new ApiException("not_found", "待识别条目不存在");
|
if (!row) throw new ApiException("not_found", "未识别条目不存在");
|
||||||
await trx
|
await trx
|
||||||
.updateTable("source_release")
|
.updateTable("source_release")
|
||||||
.set({ resolution_status: "rejected" })
|
.set({ resolution_status: "rejected" })
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
|
|||||||
{
|
{
|
||||||
key: "manual_tmdb",
|
key: "manual_tmdb",
|
||||||
label: "手动指定 TMDB ID",
|
label: "手动指定 TMDB ID",
|
||||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
description: "按 TMDB 电影或剧集 ID 加入媒体库并关联,加人工锁",
|
||||||
input: { sourceReleaseId, requires: "tmdb" },
|
input: { sourceReleaseId, requires: "tmdb" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@ function titleFor(
|
|||||||
one: string,
|
one: string,
|
||||||
work: string | null,
|
work: string | null,
|
||||||
) {
|
) {
|
||||||
const what = kind === "identity_ambiguous" ? "待确认身份" : "待识别";
|
const what = kind === "identity_ambiguous" ? "没识别出作品" : "未识别";
|
||||||
if (n <= 1) return `${what}:${one}`;
|
if (n <= 1) return `${what}:${one}`;
|
||||||
return `来源「${siteName}」的 ${n} 条资源${what}${work ? `:${work}` : ""}`;
|
return `来源「${siteName}」的 ${n} 条资源${what}${work ? `:${work}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
|||||||
key: "system.heartbeat_cleanup",
|
key: "system.heartbeat_cleanup",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "进程心跳清理",
|
name: "进程心跳清理",
|
||||||
description: "删除 24 小时没有心跳的进程行和超过保留期(存储设置,缺省 24 小时)的心跳采样",
|
description: "删除过期的进程状态记录",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs },
|
schedule: { type: "interval", everyMs },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "observation",
|
target: "observation",
|
||||||
jobId: "storage.retention.observation",
|
jobId: "storage.retention.observation",
|
||||||
name: "观察记录清理",
|
name: "RSS 抓取记录清理",
|
||||||
description: "删除超过保留期的 Feed 观察记录(observation,按 created_at)",
|
description: "删除过期的 RSS 抓取记录",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.observation} 天`,
|
retention: (s) => `${s.retentionDays.observation} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -118,8 +118,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "sse_event",
|
target: "sse_event",
|
||||||
jobId: "storage.retention.sse_event",
|
jobId: "storage.retention.sse_event",
|
||||||
name: "SSE 事件清理",
|
name: "实时事件清理",
|
||||||
description: "删除超过保留期的 SSE 补读事件(sse_event,按 at;发布方另按条数裁剪)",
|
description: "删除过期的页面实时推送记录",
|
||||||
everyMs: HOUR,
|
everyMs: HOUR,
|
||||||
retention: (s) => `${s.retentionDays.sseEvent} 天`,
|
retention: (s) => `${s.retentionDays.sseEvent} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -132,7 +132,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "task_run",
|
target: "task_run",
|
||||||
jobId: "storage.retention.task_run",
|
jobId: "storage.retention.task_run",
|
||||||
name: "任务运行清理",
|
name: "任务运行清理",
|
||||||
description: "删除超过保留期、已结束的业务任务(task)与引擎 run(含步骤、运行元数据)",
|
description: "删除过期的已结束任务",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.taskRun} 天`,
|
retention: (s) => `${s.retentionDays.taskRun} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -167,8 +167,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "agent_event",
|
target: "agent_event",
|
||||||
jobId: "storage.retention.agent_event",
|
jobId: "storage.retention.agent_event",
|
||||||
name: "Agent 事件清理",
|
name: "智能体事件清理",
|
||||||
description: "删除超过保留期、会话已结束的 Agent 事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
|
description: "删除已结束会话的过期消息,会话本身保留",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.agentEvent} 天`,
|
retention: (s) => `${s.retentionDays.agentEvent} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -182,14 +182,14 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
sql`created_at < ${cutoff} and status = 'consumed' and ${ended}`,
|
sql`created_at < ${cutoff} and status = 'consumed' and ${ended}`,
|
||||||
c.batch,
|
c.batch,
|
||||||
);
|
);
|
||||||
return `Agent 事件 ${ev.deleted}、消息 ${msg.deleted} 行已删除${ev.truncated || msg.truncated ? ",未删完,下次继续" : ""}`;
|
return `智能体事件 ${ev.deleted}、消息 ${msg.deleted} 行已删除${ev.truncated || msg.truncated ? ",未删完,下次继续" : ""}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "audit",
|
target: "audit",
|
||||||
jobId: "storage.retention.audit",
|
jobId: "storage.retention.audit",
|
||||||
name: "审计清理",
|
name: "审计清理",
|
||||||
description: "删除超过保留期的审计事件(audit_event,能力调用审计按能力统计的保留期另算)",
|
description: "删除过期的审计日志",
|
||||||
everyMs: 24 * HOUR,
|
everyMs: 24 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.audit} 天`,
|
retention: (s) => `${s.retentionDays.audit} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -207,7 +207,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "capability_stats",
|
target: "capability_stats",
|
||||||
jobId: "storage.retention.capability_stats",
|
jobId: "storage.retention.capability_stats",
|
||||||
name: "能力统计清理",
|
name: "能力统计清理",
|
||||||
description: "删除超过保留期的能力调用审计(audit_event target_type=capability)与已结束的能力调用记录",
|
description: "删除过期的能力调用记录",
|
||||||
everyMs: 24 * HOUR,
|
everyMs: 24 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
|
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -230,8 +230,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "identity_pool",
|
target: "identity_pool",
|
||||||
jobId: "storage.retention.identity_pool",
|
jobId: "storage.retention.identity_pool",
|
||||||
name: "待识别池归档",
|
name: "未识别资源归档",
|
||||||
description: "待识别池里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
|
description: "长期没有新线索的未识别资源归档,仍可手动指定",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
|
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -246,14 +246,14 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
archived += n;
|
archived += n;
|
||||||
if (n < size) break;
|
if (n < size) break;
|
||||||
}
|
}
|
||||||
return `归档 ${archived} 条待识别条目`;
|
return `归档 ${archived} 条未识别条目`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "analyze",
|
target: "analyze",
|
||||||
jobId: "storage.analyze",
|
jobId: "storage.analyze",
|
||||||
name: "统计信息更新",
|
name: "数据库维护",
|
||||||
description: "对全库执行 ANALYZE,刷新查询规划用的统计信息(每周)",
|
description: "每周刷新数据库统计信息",
|
||||||
everyMs: 7 * 24 * HOUR,
|
everyMs: 7 * 24 * HOUR,
|
||||||
retention: () => null,
|
retention: () => null,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export class WebSearchService {
|
|||||||
|
|
||||||
private async client(s: WebSearchSettings) {
|
private async client(s: WebSearchSettings) {
|
||||||
if (s.provider === "none")
|
if (s.provider === "none")
|
||||||
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → Agent → 网页搜索里开启");
|
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启");
|
||||||
if (!s.apiKeySecretRef) throw new WebSearchServiceError("auth", "未保存搜索服务 API Key");
|
if (!s.apiKeySecretRef) throw new WebSearchServiceError("auth", "未保存搜索服务 API Key");
|
||||||
const apiKey = await this.ctx.secrets.resolve(s.apiKeySecretRef);
|
const apiKey = await this.ctx.secrets.resolve(s.apiKeySecretRef);
|
||||||
return createBraveClient({
|
return createBraveClient({
|
||||||
@@ -175,7 +175,7 @@ export class WebSearchService {
|
|||||||
async fetch(url: string, o: { scopeKey: string; signal?: AbortSignal }): Promise<WebFetchOutput> {
|
async fetch(url: string, o: { scopeKey: string; signal?: AbortSignal }): Promise<WebFetchOutput> {
|
||||||
const s = await this.settings();
|
const s = await this.settings();
|
||||||
if (s.provider === "none")
|
if (s.provider === "none")
|
||||||
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → Agent → 网页搜索里开启");
|
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启");
|
||||||
const want = canonicalUrl(url.trim());
|
const want = canonicalUrl(url.trim());
|
||||||
const allow = await pgCacheFor(this.ctx.db).get<{ urls: string[] }>(WEBSEARCH_ALLOW_REGION, o.scopeKey);
|
const allow = await pgCacheFor(this.ctx.db).get<{ urls: string[] }>(WEBSEARCH_ALLOW_REGION, o.scopeKey);
|
||||||
if (!want || !allow?.urls.includes(want))
|
if (!want || !allow?.urls.includes(want))
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
|
|||||||
decisionId,
|
decisionId,
|
||||||
kind: "identity_unresolved",
|
kind: "identity_unresolved",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
title: "待识别",
|
title: "未识别",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const deadline = Date.now() + 20_000;
|
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 }> = {
|
const T: Record<"approve" | "reject", { slotId: string; releaseId: string }> = {
|
||||||
approve: { slotId: ZERO, releaseId: ZERO },
|
approve: { slotId: ZERO, releaseId: ZERO },
|
||||||
reject: { slotId: ZERO, releaseId: ZERO },
|
reject: { slotId: ZERO, releaseId: ZERO },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type Any = any;
|
|||||||
const hasPg = await pgAvailable();
|
const hasPg = await pgAvailable();
|
||||||
const HASH = "ab".repeat(20);
|
const HASH = "ab".repeat(20);
|
||||||
const SURVEY = "盘点订阅、待处理与下载";
|
const SURVEY = "盘点订阅、待处理与下载";
|
||||||
const LOOP = (at: string) => `巡检库存(${at}):逐个作品查看版本目标`;
|
const LOOP = (at: string) => `巡检库存(${at}):逐个作品查看收录版本`;
|
||||||
|
|
||||||
describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => {
|
describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => {
|
||||||
const scripts: FakeScript[] = [];
|
const scripts: FakeScript[] = [];
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
|
|||||||
const r4 = await newRun(null);
|
const r4 = await newRun(null);
|
||||||
const d4 = await drive(env, h.ctx.capabilities, r4, new AbortController().signal);
|
const d4 = await drive(env, h.ctx.capabilities, r4, new AbortController().signal);
|
||||||
expect(d4).toMatchObject({ status: "failed" });
|
expect(d4).toMatchObject({ status: "failed" });
|
||||||
expect(d4.message).toMatch(/未配置 Agent 上游/);
|
expect(d4.message).toMatch(/未配置模型服务/);
|
||||||
await setDefaultUpstream(h.ctx, fake.id);
|
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 });
|
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 e = await seedEntity(h.db, { kind: "movie", title: "规则片", year: 2024 });
|
||||||
const slot = await seedSlot(h.db, { entityId: e });
|
const slot = await seedSlot(h.db, { entityId: e });
|
||||||
const g = await h.api("POST", "/rules", { name: "片源", ruleString: "4K > 1080P", hardExcludes: ["REMUX"] });
|
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 });
|
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 defs = (await h.api("GET", "/rules/defaults")).body.data;
|
||||||
const dflt = defs.acquisition[0];
|
const dflt = defs.acquisition[0];
|
||||||
expect((await h.api("PUT", "/rules/defaults", { ...defs, acquisition: defs.upgrade })).status).toBe(400);
|
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" });
|
expect(a.body.data.items[0]).toMatchObject({ siteId: sites.mteam, siteReleaseId: "912234" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("库存叠加:已识别资源 → resolved + 订阅/版本目标缺口;未识别但标题命中唯一作品别名 → alias", async () => {
|
it("库存叠加:已识别资源 → resolved + 订阅/收录版本缺口;未识别但标题命中唯一作品别名 → alias", async () => {
|
||||||
const entity = (
|
const entity = (
|
||||||
await db
|
await db
|
||||||
.insertInto("media_entity")
|
.insertInto("media_entity")
|
||||||
@@ -346,7 +346,7 @@ describe.skipIf(!up)("全局搜索", () => {
|
|||||||
const mt = items.find((i) => i.siteReleaseId === "990001");
|
const mt = items.find((i) => i.siteReleaseId === "990001");
|
||||||
expect(mt).toMatchObject({ mediaEntityId: entity, entityMatch: "alias", inventory: { state: "gap" } });
|
expect(mt).toMatchObject({ mediaEntityId: entity, entityMatch: "alias", inventory: { state: "gap" } });
|
||||||
|
|
||||||
// 本地作品组:别名精确命中 + 版本目标状态摘要
|
// 本地作品组:别名精确命中 + 收录版本状态摘要
|
||||||
const all = await req("/search?q=沙丘2&scope=media");
|
const all = await req("/search?q=沙丘2&scope=media");
|
||||||
const local = all.body.data.media.local.items;
|
const local = all.body.data.media.local.items;
|
||||||
expect(local[0]).toMatchObject({
|
expect(local[0]).toMatchObject({
|
||||||
@@ -512,7 +512,7 @@ describe.skipIf(!up)("全局搜索", () => {
|
|||||||
expect(unresolved.status).toBe(412);
|
expect(unresolved.status).toBe(412);
|
||||||
expect(unresolved.body.error.code).toBe("precondition_failed");
|
expect(unresolved.body.error.code).toBe("precondition_failed");
|
||||||
|
|
||||||
// 已识别但作品没有版本目标 → submitted=false + no_slot
|
// 已识别但作品没有收录版本 → submitted=false + no_slot
|
||||||
const other = (
|
const other = (
|
||||||
await db
|
await db
|
||||||
.insertInto("media_entity")
|
.insertInto("media_entity")
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
|||||||
sites.c = await addSite("hdhome", "家园", null, true);
|
sites.c = await addSite("hdhome", "家园", null, true);
|
||||||
await db.updateTable("site").set({ enabled: false }).where("id", "=", sites.c).execute();
|
await db.updateTable("site").set({ enabled: false }).where("id", "=", sites.c).execute();
|
||||||
|
|
||||||
// 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的版本目标
|
// 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的收录版本
|
||||||
dune = (
|
dune = (
|
||||||
await db
|
await db
|
||||||
.insertInto("media_entity")
|
.insertInto("media_entity")
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
|
|||||||
expect(ds).toHaveLength(1);
|
expect(ds).toHaveLength(1);
|
||||||
const d = ds[0]!;
|
const d = ds[0]!;
|
||||||
expect(d.severity).toBe("warning");
|
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 }[] };
|
const ev = d.evidence as { count: number; releases: { sourceReleaseId: string }[] };
|
||||||
expect(ev.count).toBe(3);
|
expect(ev.count).toBe(3);
|
||||||
expect(ev.releases.map((x) => x.sourceReleaseId).sort()).toEqual([...ids].sort());
|
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.status).toBe("open");
|
||||||
expect((after.evidence as { count: number }).count).toBe(1);
|
expect((after.evidence as { count: number }).count).toBe(1);
|
||||||
expect(after.subject_id).toBe(pool[1]!.source_release_id);
|
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
|
const last = await h.db
|
||||||
.selectFrom("identity_candidate")
|
.selectFrom("identity_candidate")
|
||||||
@@ -336,7 +336,7 @@ describe.skipIf(!pg)("迁移 0047:存量 identity_unresolved 清理", () => {
|
|||||||
.values({
|
.values({
|
||||||
kind: "identity_unresolved",
|
kind: "identity_unresolved",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
title: `待识别:${title}`,
|
title: `未识别:${title}`,
|
||||||
subject_type: "source_release",
|
subject_type: "source_release",
|
||||||
subject_id: r.id,
|
subject_id: r.id,
|
||||||
dedupe_key: `identity:${r.id}`,
|
dedupe_key: `identity:${r.id}`,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
presetConditions,
|
presetConditions,
|
||||||
} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
|
} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
|
||||||
|
|
||||||
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 版本目标、
|
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 收录版本、
|
||||||
// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。
|
// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。
|
||||||
|
|
||||||
const hasPg = await pgAvailable();
|
const hasPg = await pgAvailable();
|
||||||
@@ -182,7 +182,7 @@ describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
|
|||||||
expect(again[0]!.hard).toEqual({ exclude: ["CAM"] });
|
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 e = await seedEntity(db(), { kind: "movie", title: "Filter Film", year: 2024 });
|
||||||
const c = await h.api("POST", "/subscriptions", {
|
const c = await h.api("POST", "/subscriptions", {
|
||||||
mediaEntityId: e,
|
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 ok = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP");
|
||||||
const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok });
|
const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok });
|
||||||
expect(r3.plans, JSON.stringify(r3)).toHaveLength(1);
|
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_plan").set({ status: "cancelled" }).where("id", "=", r3.plans[0]!).execute();
|
||||||
await db()
|
await db()
|
||||||
.updateTable("acquisition_slot")
|
.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);
|
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 e = await seedEntity(db(), { kind: "series", title: "预设剧", year: 2025 });
|
||||||
const bad = await h.api("POST", "/subscriptions", {
|
const bad = await h.api("POST", "/subscriptions", {
|
||||||
mediaEntityId: e,
|
mediaEntityId: e,
|
||||||
@@ -461,7 +461,7 @@ describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
|
|||||||
});
|
});
|
||||||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||||||
const subId = c.body.data.id as string;
|
const subId = c.body.data.id as string;
|
||||||
// E01 已入库(单版本目标:作品下资产都算)
|
// E01 已入库(单收录版本:作品下资产都算)
|
||||||
const a = await db()
|
const a = await db()
|
||||||
.insertInto("media_asset")
|
.insertInto("media_asset")
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
|
|||||||
expect((await h.api("GET", "/subscriptions/00000000-0000-0000-0000-000000000000/coverage")).status).toBe(404);
|
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 });
|
const e = await seedEntity(db(), { kind: "series", title: "范围剧", year: 2025 });
|
||||||
await season(e, 1, null);
|
await season(e, 1, null);
|
||||||
const e3 = await episode(e, 1, 3);
|
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 e4 = await seedEntity(db(), { kind: "series", title: `${tag}丁`, year: 2022 });
|
||||||
const s4 = await sub(e4, { status: "paused" });
|
const s4 = await sub(e4, { status: "paused" });
|
||||||
// 已完成(电影:唯一版本目标已入库)
|
// 已完成(电影:唯一收录版本已入库)
|
||||||
const e5 = await seedEntity(db(), { kind: "movie", title: `${tag}戊`, year: 2021 });
|
const e5 = await seedEntity(db(), { kind: "movie", title: `${tag}戊`, year: 2021 });
|
||||||
const a5 = await asset(e5);
|
const a5 = await asset(e5);
|
||||||
const s5 = await sub(e5, { kind: "movie", mode: "once" });
|
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);
|
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 sid = c.body.data.id;
|
||||||
const sl = await slot(e, sid, "main");
|
const sl = await slot(e, sid, "main");
|
||||||
const u = await h.api("PATCH", `/subscriptions/${sid}`, {
|
const u = await h.api("PATCH", `/subscriptions/${sid}`, {
|
||||||
@@ -611,7 +611,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
|
|||||||
).toEqual({ enabled: false, mode: "full_pack" });
|
).toEqual({ enabled: false, mode: "full_pack" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("暂停 / 恢复 / 重新订阅 / 批量 / 版本目标排序", async () => {
|
it("暂停 / 恢复 / 重新订阅 / 批量 / 收录版本排序", async () => {
|
||||||
const e = await seedEntity(db(), { kind: "movie", title: "动作片", year: 2024 });
|
const e = await seedEntity(db(), { kind: "movie", title: "动作片", year: 2024 });
|
||||||
const s = await sub(e, { kind: "movie", mode: "once" });
|
const s = await sub(e, { kind: "movie", mode: "once" });
|
||||||
const a = await slot(e, s, "A", { position: 0 });
|
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(
|
(${done}, 1, 'run_started', ${old}), (${done}, 2, 'run_started', ${now}), (${live}, 1, 'run_started', ${old})`.execute(
|
||||||
h.db,
|
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);
|
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);
|
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("r1", "unmatched", 31);
|
||||||
await cand("r2", "unmatched", 29);
|
await cand("r2", "unmatched", 29);
|
||||||
await cand("r3", "ambiguous", 31);
|
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
|
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);
|
join source_release sr on sr.id = c.source_release_id order by sr.title`.execute(h.db);
|
||||||
expect(st.rows).toEqual([
|
expect(st.rows).toEqual([
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class MemStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验收 S7:生产构建里 ?mock=1 不得把演示数据当真实运行展示
|
// 验收 S7:生产构建里 ?mock=1 不得把演示数据当真实运行展示
|
||||||
describe("Agent 演示开关", () => {
|
describe("智能体演示开关", () => {
|
||||||
it("只在开发构建或后端 provider=fake 时允许", () => {
|
it("只在开发构建或后端 provider=fake 时允许", () => {
|
||||||
expect(agentMockAllowed({ dev: true })).toBe(true);
|
expect(agentMockAllowed({ dev: true })).toBe(true);
|
||||||
expect(agentMockAllowed({ dev: false, provider: "fake" })).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 = {
|
export const PAUSE_REASON_LABEL = {
|
||||||
user: "用户暂停",
|
user: "用户暂停",
|
||||||
threshold: "阈值暂停",
|
threshold: "下载盘满暂停",
|
||||||
policy: "策略暂停",
|
policy: "策略暂停",
|
||||||
agent: "Agent 暂停",
|
agent: "智能体暂停",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
|
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 {
|
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
|
||||||
if (f.possibleGap) {
|
if (f.possibleGap) {
|
||||||
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
|
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;
|
if (!f.enabled || !f.lastSuccessAt) return null;
|
||||||
const gapMs = now - new Date(f.lastSuccessAt).getTime();
|
const gapMs = now - new Date(f.lastSuccessAt).getTime();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function inventoryFlags(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) {
|
if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) {
|
||||||
flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" });
|
flags.push({ key: "tracked", label: "已识别,无收录版本", tone: "neutral" });
|
||||||
}
|
}
|
||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
|
|||||||
cancelled: "neutral",
|
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} 读取逐文件预览 */
|
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
|
||||||
export function useArtifact(id: string | null | undefined) {
|
export function useArtifact(id: string | null | undefined) {
|
||||||
|
|||||||
@@ -99,14 +99,14 @@ export function useRuleSuppressions() {
|
|||||||
export type RuleRefs = {
|
export type RuleRefs = {
|
||||||
/** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */
|
/** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */
|
||||||
subscriptions: Map<string, { id: string; title: string }[]>;
|
subscriptions: Map<string, { id: string; title: string }[]>;
|
||||||
/** 规则组 id → 引用它的版本目标 */
|
/** 规则组 id → 引用它的收录版本 */
|
||||||
slots: Map<string, { id: string; name: string }[]>;
|
slots: Map<string, { id: string; name: string }[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SubPage = cursorPage(Subscription);
|
const SubPage = cursorPage(Subscription);
|
||||||
const SlotPage = cursorPage(AcquisitionSlot);
|
const SlotPage = cursorPage(AcquisitionSlot);
|
||||||
|
|
||||||
/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */
|
/** 引用关系:列表接口不带引用计数,前端从订阅与收录版本反查(各取前 200 条) */
|
||||||
export function useRuleRefs() {
|
export function useRuleRefs() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ruleKeys.refs,
|
queryKey: ruleKeys.refs,
|
||||||
@@ -150,7 +150,7 @@ function useInvalidate() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return () => {
|
return () => {
|
||||||
void qc.invalidateQueries({ queryKey: ruleKeys.all });
|
void qc.invalidateQueries({ queryKey: ruleKeys.all });
|
||||||
// 旧偏好标签页与订阅/版本目标的规则组显示同源
|
// 旧偏好标签页与订阅/收录版本的规则组显示同源
|
||||||
void qc.invalidateQueries({ queryKey: ["preferences"] });
|
void qc.invalidateQueries({ queryKey: ["preferences"] });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ export function useUpdateRuleGroup() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */
|
/** 删除:标记删除并写抑制记录;被收录版本/订阅引用时 409 */
|
||||||
export function useDeleteRuleGroup() {
|
export function useDeleteRuleGroup() {
|
||||||
const done = useInvalidate();
|
const done = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function useSiteRequestStats(siteId: string | undefined) {
|
|||||||
export function accessSummary(s: SiteRequestStats): string {
|
export function accessSummary(s: SiteRequestStats): string {
|
||||||
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
|
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
|
||||||
const parts = [
|
const parts = [
|
||||||
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
|
`抓取每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
|
||||||
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
|
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
|
||||||
];
|
];
|
||||||
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
|
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")),
|
api.patch<unknown>(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
|
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
|
||||||
// best_version 写到版本目标的升级策略
|
// best_version 写到收录版本的升级策略
|
||||||
void qc.invalidateQueries({ queryKey: ["slots"] });
|
void qc.invalidateQueries({ queryKey: ["slots"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -246,7 +246,7 @@ export function useFillSlot() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 版本目标排序:第一个成为主版本 */
|
/** 收录版本排序:第一个成为主版本 */
|
||||||
export function useSetSlotOrder() {
|
export function useSetSlotOrder() {
|
||||||
const done = useInvalidateSubs();
|
const done = useInvalidateSubs();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
|
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";
|
import { apiParsed } from "./parse.ts";
|
||||||
|
|
||||||
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
||||||
@@ -23,7 +24,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
|
|||||||
const DecisionPage = cursorPage(Decision);
|
const DecisionPage = cursorPage(Decision);
|
||||||
|
|
||||||
const RESOLUTION_LABEL = {
|
const RESOLUTION_LABEL = {
|
||||||
unresolved: "待识别",
|
unresolved: "未识别",
|
||||||
resolved: "已识别",
|
resolved: "已识别",
|
||||||
ambiguous: "有歧义,待确认",
|
ambiguous: "有歧义,待确认",
|
||||||
unidentifiable: "无法识别",
|
unidentifiable: "无法识别",
|
||||||
@@ -173,7 +174,7 @@ export function buildTimeline(input: {
|
|||||||
stage: "plan",
|
stage: "plan",
|
||||||
at: plan.createdAt,
|
at: plan.createdAt,
|
||||||
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
||||||
detail: plan.statusReason,
|
detail: humanizeReason(plan.statusReason),
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
});
|
});
|
||||||
if (plan.updatedAt !== plan.createdAt)
|
if (plan.updatedAt !== plan.createdAt)
|
||||||
@@ -182,7 +183,7 @@ export function buildTimeline(input: {
|
|||||||
stage: "plan",
|
stage: "plan",
|
||||||
at: plan.updatedAt,
|
at: plan.updatedAt,
|
||||||
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
||||||
detail: plan.statusReason,
|
detail: humanizeReason(plan.statusReason),
|
||||||
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ export function buildTimeline(input: {
|
|||||||
key: "dl-pause",
|
key: "dl-pause",
|
||||||
stage: "download",
|
stage: "download",
|
||||||
at: task.updatedAt,
|
at: task.updatedAt,
|
||||||
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
|
title: task.pauseReason === "threshold" ? "下载盘超过暂停线,已暂停" : PAUSE_REASON_LABEL[task.pauseReason],
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
ongoing: task.status === "paused",
|
ongoing: task.status === "paused",
|
||||||
});
|
});
|
||||||
@@ -239,7 +240,15 @@ export function buildTimeline(input: {
|
|||||||
stage: "organization",
|
stage: "organization",
|
||||||
at: r.startedAt ?? r.createdAt,
|
at: r.startedAt ?? r.createdAt,
|
||||||
title: `整理 ${organizationActionLabel(r.action)}:${organizationLabel(r.status).label}`,
|
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],
|
tone: RECORD_TONE[r.status],
|
||||||
href: `/console/organize/${r.id}`,
|
href: `/console/organize/${r.id}`,
|
||||||
ongoing: r.status === "running" || r.status === "queued",
|
ongoing: r.status === "running" || r.status === "queued",
|
||||||
@@ -260,7 +269,7 @@ export function buildTimeline(input: {
|
|||||||
key: `agent-${d.id}`,
|
key: `agent-${d.id}`,
|
||||||
stage: "agent",
|
stage: "agent",
|
||||||
at: d.updatedAt,
|
at: d.updatedAt,
|
||||||
title: "Agent 已介入调查",
|
title: "智能体已介入调查",
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
href: `/console/agent/${d.agentRunId}`,
|
href: `/console/agent/${d.agentRunId}`,
|
||||||
});
|
});
|
||||||
@@ -275,7 +284,9 @@ export function buildTimeline(input: {
|
|||||||
at: t.startedAt ?? t.createdAt,
|
at: t.startedAt ?? t.createdAt,
|
||||||
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`,
|
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`,
|
||||||
detail:
|
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],
|
tone: TASK_STATUS_TONE[t.status],
|
||||||
href: `/console/tasks/${encodeURIComponent(t.id)}`,
|
href: `/console/tasks/${encodeURIComponent(t.id)}`,
|
||||||
ongoing: ACTIVE_TASK.includes(t.status),
|
ongoing: ACTIVE_TASK.includes(t.status),
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
useDecideApproval,
|
useDecideApproval,
|
||||||
useIsAdminSession,
|
useIsAdminSession,
|
||||||
} from "~/api/agent.ts";
|
} 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 { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
|
||||||
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
|
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
|
||||||
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.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 { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { humanizeReason } from "~/lib/reasons.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||||
import { ToolCallIo } from "./tool-call-card-io.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);
|
const m = capabilityMeta(id);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* 只标需要注意的:会改动数据的动作和中高风险;只读、scope、幂等进技术信息 */}
|
||||||
{m ? (
|
{m ? (
|
||||||
<>
|
<>
|
||||||
<StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge>
|
{m.kind !== "read" ? <StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge> : null}
|
||||||
<StatusBadge>scope · {m.scope}</StatusBadge>
|
{m.riskLevel !== "low" ? <RiskBadge level={m.riskLevel} /> : null}
|
||||||
<RiskBadge level={m.riskLevel} />
|
|
||||||
{m.idempotent ? <StatusBadge>幂等</StatusBadge> : null}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<StatusBadge>未登记能力</StatusBadge>
|
|
||||||
)}
|
|
||||||
{extra}
|
{extra}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -197,20 +199,21 @@ export function ToolCallCard({
|
|||||||
<ToolApproval
|
<ToolApproval
|
||||||
status="rejected"
|
status="rejected"
|
||||||
tool={call.capabilityId}
|
tool={call.capabilityId}
|
||||||
title={`服务端拒绝了「${meta?.title ?? call.capabilityId}」`}
|
title={`未执行:${meta?.title ?? "这个操作"}`}
|
||||||
badges={
|
badges={
|
||||||
<CapabilityBadges
|
<CapabilityBadges
|
||||||
id={call.capabilityId}
|
id={call.capabilityId}
|
||||||
extra={<StatusBadge tone="destructive">{authReason ? "授权校验未通过" : "白名单拦截"}</StatusBadge>}
|
extra={<StatusBadge tone="destructive">{authReason ? "需要你批准" : "本次没有开放"}</StatusBadge>}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
reason={authReason ?? call.reason ?? "未授权"}
|
reason={authReason ?? call.reason ?? "未授权"}
|
||||||
explanation={
|
explanation={
|
||||||
authReason
|
authReason
|
||||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
|
? "会改动数据的操作要你批准:在对应步骤的审批卡上批准,或在设置 → 智能体里给它持续授权。"
|
||||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
: "这次会话没有开放这个操作,或已用完调用次数。"
|
||||||
}
|
}
|
||||||
parameters={[
|
parameters={[
|
||||||
|
{ id: "cap", label: "能力", value: call.capabilityId },
|
||||||
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
||||||
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
|
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
|
||||||
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
|
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
|
||||||
@@ -222,9 +225,8 @@ export function ToolCallCard({
|
|||||||
return (
|
return (
|
||||||
<ToolResult
|
<ToolResult
|
||||||
status={status}
|
status={status}
|
||||||
tool={call.capabilityId}
|
tool={meta ? null : call.capabilityId}
|
||||||
title={meta?.title ?? call.capabilityId}
|
title={meta?.title ?? call.capabilityId}
|
||||||
meta={durationText(call.startedAt, call.endedAt) ?? undefined}
|
|
||||||
badges={
|
badges={
|
||||||
<CapabilityBadges
|
<CapabilityBadges
|
||||||
id={call.capabilityId}
|
id={call.capabilityId}
|
||||||
@@ -244,24 +246,39 @@ export function ToolCallCard({
|
|||||||
) : call.status === "running" ? (
|
) : call.status === "running" ? (
|
||||||
<p className="text-meta text-muted-foreground">等待结果…</p>
|
<p className="text-meta text-muted-foreground">等待结果…</p>
|
||||||
) : null}
|
) : null}
|
||||||
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
<TechInfo>
|
||||||
<dt className="text-muted-foreground">调用 ID</dt>
|
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
||||||
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
|
<dt className="text-muted-foreground">能力</dt>
|
||||||
<dt className="text-muted-foreground">幂等键</dt>
|
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
||||||
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
{call.capabilityId}
|
||||||
{audit?.idempotencyKey ??
|
{meta
|
||||||
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
|
? ` · ${KIND_LABEL[meta.kind] ?? meta.kind} · scope ${meta.scope}${meta.idempotent ? " · 幂等" : ""}`
|
||||||
</dd>
|
: ""}
|
||||||
{audit?.grantId || audit?.approvalId ? (
|
</dd>
|
||||||
<>
|
{durationText(call.startedAt, call.endedAt) ? (
|
||||||
<dt className="text-muted-foreground">授权依据</dt>
|
<>
|
||||||
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
<dt className="text-muted-foreground">耗时</dt>
|
||||||
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
|
<dd className="min-w-0 text-foreground/80">{durationText(call.startedAt, call.endedAt)}</dd>
|
||||||
</dd>
|
</>
|
||||||
</>
|
) : null}
|
||||||
) : null}
|
<dt className="text-muted-foreground">调用 ID</dt>
|
||||||
</dl>
|
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
|
||||||
<ToolCallIo call={call} />
|
<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>
|
</ToolResult>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -652,7 +669,7 @@ export function AgentConversation({
|
|||||||
at: m.at,
|
at: m.at,
|
||||||
role: "user",
|
role: "user",
|
||||||
foldable: false,
|
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));
|
entries.sort((a, b) => a.at.localeCompare(b.at));
|
||||||
const blocks = groupConversation(entries);
|
const blocks = groupConversation(entries);
|
||||||
@@ -678,13 +695,13 @@ export function AgentConversation({
|
|||||||
b.kind === "single" ? (
|
b.kind === "single" ? (
|
||||||
<div key={b.entry.key}>{b.entry.node}</div>
|
<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>
|
<MessageAvatar>
|
||||||
<Bot />
|
<Bot />
|
||||||
</MessageAvatar>
|
</MessageAvatar>
|
||||||
<MessageContent className="gap-2">
|
<MessageContent className="gap-2">
|
||||||
<MessageHeader>
|
<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>
|
<time dateTime={b.at}>{clockText(b.at)}</time>
|
||||||
</MessageHeader>
|
</MessageHeader>
|
||||||
{b.parts.map((p) =>
|
{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()
|
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
|
||||||
: undefined);
|
: undefined);
|
||||||
return (
|
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")}>
|
<div className="grid gap-2">
|
||||||
<Meter
|
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
|
||||||
label="工具调用"
|
<Meter
|
||||||
used={toolCalls}
|
label="费用(估算)"
|
||||||
max={run.budget.maxToolCalls}
|
used={u.costUsd}
|
||||||
format={(n) => String(n)}
|
max={run.budget.maxCostUsd}
|
||||||
exceeded={exceeded === "tool_calls"}
|
format={(n) => formatUsd(n)}
|
||||||
/>
|
exceeded={exceeded === "cost"}
|
||||||
<Meter
|
/>
|
||||||
label="token"
|
<Meter
|
||||||
used={u.totalTokens}
|
label="时长"
|
||||||
max={run.budget.maxTokens}
|
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
||||||
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
|
max={run.budget.maxDurationSec}
|
||||||
exceeded={exceeded === "tokens"}
|
format={(n) => `${n} 秒`}
|
||||||
/>
|
exceeded={exceeded === "duration"}
|
||||||
<Meter
|
/>
|
||||||
label="时长"
|
</div>
|
||||||
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
{/* 调用次数与 token 超限时直接显示,否则收进技术信息 */}
|
||||||
max={run.budget.maxDurationSec}
|
{exceeded === "tool_calls" || exceeded === "tokens" ? (
|
||||||
format={(n) => `${n}s`}
|
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
|
||||||
exceeded={exceeded === "duration"}
|
<Meter
|
||||||
/>
|
label="工具调用"
|
||||||
<Meter
|
used={toolCalls}
|
||||||
label="费用(估算)"
|
max={run.budget.maxToolCalls}
|
||||||
used={u.costUsd}
|
format={(n) => String(n)}
|
||||||
max={run.budget.maxCostUsd}
|
exceeded={exceeded === "tool_calls"}
|
||||||
format={(n) => `$${n.toFixed(3)}`}
|
/>
|
||||||
exceeded={exceeded === "cost"}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -947,17 +989,25 @@ export function FinalReport({
|
|||||||
...report.downloads.map((d) => ({
|
...report.downloads.map((d) => ({
|
||||||
id: `d${d.downloadTaskId}`,
|
id: `d${d.downloadTaskId}`,
|
||||||
icon: <Download />,
|
icon: <Download />,
|
||||||
title: d.name ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`,
|
title: d.name ?? "下载任务",
|
||||||
domain: "下载任务",
|
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}`,
|
to: `/console/downloads/${d.downloadTaskId}`,
|
||||||
})),
|
})),
|
||||||
...report.organization.map((o) => ({
|
...report.organization.map((o) => ({
|
||||||
id: `o${o.recordId}`,
|
id: `o${o.recordId}`,
|
||||||
icon: <FolderInput />,
|
icon: <FolderInput />,
|
||||||
title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`,
|
title: o.targetPath ?? "整理记录",
|
||||||
domain: "整理记录",
|
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}`,
|
to: `/console/organize/${o.recordId}`,
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
@@ -976,7 +1026,6 @@ export function FinalReport({
|
|||||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||||
</StreamingResponse>
|
</StreamingResponse>
|
||||||
</LongText>
|
</LongText>
|
||||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
||||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||||
@@ -985,19 +1034,19 @@ export function FinalReport({
|
|||||||
) : (
|
) : (
|
||||||
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
|
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||||
)}
|
)}
|
||||||
系统核实的结果
|
实际结果
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
{state || count("failed") || denied ? (
|
||||||
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
|
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
||||||
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
|
{count("failed") ? <StatusBadge tone="destructive">{count("failed")} 步失败</StatusBadge> : null}
|
||||||
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
|
{denied ? <StatusBadge tone="destructive">{denied} 步未执行</StatusBadge> : null}
|
||||||
{denied ? <StatusBadge tone="destructive">被拒绝 {denied}</StatusBadge> : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
{report ? (
|
{report ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
|
<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],
|
["已提交", report.submitted],
|
||||||
@@ -1005,20 +1054,26 @@ export function FinalReport({
|
|||||||
["进行中", report.inProgress],
|
["进行中", report.inProgress],
|
||||||
["失败", report.failed],
|
["失败", report.failed],
|
||||||
] as const
|
] as const
|
||||||
).map(([k, v]) => (
|
)
|
||||||
<div key={k} className="flex flex-col-reverse rounded-lg border border-border/60 px-1 py-1.5">
|
.filter(([, v]) => v > 0)
|
||||||
<dt className="text-meta text-muted-foreground">{k}</dt>
|
.map(([k, v]) => (
|
||||||
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
|
<div
|
||||||
</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>
|
</dl>
|
||||||
{report.plans.length ? (
|
{report.plans.length ? (
|
||||||
<ul className="mt-2 space-y-1 text-meta">
|
<ul className="mt-2 space-y-1 text-meta">
|
||||||
{report.plans.map((p) => (
|
{report.plans.map((p) => (
|
||||||
<li key={p.planId} className="flex min-w-0 items-center gap-2">
|
<li key={p.planId} className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-muted-foreground">下载计划</span>
|
<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)}>
|
||||||
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>{p.status}</StatusBadge>
|
{PLAN_STATUS_LABEL[p.status as keyof typeof PLAN_STATUS_LABEL] ?? p.status}
|
||||||
|
</StatusBadge>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1037,18 +1092,17 @@ export function FinalReport({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-meta text-muted-foreground">
|
|
||||||
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
|
|
||||||
)。「已提交」指已进入下载器,整理成功才算入库。
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
|
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
|
||||||
)}
|
)}
|
||||||
{run.error ? (
|
{run.error ? (
|
||||||
<p className="mt-2 text-caption break-words text-destructive">
|
<>
|
||||||
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
|
<p className="mt-2 text-caption break-words text-destructive">
|
||||||
</p>
|
{humanizeReason(String((run.error as { message?: unknown }).message ?? "")) || "会话出错"}
|
||||||
|
</p>
|
||||||
|
<TechInfo className="mt-1" items={[{ label: "原始错误", value: JSON.stringify(run.error), wide: true }]} />
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import {
|
|||||||
setAgentDrawerOpen,
|
setAgentDrawerOpen,
|
||||||
setAgentDrawerRun,
|
setAgentDrawerRun,
|
||||||
setAgentDrawerTab,
|
setAgentDrawerTab,
|
||||||
|
shortGoalTitle,
|
||||||
showAgentRunInDrawer,
|
showAgentRunInDrawer,
|
||||||
splitContextGoal,
|
|
||||||
useAgentDrawer,
|
useAgentDrawer,
|
||||||
} from "./agent-store.ts";
|
} from "./agent-store.ts";
|
||||||
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
||||||
@@ -72,7 +72,7 @@ function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
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"
|
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
|
||||||
>
|
>
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
@@ -108,7 +108,7 @@ function HistoryRunView({ runId }: { runId: string }) {
|
|||||||
返回列表
|
返回列表
|
||||||
</Button>
|
</Button>
|
||||||
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
|
<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>
|
</p>
|
||||||
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
|
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
|
||||||
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
|
<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">
|
<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">
|
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
Agent
|
智能体
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className={cn("break-words", history && "sr-only")}>
|
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}>
|
||||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
|
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`}
|
||||||
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
|
|
||||||
({agentShortcutLabel()} 开关,Esc 关闭)
|
|
||||||
</span>
|
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
{context && !history ? <ContextChips context={context} /> : null}
|
{context && !history ? <ContextChips context={context} /> : null}
|
||||||
<DrawerTabs tab={tab} />
|
<DrawerTabs tab={tab} />
|
||||||
@@ -281,16 +278,16 @@ function AgentMiniDock({ runId }: { runId: string }) {
|
|||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<section
|
<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"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
||||||
{status ? <RunStatusBadge status={status} /> : null}
|
{status ? <RunStatusBadge status={status} /> : null}
|
||||||
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
|
<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>
|
</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 />
|
<Maximize2 />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -325,8 +322,8 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="打开 Agent"
|
aria-label="打开智能体"
|
||||||
title={`Agent(${agentShortcutLabel()})`}
|
title={`智能体(${agentShortcutLabel()})`}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
|
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
toolSummary,
|
toolSummary,
|
||||||
} from "./agent-conversation.tsx";
|
} from "./agent-conversation.tsx";
|
||||||
import { matchesHistory, rangeStart } from "./agent-session-list.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";
|
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
const call = (p: Partial<ToolCallView>): ToolCallView => ({
|
const call = (p: Partial<ToolCallView>): ToolCallView => ({
|
||||||
@@ -185,3 +185,20 @@ describe("会话历史筛选", () => {
|
|||||||
expect(rangeStart("", now)).toBeNull();
|
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;
|
const optionId = picked === NONE ? null : picked;
|
||||||
if (!picked) return;
|
if (!picked) return;
|
||||||
if (optionId === null && !reason.trim()) {
|
if (optionId === null && !reason.trim()) {
|
||||||
toast.error("写一句不要的原因,Agent 按它调整");
|
toast.error("写一句不要的原因,智能体按它调整");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
choose.mutate(
|
choose.mutate(
|
||||||
@@ -115,7 +115,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSent({ optionId, reason: reason.trim() });
|
setSent({ optionId, reason: reason.trim() });
|
||||||
toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案");
|
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案");
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
},
|
},
|
||||||
@@ -129,8 +129,8 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
|
|||||||
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
|
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
|
||||||
: sent
|
: sent
|
||||||
? sent.optionId
|
? sent.optionId
|
||||||
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待 Agent 继续`
|
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
|
||||||
: `都不要:${sent.reason},等待 Agent 调整`
|
: `都不要:${sent.reason},等待智能体调整`
|
||||||
: !waiting
|
: !waiting
|
||||||
? "会话已不在等待这个选择"
|
? "会话已不在等待这个选择"
|
||||||
: null;
|
: null;
|
||||||
@@ -232,7 +232,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
|
|||||||
</Button>
|
</Button>
|
||||||
<span className="text-meta text-muted-foreground">
|
<span className="text-meta text-muted-foreground">
|
||||||
{formatDateTime(view.expiresAt)} 前没选,
|
{formatDateTime(view.expiresAt)} 前没选,
|
||||||
{view.timeoutAction === "recommended" ? "自动选推荐项" : "Agent 停止"}。
|
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -285,7 +285,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
|
|||||||
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
|
...(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)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -332,7 +332,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
|
|||||||
value={reason}
|
value={reason}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
aria-label="拒绝理由"
|
aria-label="拒绝理由"
|
||||||
placeholder="拒绝时写理由或建议(可选),Agent 按它调整"
|
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { PromptInput } from "~/components/agentui/prompt-input.tsx";
|
|||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { formatUsd } from "~/components/schedules/labels.ts";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Label } from "~/components/ui/label.tsx";
|
import { Label } from "~/components/ui/label.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.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">
|
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
|
||||||
{proposeFirst
|
{proposeFirst
|
||||||
? "Agent 先调查并给出方案,你选定后才执行。"
|
? "智能体先调查并给出方案,你选定后才执行。"
|
||||||
: "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
|
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}
|
||||||
Enter 发送,Shift+Enter 换行。
|
|
||||||
</p>
|
</p>
|
||||||
{suggestions?.length ? (
|
{suggestions?.length ? (
|
||||||
<div className="pt-2">
|
<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 }) {
|
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +159,6 @@ function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunSta
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-meta break-words text-muted-foreground">
|
<p className="text-meta break-words text-muted-foreground">
|
||||||
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
|
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
|
||||||
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -194,8 +189,7 @@ function RunOverviewBar({
|
|||||||
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
|
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
|
||||||
const failed = steps.some((s) => s.status === "failed");
|
const failed = steps.some((s) => s.status === "failed");
|
||||||
const u = { ...r.usage, ...(live.usage ?? {}) };
|
const u = { ...r.usage, ...(live.usage ?? {}) };
|
||||||
const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
const usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
|
||||||
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
|
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
|
||||||
<button
|
<button
|
||||||
@@ -240,7 +234,7 @@ function RunOverviewBar({
|
|||||||
<RunPlan plan={live.plan} active={active} fill />
|
<RunPlan plan={live.plan} active={active} fill />
|
||||||
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
||||||
<div>
|
<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} />
|
<BudgetBar run={r} live={live} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +394,7 @@ export function AgentRunPanel({
|
|||||||
viewportClassName="pr-1"
|
viewportClassName="pr-1"
|
||||||
contentClassName={column}
|
contentClassName={column}
|
||||||
busy={streaming}
|
busy={streaming}
|
||||||
label="Agent 会话记录"
|
label="智能体会话记录"
|
||||||
>
|
>
|
||||||
<AgentConversation
|
<AgentConversation
|
||||||
run={r}
|
run={r}
|
||||||
@@ -462,8 +456,8 @@ export function AgentRunPanel({
|
|||||||
<RunMeta run={r} status={shownStatus} />
|
<RunMeta run={r} status={shownStatus} />
|
||||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
|
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
|
||||||
<SubagentTree snapshot={run.data} className="p-2.5" />
|
<SubagentTree snapshot={run.data} className="p-2.5" />
|
||||||
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
<section aria-label="用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
||||||
<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} stacked />
|
<BudgetBar run={r} live={live} stacked />
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
import { splitContextGoal } from "./agent-store.ts";
|
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||||
|
|
||||||
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
|
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
|
||||||
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
|
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
|
||||||
@@ -57,16 +58,9 @@ export function matchesHistory(
|
|||||||
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
|
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列表副行:只写费用(token 与调用次数在会话的技术信息里) */
|
||||||
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
|
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
|
||||||
const parts = [`${r.toolCallCount} 次调用`];
|
return r.usage.costUsd ? `费用约 ${formatUsd(r.usage.costUsd)}` : "";
|
||||||
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(" · ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL = "__all__";
|
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">
|
<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}>
|
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
|
||||||
{goal.text}
|
{shortGoalTitle(r.goal)}
|
||||||
</p>
|
</p>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,10 +124,11 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
|
|||||||
{relTime(r.createdAt)}
|
{relTime(r.createdAt)}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
|
{goal.context || usageText(r) ? (
|
||||||
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
|
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
|
||||||
{usageText(r)}
|
{[goal.context?.split(" · ")[0], usageText(r)].filter(Boolean).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,7 +191,7 @@ export function AgentSessionList({
|
|||||||
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
|
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mb-2 space-y-1.5">
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
|
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
|
||||||
@@ -192,3 +193,29 @@ export function splitContextGoal(goal: string): { context: string | null; text:
|
|||||||
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
|
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
|
||||||
return m ? { context: m[1] ?? null, text: goal.slice(m[0].length) } : { context: null, text: 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 { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
import { splitContextGoal } from "./agent-store.ts";
|
import { shortGoalTitle } from "./agent-store.ts";
|
||||||
|
|
||||||
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
|
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
|
||||||
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
|
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 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 />
|
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||||
<RunStatusBadge status={node.status} />
|
<RunStatusBadge status={node.status} />
|
||||||
<span className="text-meta text-muted-foreground tabular-nums">
|
<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" />
|
<CornerLeftUp aria-hidden className="size-3.5" />
|
||||||
父会话
|
父会话
|
||||||
</p>
|
</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">
|
<p className="text-meta text-muted-foreground">
|
||||||
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
||||||
{budgetText(run.budget as Record<string, number | undefined>)}
|
{budgetText(run.budget as Record<string, number | undefined>)}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
|
|||||||
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
|
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
|
||||||
};
|
};
|
||||||
const steps: AgentPlanStep[] = [
|
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: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
|
||||||
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
|
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
|
||||||
{ id: "s4", 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(0, { type: "run_started", goal, resumed: false });
|
||||||
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
|
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
|
||||||
say("先确认本地库存与版本目标,再去站点找候选。");
|
say("先确认本地库存与收录版本,再去站点找候选。");
|
||||||
push(400, {
|
push(400, {
|
||||||
type: "tool_call_start",
|
type: "tool_call_start",
|
||||||
toolCallId: "t1",
|
toolCallId: "t1",
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ const RULES: Rule[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/identity/,
|
match: /^\/console\/identity/,
|
||||||
goals: ["识别待识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
|
goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/subscriptions/,
|
match: /^\/console\/subscriptions/,
|
||||||
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"],
|
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/sources/,
|
match: /^\/console\/sources/,
|
||||||
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 Feed 的抓取状态", "最近一天各来源抓到了什么"],
|
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/engine/,
|
match: /^\/console\/engine/,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface MessageProps extends ComponentPropsWithRef<"article"> {
|
|||||||
animateIn?: boolean;
|
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) {
|
export function Message({ from, animateIn = false, className, children, ...props }: MessageProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ describe("原站视图坐标换算", () => {
|
|||||||
|
|
||||||
describe("当前控制文案", () => {
|
describe("当前控制文案", () => {
|
||||||
const now = Date.parse("2026-09-29T10:00:00Z");
|
const now = Date.parse("2026-09-29T10:00:00Z");
|
||||||
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「Agent」", () => {
|
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「智能体」", () => {
|
||||||
expect(controllerLabel({ controller: "automation", lastAutomationAt: null }, now)).toBe("自动化");
|
expect(controllerLabel({ controller: "automation", lastAutomationAt: null }, now)).toBe("自动化");
|
||||||
expect(controllerLabel({ controller: "automation" }, 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: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);
|
expect(agentActive({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe(true);
|
||||||
});
|
});
|
||||||
it("人工接管 / 等待处理不看自动化时间", () => {
|
it("人工接管 / 等待处理不看自动化时间", () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function controllerLabel(
|
|||||||
s: { controller: BrowserController; lastAutomationAt?: string | null },
|
s: { controller: BrowserController; lastAutomationAt?: string | null },
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
): string {
|
): string {
|
||||||
return agentActive(s, now) ? "Agent" : CONTROLLER_LABEL[s.controller];
|
return agentActive(s, now) ? "智能体" : CONTROLLER_LABEL[s.controller];
|
||||||
}
|
}
|
||||||
|
|
||||||
const WAITING_TEXT: Record<string, string> = {
|
const WAITING_TEXT: Record<string, string> = {
|
||||||
@@ -51,7 +51,7 @@ const WAITING_TEXT: Record<string, string> = {
|
|||||||
challenge_unsolved: "自动处理页面挑战未成功",
|
challenge_unsolved: "自动处理页面挑战未成功",
|
||||||
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
||||||
lease_expired: "接管超时未交还",
|
lease_expired: "接管超时未交还",
|
||||||
agent_request: "Agent 请求人工处理",
|
agent_request: "智能体请求人工处理",
|
||||||
human_released_waiting: "交还时选择继续等待",
|
human_released_waiting: "交还时选择继续等待",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function DesktopView({
|
|||||||
control.release.mutate(s.id, {
|
control.release.mutate(s.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refreshRole();
|
refreshRole();
|
||||||
toast.success("已交还,Agent 会重新读取页面");
|
toast.success("已交还,智能体会重新读取页面");
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
})
|
})
|
||||||
@@ -276,8 +276,8 @@ function DesktopView({
|
|||||||
{mine
|
{mine
|
||||||
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
|
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
|
||||||
: agentActive(s)
|
: agentActive(s)
|
||||||
? "只读观看,Agent 正在操作。需要自己点或输入时先接管,Agent 会暂停。"
|
? "只读观看,智能体正在操作。需要自己点或输入时先接管,智能体会暂停。"
|
||||||
: "只读观看,目前没有 Agent 在操作。需要自己点或输入时先接管。"}
|
: "只读观看,目前没有智能体在操作。需要自己点或输入时先接管。"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
|
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
control.release.mutate(s.id, {
|
control.release.mutate(s.id, {
|
||||||
onSuccess: () => toast.success("已交还,Agent 会重新读取页面"),
|
onSuccess: () => toast.success("已交还,智能体会重新读取页面"),
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AGENT_LAUNCHER_ATTR } from "~/components/agent/agent-drawer.tsx";
|
|||||||
import {
|
import {
|
||||||
agentShortcutLabel,
|
agentShortcutLabel,
|
||||||
isAgentShortcut,
|
isAgentShortcut,
|
||||||
splitContextGoal,
|
shortGoalTitle,
|
||||||
toggleAgentDrawer,
|
toggleAgentDrawer,
|
||||||
useAgentDrawer,
|
useAgentDrawer,
|
||||||
} from "~/components/agent/agent-store.ts";
|
} from "~/components/agent/agent-store.ts";
|
||||||
@@ -24,7 +24,7 @@ export function useActiveAgentRuns() {
|
|||||||
const latest = active[0];
|
const latest = active[0];
|
||||||
return {
|
return {
|
||||||
count: active.length,
|
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]);
|
}, [runs.data]);
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ function useHideOnScroll(): boolean {
|
|||||||
/**
|
/**
|
||||||
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
|
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
|
||||||
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
|
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
|
||||||
* - 抽屉打开时变成"收起",停在抽屉左侧(窄屏抽屉占满全宽,用抽屉自带的关闭钮,FAB 隐藏)。
|
* - 只在没有侧栏的窄屏显示(xl 起侧栏顶部就是入口);抽屉打开时隐藏,用抽屉自带的关闭钮。
|
||||||
*/
|
*/
|
||||||
export function AgentLauncher() {
|
export function AgentLauncher() {
|
||||||
useAgentHotkey();
|
useAgentHotkey();
|
||||||
@@ -145,7 +145,7 @@ export function AgentLauncher() {
|
|||||||
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
|
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
|
||||||
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
|
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
|
||||||
|
|
||||||
const label = open ? "收起 Agent" : count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent";
|
const label = open ? "收起智能体" : count ? `打开智能体(${count} 个会话进行中)` : "打开智能体";
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,14 +154,15 @@ export function AgentLauncher() {
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-keyshortcuts="Control+J Meta+J"
|
aria-keyshortcuts="Control+J Meta+J"
|
||||||
aria-expanded={open}
|
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))` }}
|
style={{ bottom: `calc(${BASE_BOTTOM + lift}px + env(safe-area-inset-bottom))` }}
|
||||||
className={cn(
|
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",
|
"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",
|
"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",
|
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
|
||||||
"md:px-2 md:pr-5",
|
"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",
|
!open && scrolledAway && "translate-x-[calc(100%+2rem)] focus-visible:translate-x-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -171,7 +172,7 @@ export function AgentLauncher() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="hidden min-w-0 flex-col items-start text-left md:flex">
|
<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">
|
<span className="flex items-center gap-2 text-control leading-5 font-semibold">
|
||||||
{open ? "收起" : "Agent"}
|
{open ? "收起" : "智能体"}
|
||||||
{count && !open ? <PulseDot /> : null}
|
{count && !open ? <PulseDot /> : null}
|
||||||
{count && !open ? <CountBadge count={count} /> : 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">
|
<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>
|
</kbd>
|
||||||
</span>
|
</span>
|
||||||
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
|
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
|
||||||
{open ? "Esc 关闭" : (summary ?? "交代目标,边做边汇报")}
|
{open ? "Esc 关闭" : (summary ?? "")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>,
|
</button>,
|
||||||
@@ -196,7 +197,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
|
|||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
toggleAgentDrawer(pathname);
|
toggleAgentDrawer(pathname);
|
||||||
};
|
};
|
||||||
const title = `Agent(${shortcut})${summary ? `\n${summary}` : ""}`;
|
const title = `智能体(${shortcut})${summary ? `\n${summary}` : ""}`;
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -205,7 +206,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
|
|||||||
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
|
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
|
||||||
onClick={activate}
|
onClick={activate}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent"}
|
aria-label={count ? `打开智能体(${count} 个会话进行中)` : "打开智能体"}
|
||||||
aria-keyshortcuts="Control+J Meta+J"
|
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"
|
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>
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||||
Agent
|
智能体
|
||||||
{count ? <PulseDot /> : null}
|
{count ? <PulseDot /> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-meta text-muted-foreground">
|
{count ? <span className="truncate text-meta text-muted-foreground">{count} 个会话进行中</span> : null}
|
||||||
{count ? `${count} 个会话进行中` : "交代目标,边做边汇报"}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
{count ? (
|
{count ? (
|
||||||
<CountBadge count={count} />
|
<CountBadge count={count} />
|
||||||
@@ -250,8 +249,8 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
|
|||||||
export function AgentShortcutsHelp() {
|
export function AgentShortcutsHelp() {
|
||||||
const shortcut = agentShortcutLabel();
|
const shortcut = agentShortcutLabel();
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
[shortcut, "打开 / 收起 Agent,附带当前页面"],
|
[shortcut, "打开 / 收起智能体,附带当前页面"],
|
||||||
["Esc", "关闭 Agent"],
|
["Esc", "关闭智能体"],
|
||||||
["Enter", "发送目标;Shift+Enter 换行"],
|
["Enter", "发送目标;Shift+Enter 换行"],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./dat
|
|||||||
|
|
||||||
type Row = { id: string; status: string; name: string };
|
type Row = { id: string; status: string; name: string };
|
||||||
const rows: Row[] = [
|
const rows: Row[] = [
|
||||||
{ id: "1", status: "待识别", name: "a.mkv" },
|
{ id: "1", status: "未识别", name: "a.mkv" },
|
||||||
{ id: "2", status: "已登记", name: "b.mkv" },
|
{ id: "2", status: "已登记", name: "b.mkv" },
|
||||||
{ id: "3", status: "待识别", name: "c.mkv" },
|
{ id: "3", status: "未识别", name: "c.mkv" },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("DataTable groupBy:分组逻辑", () => {
|
describe("DataTable groupBy:分组逻辑", () => {
|
||||||
it("按首次出现顺序分组,组内保持原顺序", () => {
|
it("按首次出现顺序分组,组内保持原顺序", () => {
|
||||||
expect(groupRows(rows, (r) => r.status).map((g) => [g.key, g.rows.map((r) => r.id)])).toEqual([
|
expect(groupRows(rows, (r) => r.status).map((g) => [g.key, g.rows.map((r) => r.id)])).toEqual([
|
||||||
["待识别", ["1", "3"]],
|
["未识别", ["1", "3"]],
|
||||||
["已登记", ["2"]],
|
["已登记", ["2"]],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ describe("DataTable groupBy:分组逻辑", () => {
|
|||||||
(r) => r.status,
|
(r) => r.status,
|
||||||
(a, b) => a.localeCompare(b, "zh-CN"),
|
(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("折叠切换返回新集合", () => {
|
it("折叠切换返回新集合", () => {
|
||||||
const a = new Set<string>();
|
const a = new Set<string>();
|
||||||
@@ -72,8 +72,8 @@ describe("DataTable groupBy:渲染", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(html.match(/aria-expanded="true"/g)).toHaveLength(2);
|
expect(html.match(/aria-expanded="true"/g)).toHaveLength(2);
|
||||||
expect(html).toContain("待识别");
|
expect(html).toContain("未识别");
|
||||||
expect(html).toMatch(/待识别<\/span><span[^>]*>2<\/span>/);
|
expect(html).toMatch(/未识别<\/span><span[^>]*>2<\/span>/);
|
||||||
expect(html).toMatch(/已登记<\/span><span[^>]*>1<\/span>/);
|
expect(html).toMatch(/已登记<\/span><span[^>]*>1<\/span>/);
|
||||||
for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n);
|
for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ describe("控制台导航", () => {
|
|||||||
it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => {
|
it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => {
|
||||||
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]);
|
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]);
|
||||||
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
||||||
["首页", "任务", "计划", "Agent 会话"],
|
["首页", "任务", "计划", "智能体会话"],
|
||||||
["站点", "RSS 订阅"],
|
["站点", "RSS"],
|
||||||
["订阅", "规则"],
|
["订阅", "规则"],
|
||||||
["下载面板", "整理记录", "文件管理", "媒体概览"],
|
["下载面板", "整理记录", "文件管理", "媒体库"],
|
||||||
["设置", "审计", "运行状态"],
|
["设置", "审计", "运行状态"],
|
||||||
]);
|
]);
|
||||||
// IA-3 逐项清单 14 项 + FE-6 计划页
|
// IA-3 逐项清单 14 项 + FE-6 计划页
|
||||||
@@ -44,7 +44,7 @@ describe("控制台导航", () => {
|
|||||||
it("按最长前缀匹配当前项", () => {
|
it("按最长前缀匹配当前项", () => {
|
||||||
expect(findNavItem("/console")?.label).toBe("首页");
|
expect(findNavItem("/console")?.label).toBe("首页");
|
||||||
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/tasks/r1")?.label).toBe("任务");
|
||||||
expect(findNavItem("/console/schedules")?.label).toBe("计划");
|
expect(findNavItem("/console/schedules")?.label).toBe("计划");
|
||||||
expect(findNavItem("/console/downloads")?.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