diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index a3a997aa..0c8886cf 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -14,6 +14,7 @@ import { previewPolicy, publishRevision } from "./routes/rules.ts"; import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts"; import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts"; import { type FrozenPlan, planImport } from "./services/organize.ts"; +import { assignOrganizeIdentity, OrganizeDecisionError } from "./services/takeover.ts"; import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts"; // 本模块领域的能力处理器(规格一律取 contracts): @@ -591,6 +592,28 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => }; }); + // 整理时没识别出作品:智能体查到作品后指定,随即重新整理(授权见 agent/authorize.ts:只放行本事项的识别会话或用户对话) + registry.register(capabilitySpec("organize.assign_identity"), async (input, ctx) => { + if (!input.mediaEntityId && !input.tmdb) + throw new CapabilityError("invalid_input", "需要 mediaEntityId 或 tmdb(类型 + ID)"); + try { + return await assignOrganizeIdentity( + deps(), + input.decisionId, + input.mediaEntityId ? { mediaEntityId: input.mediaEntityId } : { tmdb: input.tmdb }, + { type: ctx.source === "web" ? "user" : "agent", id: ctx.agentRunId ?? ctx.actorId }, + input.reasons.join(";"), + ); + } catch (e) { + if (e instanceof OrganizeDecisionError) + throw new CapabilityError( + e.code === "bad_request" ? "invalid_input" : e.code === "not_found" ? "not_found" : "conflict", + e.message, + ); + throw e; + } + }); + registry.register(capabilitySpec("organize.execute"), async (input, ctx) => { const d = deps(); const db = d.ctx.db; diff --git a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts index e5ea0055..0d0f431e 100644 --- a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts +++ b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts @@ -1,17 +1,68 @@ -import { Dashboard, type DecisionKind, type SlotStatusCounts } from "@umt/contracts"; +import { Api, Dashboard, type Decision, type DecisionKind, type SlotStatusCounts } from "@umt/contracts"; import { sql } from "@umt/db"; import type { ModuleApp } from "../../types.ts"; import type { AoDeps } from "../services/common.ts"; import { cleanupCandidates } from "../services/downloads.ts"; +import { attentionSql } from "../services/organize-records.ts"; import { decideVolume, loadVolumes, usedPercent, volumeThresholds } from "../services/volumes.ts"; -import { startOfDay } from "../util.ts"; +import { startOfDay, toApiList } from "../util.ts"; import { mount } from "./helpers.ts"; // 首页六区块(REQ §13.4.3):只读投影,统计未知时给 null 并在 notes 说明。 const iso = (v: Date | null | undefined) => (v ? v.toISOString() : null); +/** 需要你批准的智能体操作:会话里的审批 / 方案选择、计划审批(整理记录上的审批在整理记录里处理)、原站页面等你接手 */ +const APPROVAL_SQL = sql`(subject_type = 'agent_run' + or (kind = 'approval_required' and coalesce(subject_type, '') <> 'organization_record') + or dedupe_key like 'browser.waiting:%')`; +/** 系统健康:站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线(恢复后自动关闭) */ +const HEALTH_SQL = sql`(kind in ('source_unreachable', 'downloader_offline', 'cache_volume_unknown', 'space_insufficient') + or dedupe_key like 'library_root_offline:%')`; +const OPEN_SQL = sql`status in ('open', 'investigating', 'awaiting_user')`; +const ATTENTION_ITEMS = 20; + export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) { + // 铃铛 / 首页 / 运行状态共用的「需要关注」汇总(口径见 contracts AttentionSummary) + mount( + app, + Api.attention.summary, + async () => { + const db = d.ctx.db; + const group = async (where: typeof APPROVAL_SQL) => { + const [items, total] = await Promise.all([ + db + .selectFrom("decision") + .selectAll() + .where(OPEN_SQL) + .where(where) + .orderBy("created_at", "desc") + .limit(ATTENTION_ITEMS) + .execute(), + db + .selectFrom("decision") + .select((eb) => eb.fn.countAll().as("n")) + .where(OPEN_SQL) + .where(where) + .executeTakeFirst(), + ]); + return { total: Number(total?.n ?? 0), items: toApiList(items) }; + }; + const [organize, approvals, health] = await Promise.all([ + db + .selectFrom("organization_record") + .select((eb) => eb.fn.countAll().as("n")) + .where("action", "in", ["copy", "move", "in_place", "hardlink"]) + .where(attentionSql()) + .executeTakeFirst(), + group(APPROVAL_SQL), + group(HEALTH_SQL), + ]); + return { organize: Number(organize?.n ?? 0), approvals, health }; + }, + { tag: "dashboard" }, + ); + mount( app, { diff --git a/apps/server/src/modules/acquisition-organize/routes/organization.ts b/apps/server/src/modules/acquisition-organize/routes/organization.ts index 5f8daeb5..80ded260 100644 --- a/apps/server/src/modules/acquisition-organize/routes/organization.ts +++ b/apps/server/src/modules/acquisition-organize/routes/organization.ts @@ -15,6 +15,7 @@ import { type SourceGuard, sourceGuard, } from "../services/organize-guard.ts"; +import { attentionSql } from "../services/organize-records.ts"; import { actorOf, errMessage, hashJson, jsonb, notFound } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; @@ -50,7 +51,14 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { query, ).execute(); const n = (s: string) => Number(rows.find((r) => r.status === s)?.n ?? 0); + const att = await recordFilters( + db() + .selectFrom("organization_record") + .select((eb) => eb.fn.countAll().as("n")), + { ...query, attention: true }, + ).executeTakeFirst(); return { + attention: Number(att?.n ?? 0), total: rows.reduce((a, r) => a + Number(r.n), 0), succeeded: n("succeeded"), partial: n("partial"), @@ -102,6 +110,79 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG }, ); + // 忽略:这条记录不再算需要处理;关联的未关闭事项一并关闭(dismissed) + mount( + app, + Api.organization.dismissAttention, + async (c, { params }) => { + const by = actorOf(c); + const rec = await db() + .transaction() + .execute(async (trx) => { + const r = await trx + .updateTable("organization_record") + .set({ attention_cleared_at: d.ports.now() }) + .where("id", "=", params.id) + .returningAll() + .executeTakeFirst(); + if (!r) return null; + const closed = await trx + .updateTable("decision") + .set({ + status: "dismissed", + resolution: jsonb({ optionKey: null, note: "在整理记录里忽略", by }), + resolved_by: by, + resolved_at: d.ports.now(), + }) + .where("subject_type", "=", "organization_record") + .where("subject_id", "=", params.id) + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .returning("id") + .execute(); + for (const x of closed) + await d.ctx.outbox.emit( + trx, + "decision.updated", + { decisionId: x.id, status: "dismissed" }, + { idempotencyKey: `decision.updated:${x.id}:dismissed` }, + ); + return r; + }); + if (!rec) notFound("整理记录"); + await audit(d, by, "organize.attention_dismissed", { recordId: params.id }); + return (await loadRecordsPublic(db(), [rec!], false))[0]!; + }, + { tag: TAG }, + ); + + // 让智能体识别:没识别出作品的记录入队一次识别会话(agent 模块 resolveDecision trigger=identify),认出来自动重新整理 + mount( + app, + Api.organization.agentIdentify, + async (_c, { params }) => { + const dec = await db() + .selectFrom("decision") + .select(["id", "status", "evidence"]) + .where("subject_type", "=", "organization_record") + .where("subject_id", "=", params.id) + .where("dedupe_key", "like", "organize_identity:%") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .orderBy("created_at", "desc") + .executeTakeFirst(); + if (!dec) throw new ApiException("conflict", "这条记录不是「没识别出作品」,不能让智能体识别"); + if (typeof (dec.evidence as { downloadTaskId?: unknown } | null)?.downloadTaskId !== "string") + throw new ApiException("conflict", "这条记录没有关联的下载任务。用整理向导手动整理"); + if (dec.status === "investigating") return { decisionId: dec.id, queued: false }; + await d.ctx.engine.enqueue( + "resolveDecision", + { decisionId: dec.id, trigger: "identify" }, + { idempotencyKey: `resolveDecision:identify:${dec.id}:${randomUUID()}`, traceId: dec.id }, + ); + return { decisionId: dec.id, queued: true }; + }, + { tag: TAG }, + ); + // 手动发起整理:与自动整理走同一冻结计划流程;返回 record/task ID,由任务页追踪。 // 先过三道闸(organize-guard.ts):路径守卫(400)→ Move 授权(403)→ 纳管来源的做种义务(409 + 待处理)。 mount( @@ -273,6 +354,7 @@ function recordFilters=", new Date(query.since)); if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]); + if (query.attention) q = q.where(attentionSql()); if (query.kind === "none") q = q.where("media_entity_id", "is", null); else if (query.kind) q = q.where("media_entity_id", "in", (eb: ExpressionBuilder) => diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index 4aed224c..9b5bcb49 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -14,6 +14,7 @@ import type { SubscriptionRow } from "@umt/db"; import type { Context } from "hono"; import type { AuthEnv } from "../../../http/auth.ts"; import { ApiException } from "../../../http/errors.ts"; +import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import type { ModuleApp } from "../../types.ts"; import { ensureSubscriptionSlot } from "../services/acquisition.ts"; import type { AoDeps, Exec } from "../services/common.ts"; @@ -378,6 +379,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { return r; }); if (row.media_entity_id) { + // 建订阅 = 作品进入关注范围:从没刷新过元数据的补排 full 刷新(集数两层,见 sources-identity/service/watched.ts) + await enqueueWatchedRefresh(d.ctx, row.media_entity_id, "subscription"); await kickEvaluate(d, { subscriptionId: row.id }); // 新订阅:立即主动搜索一次(之后按周期);剧集先刷新元数据拿季总集数 await enqueueSubscriptionSearch(d, row.id, "created").catch((err) => diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index 854e5553..aa572fbf 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -35,6 +35,7 @@ import { import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts"; +import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import { errMessage, jsonb } from "../util.ts"; import { parsedOf } from "./acquisition.ts"; import { @@ -266,6 +267,9 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: .selectAll() .where("id", "=", plan.source_release_id) .executeTakeFirstOrThrow(); + // 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts) + const watchedEntity = ev.mediaEntityId ?? rel.media_entity_id; + if (watchedEntity) await enqueueWatchedRefresh(d.ctx, watchedEntity, "download"); const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); const client = await d.ports.downloader(topo.inst); const tag = topo.inst.managed_tag; diff --git a/apps/server/src/modules/acquisition-organize/services/organize-records.ts b/apps/server/src/modules/acquisition-organize/services/organize-records.ts index 3cfb0f77..184b5cc6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-records.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-records.ts @@ -1,4 +1,4 @@ -import { reasonSentence } from "@umt/contracts"; +import { type DecisionOption, type RecordAttention, reasonSentence } from "@umt/contracts"; import { type OrganizationRecordRow, sql } from "@umt/db"; import { errMessage, jsonb } from "../util.ts"; import { type AoDeps, type Exec, enqueueSweep, timeSlot, upsertDecision } from "./common.ts"; @@ -9,11 +9,111 @@ import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 +// ── 需要处理(2026-10-06 去掉待处理中心:整理侧的问题在整理记录里就地处理) ── + +const OPEN_DECISION = ["open", "investigating", "awaiting_user"] as const; + +/** + * 一条整理记录「需要处理」的口径(列表筛选、统计、首页 / 运行状态汇总共用): + * 没被同一下载任务后来的整理记录取代,并且 + * (有关联的未关闭事项(subject = 这条记录)) 或 (失败 / 部分完成且没被用户忽略)。 + * alias:外层 organization_record 的别名(kysely selectFrom("organization_record") 即表名)。 + */ +export function attentionSql(alias = "organization_record") { + const r = sql.raw(alias); + return sql`(not exists ( + select 1 from organization_record r2 + where ${r}.download_task_id is not null and r2.download_task_id = ${r}.download_task_id + and r2.created_at > ${r}.created_at) + and (exists ( + select 1 from decision dd + where dd.subject_type = 'organization_record' and dd.subject_id = ${r}.id + and dd.status in ('open', 'investigating', 'awaiting_user')) + or (${r}.status in ('failed', 'partial') and ${r}.attention_cleared_at is null)))`; +} + +/** 事项归到哪类处理:没识别出作品 / 目标冲突 / 其它 */ +function attentionTopic(dedupeKey: string | null, kind: string | null, reason: string | null) { + if (dedupeKey?.startsWith("organize_identity:") || reason?.startsWith("identity_unknown")) return "identity" as const; + if (dedupeKey?.startsWith("organize_blocked:") || kind === "path_conflict" || reason?.startsWith("blocked")) + return "conflict" as const; + return "other" as const; +} + +async function loadAttention(exec: Exec, rows: OrganizationRecordRow[]) { + const ids = rows.map((r) => r.id); + const [decisions, superseded] = await Promise.all([ + exec + .selectFrom("decision") + .select([ + "id", + "kind", + "status", + "title", + "summary", + "options", + "agent_run_id", + "evidence", + "dedupe_key", + "subject_id", + ]) + .where("subject_type", "=", "organization_record") + .where("subject_id", "in", ids) + .where("status", "in", [...OPEN_DECISION]) + .orderBy("created_at", "desc") + .execute(), + exec + .selectFrom("organization_record as r") + .select("r.id") + .where("r.id", "in", ids) + .where((eb) => + eb.exists( + eb + .selectFrom("organization_record as r2") + .select("r2.id") + .whereRef("r2.download_task_id", "=", "r.download_task_id") + .whereRef("r2.created_at", ">", "r.created_at"), + ), + ) + .execute(), + ]); + const old = new Set(superseded.map((x) => x.id)); + const out = new Map(); + for (const r of rows) { + const d = decisions.find((x) => x.subject_id === r.id) ?? null; + const failed = (r.status === "failed" || r.status === "partial") && !r.attention_cleared_at; + if (old.has(r.id) || (!d && !failed)) { + out.set(r.id, null); + continue; + } + const topic = attentionTopic(d?.dedupe_key ?? null, d?.kind ?? null, r.reason); + const ev = (d?.evidence ?? {}) as Record; + out.set(r.id, { + topic, + decision: d + ? { + id: d.id, + kind: d.kind, + status: d.status, + title: d.title, + summary: d.summary, + options: (d.options ?? []) as DecisionOption[], + agentRunId: d.agent_run_id, + evidence: ev, + } + : null, + agentIdentify: !!d && d.dedupe_key.startsWith("organize_identity:") && typeof ev.downloadTaskId === "string", + }); + } + return out; +} + // ── 公开记录映射 ── export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[], includeFiles: boolean) { if (rows.length === 0) return []; const ids = rows.map((r) => r.id); + const attention = await loadAttention(exec, rows); const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))]; const dlIds = [...new Set(rows.map((r) => r.download_task_id).filter((x): x is string => !!x))]; const [files, ops, entities, idents, units, sizes, dls] = await Promise.all([ @@ -196,6 +296,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ startedAt: iso(r.started_at), finishedAt: iso(r.finished_at), changeSeq: String(r.change_seq), + attention: attention.get(r.id) ?? null, }; }); } diff --git a/apps/server/src/modules/acquisition-organize/services/takeover.ts b/apps/server/src/modules/acquisition-organize/services/takeover.ts index da1e2809..bc7ccc65 100644 --- a/apps/server/src/modules/acquisition-organize/services/takeover.ts +++ b/apps/server/src/modules/acquisition-organize/services/takeover.ts @@ -326,13 +326,7 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook { if (row.dedupe_key.startsWith("organize_identity:")) { if (!dt) throw new HookError("not_found", "关联的下载任务不存在"); const entityId = await chosenEntity(d, body, opt, actor); - await d.ctx.db - .updateTable("download_task") - .set({ media_entity_id: entityId }) - .where("id", "=", dt.id) - .execute(); - const r = await startManualImport(d, { downloadTaskId: dt.id, mediaEntityId: entityId }, actor, "decision"); - return { kind: "organize", mediaEntityId: entityId, ...r }; + return reorganizeAs(d, dt.id, entityId, actor, "decision"); } // organize_blocked:目标已存在 → 替换 / 保留两者 / 跳过 const key = body.optionKey; @@ -367,6 +361,84 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook { }; } +/** 回填下载任务的作品后按该作品重新整理(人工指定与智能体识别共用) */ +async function reorganizeAs( + d: AoDeps, + downloadTaskId: string, + entityId: string, + actor: DecisionHookActor, + via: string, +) { + await d.ctx.db + .updateTable("download_task") + .set({ media_entity_id: entityId }) + .where("id", "=", downloadTaskId) + .execute(); + const r = await startManualImport(d, { downloadTaskId, mediaEntityId: entityId }, actor, via); + return { kind: "organize", mediaEntityId: entityId, ...r }; +} + +const OPEN_DECISION = ["open", "investigating", "awaiting_user"]; + +/** + * organize.assign_identity:智能体给「没识别出作品」的整理事项指定作品(本地作品或 TMDB),随即重新整理并关闭事项。 + * 与人工在整理记录里「指定作品」走同一条路(回填下载任务 → 冻结手动整理计划 → 入队执行)。 + */ +export async function assignOrganizeIdentity( + d: AoDeps, + decisionId: string, + pick: { mediaEntityId?: string | undefined; tmdb?: { type: "movie" | "tv"; id: string } | undefined }, + actor: DecisionHookActor, + note: string, +): Promise<{ mediaEntityId: string; recordId: string | null; status: string; decisionStatus: "resolved" }> { + const db = d.ctx.db; + const row = await db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); + if (!row) throw new HookError("not_found", "事项不存在"); + if (!row.dedupe_key.startsWith("organize_identity:")) + throw new HookError("bad_request", "只能用于整理时「没识别出作品」的事项"); + if (!OPEN_DECISION.includes(row.status)) throw new HookError("conflict", "这条记录已经处理过了"); + const ev = (row.evidence ?? {}) as { downloadTaskId?: string | null }; + const dtId = ev.downloadTaskId ?? null; + if (!dtId) throw new HookError("not_found", "这条记录没有关联的下载任务,只能在整理向导里手动整理"); + const entityId = pick.mediaEntityId + ? await chosenEntity( + d, + { identity: { mediaEntityId: pick.mediaEntityId } }, + { key: "x", label: "x", input: { requires: "mediaEntityId" } }, + actor, + ) + : await chosenEntity( + d, + { identity: { tmdb: pick.tmdb } }, + { key: "x", label: "x", input: { requires: "tmdb" } }, + actor, + ); + const r = await reorganizeAs(d, dtId, entityId, actor, "agent-identify"); + const by = actor.id ? `${actor.type}:${actor.id}` : actor.type; + await db.transaction().execute(async (trx) => { + const closed = await trx + .updateTable("decision") + .set({ + status: "resolved", + resolution: jsonb({ optionKey: null, note, by, execution: { ...r, via: "organize.assign_identity" } }), + resolved_by: by, + resolved_at: new Date(), + }) + .where("id", "=", decisionId) + .where("status", "in", OPEN_DECISION as never[]) + .returning("id") + .executeTakeFirst(); + if (closed) + await d.ctx.outbox.emit( + trx, + "decision.updated", + { decisionId, status: "resolved" }, + { idempotencyKey: `decision.updated:${decisionId}:resolved` }, + ); + }); + return { mediaEntityId: entityId, recordId: r.recordId, status: r.status, decisionStatus: "resolved" }; +} + /** 已登记下载任务里,内容路径(本机视图)与这些源路径重叠的(手动按路径整理时找回对应下载任务) */ export async function downloadTasksUnder(exec: Exec, paths: readonly string[]): Promise { if (paths.length === 0) return []; diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 9e690a71..8271d792 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -4,6 +4,7 @@ import type { CapabilitySpec } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import type { SettingsReader } from "../types.ts"; import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts"; +import { investigationDecisionId } from "./runner.ts"; import { exceedsAncestors } from "./subagents.ts"; // 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中 @@ -233,6 +234,24 @@ export function createAuthorizer(deps: { if (block) return { allowed: false, reason: block }; } + // 整理没识别出作品(organize.assign_identity):只放行这条事项的智能体识别会话(dedupe investigate:<事项>)与用户发起的对话; + // 不需要持续授权(用户在设置里开着「整理识别失败时叫智能体识别」即同意,默认开启) + if (d.id === "organize.assign_identity") { + if (!runId || (ctx.source !== "agent" && ctx.source !== "task")) + return { allowed: false, reason: "只有智能体会话或控制台可以指定整理作品" }; + const r = await db + .selectFrom("agent_run") + .select(["trigger", "dedupe_key"]) + .where("id", "=", runId) + .executeTakeFirst(); + if (r?.trigger === "user") return { allowed: true }; + const target = typeof inp.decisionId === "string" ? inp.decisionId : null; + const own = investigationDecisionId(r?.dedupe_key); + return target && own === target + ? { allowed: true } + : { allowed: false, reason: "只能处理本次识别的那条整理记录" }; + } + // 视觉子流程(spawn_reason = browser.solve_challenge)里的坐标动作:父会话那次 solve_challenge 已过授权, // 步数 / 预算 / 白名单由子流程约束,这里随之放行(否则只授权 solve_challenge 时子流程一步也点不了) if (runId && CHALLENGE_STEP_CAPS.has(d.id)) { diff --git a/apps/server/src/modules/agent/challenge.ts b/apps/server/src/modules/agent/challenge.ts index 4143e38b..393856be 100644 --- a/apps/server/src/modules/agent/challenge.ts +++ b/apps/server/src/modules/agent/challenge.ts @@ -78,9 +78,8 @@ export function systemChallengeVision(env: () => AgentEnv, registry: CapabilityR success: { textAbsent: req.textAbsent ?? "Just a moment" }, maxSteps: Math.min(12, Math.max(1, req.maxSteps)), budget: { + // 不设 token / 费用上限(2026-10-06);步数与时长是防跑飞限制 maxDurationSec: Math.min(120, s.agent.maxDurationSec), - ...(s.agent.maxTokensPerRun ? { maxTokens: Math.max(1000, s.agent.maxTokensPerRun) } : {}), - ...(s.agent.maxCostUsdPerRun ? { maxCostUsd: s.agent.maxCostUsdPerRun } : {}), }, }; const hctx = { diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index 02c44ce5..2c620947 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -2,8 +2,16 @@ import type { DecisionOption, DownloadStatus } from "@umt/contracts"; import { type DecisionRow, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; import { GRANT_ACTIONS } from "./authorize.ts"; -import { type DriveResult, INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES, RECOVERY_ACTIONS } from "./runner.ts"; +import { + type DriveResult, + INVESTIGATE_CAPABILITIES, + investigationDecisionId, + ORGANIZE_IDENTIFY_CAPABILITIES, + RECOVER_CAPABILITIES, + RECOVERY_ACTIONS, +} from "./runner.ts"; import { startRun } from "./service.ts"; +import { ORGANIZE_IDENTIFY_ACTOR } from "./usage.ts"; // 后台自动调查与授权内恢复(REQ §8.4.2、§10.7、AT68 / AT71): // decision.created → autoInvestigate:按 settings.agent.autoInvestigateMode 决定只读调查或授权内恢复, @@ -13,6 +21,9 @@ import { startRun } from "./service.ts"; // 按业务事实(download_task / task / source_release 状态;集数映射看事项内资源是否都已不是待确认)核实恢复结果, // 核实成功才关闭事项; // 失败 / 超时 / 预算超限一律保持待处理(finalize 把事项转为 awaiting_user),不自动再试。 +// 整理没识别出作品(organize_identity:*):不看 autoInvestigate,按 agent.autoIdentifyOrganize(缺省开)派一次智能体识别, +// 认出来调 organize.assign_identity 指定作品并重新整理(事项随即关闭);认不出停在整理记录「需要处理」里, +// 用户可在那里再点「让智能体识别」(manual,另起一个会话)。 const KINDS = ["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"] as const; export const AUTO_ACTOR = "system:auto-investigate"; @@ -66,8 +77,7 @@ export interface RecoveryOutcome { summary: string; } -const decisionIdOf = (dedupeKey: string | null | undefined) => - dedupeKey?.startsWith("investigate:") ? dedupeKey.slice("investigate:".length) : null; +const decisionIdOf = investigationDecisionId; export const isInvestigationRun = (run: { dedupe_key: string | null }) => decisionIdOf(run.dedupe_key) !== null; @@ -128,12 +138,13 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) { } function suggestionFor(d: DecisionRow, reason: string): string { + if (d.dedupe_key.startsWith("organize_identity:")) return `${reason}。可在整理记录里指定作品或重新整理。`; if (d.kind === "identity_ambiguous") - return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`; + return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`; if (d.kind === "episode_mapping") - return `${reason}。智能体模拟通过的规则会作为建议留在事项上,可在待处理中确认;或为 episode_mapping.apply_rule 发放持续授权。`; + return `${reason}。智能体模拟通过的规则会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`; return ( - `${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + + `${reason}。可在下载详情里「强制重新校验」(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + `换源、删除需人工决定。` ); } @@ -167,8 +178,10 @@ async function addRecheckOption(ctx: ServerContext, d: DecisionRow) { export async function autoInvestigate( ctx: ServerContext, decisionId: string, + opts: { manual?: boolean } = {}, ): Promise<{ runId: string | null; created: boolean; mode?: "investigate" | "recover"; skipped?: string }> { const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); + if (d?.dedupe_key.startsWith("organize_identity:")) return identifyOrganize(ctx, d, opts.manual === true); if (d?.status !== "open") return { runId: null, created: false }; if (!(KINDS as readonly string[]).includes(d.kind)) return { runId: null, created: false }; // Agent 自己引起的事项不再触发 Agent(防止递归自触发) @@ -284,6 +297,96 @@ export async function autoInvestigate( return { runId: run.id, created, mode }; } +/** 证据里的字段转成给模型看的一句(没有就空) */ +function evidenceLine(ev: Record): string { + const parsed = (ev.parsed ?? {}) as Record; + const parts = [ + typeof ev.torrentName === "string" ? `种子名「${ev.torrentName}」` : "", + typeof ev.mainFile === "string" ? `主视频「${ev.mainFile}」` : "", + parsed.title ? `解析出的标题「${String(parsed.title)}」` : "", + parsed.year ? `年份 ${String(parsed.year)}` : "", + parsed.season != null ? `第 ${String(parsed.season)} 季` : "", + Array.isArray(parsed.episodes) && parsed.episodes.length ? `集 ${parsed.episodes.join("、")}` : "", + ].filter(Boolean); + const cands = Array.isArray(ev.candidates) ? (ev.candidates as Record[]).slice(0, 5) : []; + const candText = cands.length + ? `;常规识别的候选:${cands + .map( + (c) => + `${String(c.title ?? "?")}${c.year ? ` (${String(c.year)})` : ""}${c.externalId ? ` tmdb:${String(c.entityType ?? "")}/${String(c.externalId)}` : ""}${c.localEntityId ? ` 本地作品 ${String(c.localEntityId)}` : ""}`, + ) + .join(";")}` + : ""; + return parts.length ? `${parts.join(",")}${candText}。` : candText ? `${candText.slice(1)}。` : ""; +} + +/** + * 整理没识别出作品 → 智能体识别一次(设置 agent.autoIdentifyOrganize,缺省开;manual = 用户在整理记录里点「让智能体识别」, + * 不看开关、事项已转人工也可以,另起一个会话)。会话在白名单内查作品,有把握才调 organize.assign_identity; + * 结果按恢复动作核实(事项关闭 = 已指定并重新整理),没认出来事项转 awaiting_user,留在整理记录「需要处理」里。 + */ +export async function identifyOrganize( + ctx: ServerContext, + d: DecisionRow, + manual: boolean, +): Promise<{ runId: string | null; created: boolean; mode?: "recover"; skipped?: string }> { + if (manual) { + if (!["open", "awaiting_user"].includes(d.status)) + return d.status === "investigating" && d.agent_run_id + ? { runId: d.agent_run_id, created: false, skipped: "running" } + : { runId: null, created: false, skipped: `status_${d.status}` }; + } else { + if (d.status !== "open") return { runId: null, created: false }; + const s = await ctx.settings.get("agent"); + if (!s.autoIdentifyOrganize) return { runId: null, created: false, skipped: "disabled" }; + } + const s = await ctx.settings.get("agent"); + const ev = (d.evidence ?? {}) as Record; + const goal = [ + `识别整理记录里的作品(待处理事项 ${d.id}):${d.title}。`, + evidenceLine(ev), + "用 catalog.lookup 按标题 / 年份查 TMDB,用 inventory.query 看媒体库里有没有这个作品;标题是拼音、缩写或译名对不上时可以用 web.search 查正式名称。", + `确认标题、年份、类型(电影 / 剧集)和季集都对得上时,调用一次 organize.assign_identity(decisionId 填 ${d.id},` + + "本地已有作品填 mediaEntityId,否则填 tmdb 类型和 ID,reasons 写依据),系统会按这个作品重新整理。", + "拿不准就不要指定,只说明查到了什么、为什么认不出。不下载、不删除、不改别的记录。", + ].join(""); + const { run, created } = await startRun(ctx, { + goal, + mode: "execute", + capabilityAllowlist: [...ORGANIZE_IDENTIFY_CAPABILITIES], + trigger: "event", + createdBy: ORGANIZE_IDENTIFY_ACTOR, + dedupeKey: manual ? `investigate:${d.id}#${Date.now()}` : `investigate:${d.id}`, + budget: { maxToolCalls: s.investigateMaxToolCalls, maxDurationSec: Math.min(900, s.maxDurationSec) }, + }); + if (created) { + const r = await ctx.db + .updateTable("decision") + .set({ status: "investigating", agent_run_id: run.id }) + .where("id", "=", d.id) + .where("status", "in", ["open", "awaiting_user"]) + .returning("id") + .executeTakeFirst(); + if (r) { + await mergeEvidence(ctx, d.id, { + mode: "recover", + requestedMode: "recover", + attempt: 1, + maxAttempts: 1, + recoverAttempted: true, + runId: run.id, + }); + await ctx.outbox.emit( + ctx.db, + "decision.updated", + { decisionId: d.id, status: "investigating" }, + { idempotencyKey: `decision.updated:${d.id}:investigating:${run.id}` }, + ); + } + } + return { runId: run.id, created, mode: "recover" }; +} + /** 本 run 调用过的恢复动作(账本)与被拒绝的调用(审计) */ export async function recoveryActions( ctx: ServerContext, @@ -356,6 +459,14 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[ : undefined; facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null; states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending"); + } else if (a.capabilityId === "organize.assign_identity") { + // 指定作品后事项随即关闭、重新整理已入队;事项关闭即视为恢复(整理结果看整理记录) + const id = String(a.input.decisionId ?? ""); + const d = id + ? await ctx.db.selectFrom("decision").select(["status"]).where("id", "=", id).executeTakeFirst() + : undefined; + facts[`decision:${id}`] = d ? { status: d.status } : null; + states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending"); } else if (a.capabilityId === "episode_mapping.apply_rule") { // 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复 const id = String(a.input.decisionId ?? ""); @@ -415,15 +526,15 @@ export async function applyRecoveryOutcome( ? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。` : outcome === "failed" ? done.length > 0 - ? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。` - : `未恢复:${what} 执行失败,已退回待处理。` + ? `未恢复:执行 ${what} 后业务状态仍为失败,已交还人工。` + : `未恢复:${what} 执行失败,已交还人工。` : outcome === "unverified" - ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。` + ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已交还人工。` : outcome === "budget_exceeded" - ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。` + ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已交还人工。` : outcome === "cancelled" ? "未恢复:运行已取消。" - : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`; + : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已交还人工。`; const evidence: Partial = { runId, outcome, diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index e81ecdce..7db462b8 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -19,6 +19,7 @@ import { afterApprovalDecided, answerOptions } from "./interaction.ts"; import { type InvokeLimiter, sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "./invoke-limits.ts"; import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts"; import { descendants, descendantUsage } from "./subagents.ts"; +import { agentUsage } from "./usage.ts"; // Agent 会话 API、持续授权 / 单次审批、能力目录与调用、业务任务。 @@ -119,6 +120,8 @@ export function registerAgentRoutes( const dispatch = new InternalDispatcher(app); // ── Agent 会话 ── + route(app, Api.agentRuns.usage, async () => agentUsage(db())); + route(app, Api.agentRuns.list, async ({ query }) => { let q = db().selectFrom("agent_run").selectAll(); if (query.status) q = q.where("status", "=", query.status); diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index f1e00c65..532f8f90 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -91,12 +91,35 @@ export const RECOVER_CAPABILITIES = [ "download.recheck", "episode_mapping.apply_rule", ] as const; +/** + * 整理没识别出作品时的智能体识别:查作品(TMDB / 媒体库 / 网页)+ 指定作品并重新整理。 + * 不能下载、删除、改别的事项;organize.assign_identity 只放行本事项(authorize.ts) + */ +export const ORGANIZE_IDENTIFY_CAPABILITIES = [ + "catalog.lookup", + "inventory.query", + "release.inspect", + "download.inspect", + "task.inspect", + "decision.list", + "web.search", + "web.fetch", + "organize.assign_identity", +] as const; +/** + * 后台调查会话的去重键 investigate:<事项 id>;用户在整理记录里再点一次「让智能体识别」时加 #<序号> 另起一个会话。 + * 返回事项 id,不是调查会话返回 null + */ +export function investigationDecisionId(dedupeKey: string | null | undefined): string | null { + return dedupeKey?.startsWith("investigate:") ? (dedupeKey.slice("investigate:".length).split("#")[0] ?? null) : null; +} /** 被视为"恢复动作"的能力(结果需系统核实) */ export const RECOVERY_ACTIONS = [ "task.retry", "download.recheck", "identity.resolve", "episode_mapping.apply_rule", + "organize.assign_identity", ] as const; export interface RunScope { @@ -162,8 +185,9 @@ export function agentSettings( upstream: RunUpstream, ): AgentSettings { const pi = s.pi; - const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined; - const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined; + // 设置里不再有 token / 费用上限(2026-10-06);只有调用方显式划拨(子会话、巡检预算)时才限制 + const maxTokens = budget.maxTokens; + const maxCost = budget.maxCostUsd; const up = upstream.row; return { provider: up.provider, @@ -387,20 +411,6 @@ async function lastOutcomeEvent(env: AgentEnv, runId: string) { return r ?? null; } -async function dailyBudgetBlock(env: AgentEnv, s: GlobalSettings, run: AgentRunRow): Promise { - if (run.started_at) return null; // 续跑不重复计数 - const day = new Date(); - day.setHours(0, 0, 0, 0); - const r = await sql<{ n: number; cost: number | null }>` - select count(*)::int as n, sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost - from agent_run where started_at >= ${day} and id <> ${run.id}`.execute(env.ctx.db); - const n = r.rows[0]?.n ?? 0; - const cost = r.rows[0]?.cost ?? 0; - if (n >= s.budget.agentMaxRunsPerDay) return `今日智能体运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`; - if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日智能体费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`; - return null; -} - /** * 驱动一次模型会话直到本轮结束(含追加消息)。整个函数在一个持久步骤里执行:worker 崩溃后新 worker 从头重跑本函数, * 打开同一 JSONL 会话,pendingOperations>0 时对账 + resume,已完成的工具调用不会重放。 @@ -416,9 +426,8 @@ export async function drive( const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow(); if (run.cancel_requested_at || signal.aborted) return { status: "cancelled", reason: "aborted", budget: null, message: "运行已被取消", finalText: "" }; + // 不设每日次数 / 费用上限(2026-10-06):花费在设置页与运行状态里可见,单次运行只受工具调用次数与时长约束 const s = await ctx.settings.all(); - const blocked = await dailyBudgetBlock(env, s, run); - if (blocked) return { status: "failed", reason: "budget", budget: "daily", message: blocked, finalText: "" }; const scope = (run.scope ?? {}) as unknown as RunScope; // 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId;都没有直接失败 diff --git a/apps/server/src/modules/agent/service.ts b/apps/server/src/modules/agent/service.ts index 76ac9a08..cf36aa61 100644 --- a/apps/server/src/modules/agent/service.ts +++ b/apps/server/src/modules/agent/service.ts @@ -12,7 +12,14 @@ import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; import { dismissPendingInteractions } from "./interaction.ts"; import { buildGoalReport } from "./report.ts"; -import { type AgentEnv, type DriveResult, INVESTIGATE_CAPABILITIES, publishStatus, scopeFor } from "./runner.ts"; +import { + type AgentEnv, + type DriveResult, + INVESTIGATE_CAPABILITIES, + investigationDecisionId, + publishStatus, + scopeFor, +} from "./runner.ts"; import { cascadeCancel, descendants, @@ -364,8 +371,9 @@ export async function finalize( .where("id", "=", runId) .execute(); // 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒) - if (run.dedupe_key?.startsWith("investigate:")) { - const decisionId = run.dedupe_key.slice("investigate:".length); + const investigated = investigationDecisionId(run.dedupe_key); + if (investigated) { + const decisionId = investigated; await ctx.db.transaction().execute(async (trx) => { const r = await trx .updateTable("decision") diff --git a/apps/server/src/modules/agent/subagents.ts b/apps/server/src/modules/agent/subagents.ts index 44b2ab2d..3c431306 100644 --- a/apps/server/src/modules/agent/subagents.ts +++ b/apps/server/src/modules/agent/subagents.ts @@ -471,9 +471,9 @@ function limitsOf(run: AgentRunRow, s: GlobalSettings): Limits { const b = (run.budget ?? {}) as AgentBudget; return { maxToolCalls: b.maxToolCalls ?? s.agent.maxToolCalls, - maxTokens: b.maxTokens ?? s.agent.maxTokensPerRun ?? null, - maxCostUsd: - b.maxCostUsd ?? (s.agent.maxCostUsdPerRun && s.agent.maxCostUsdPerRun > 0 ? s.agent.maxCostUsdPerRun : null), + // token / 费用只认本会话显式划拨的额度;设置里不再有全局上限(2026-10-06) + maxTokens: b.maxTokens ?? null, + maxCostUsd: b.maxCostUsd && b.maxCostUsd > 0 ? b.maxCostUsd : null, maxDurationSec: b.maxDurationSec ?? s.agent.maxDurationSec, }; } diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 278436e4..4e8a89ba 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -177,12 +177,15 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) name: "resolveDecision", version: 1, lane: "agent", - inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated"]).default("updated") }), + // identify:用户在整理记录里点「让智能体识别」(整理没识别出作品的事项,另起一个识别会话) + inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated", "identify"]).default("updated") }), retryPolicy: DB_RETRY, handler: async (t) => { const e = env(); if (t.input.trigger === "created") return t.step.run("investigate", () => autoInvestigate(e.ctx, t.input.decisionId)); + if (t.input.trigger === "identify") + return t.step.run("identify", () => autoInvestigate(e.ctx, t.input.decisionId, { manual: true })); return t.step.run("execute", () => executeResolution(e.ctx, t.input.decisionId)); }, }), diff --git a/apps/server/src/modules/agent/usage.ts b/apps/server/src/modules/agent/usage.ts new file mode 100644 index 00000000..6ffb0b76 --- /dev/null +++ b/apps/server/src/modules/agent/usage.ts @@ -0,0 +1,44 @@ +import type { AgentSpend, AgentSpendBucket, AgentSpendKind } from "@umt/contracts"; +import { type DB, type Kysely, sql } from "@umt/db"; + +// 智能体用量(2026-10-06):不设每日次数 / 费用上限,改为把花费摆出来。按 agent_run 的开始时间(没开始的按创建时间) +// 统计今天与最近 7 天,按会话类型分组;费用取 usage.costUsd(上游不报费用时为 0)。 + +/** 整理没识别出作品时智能体识别的会话发起者(见 investigate.ts) */ +export const ORGANIZE_IDENTIFY_ACTOR = "system:organize-identify"; + +const KIND_SQL = sql`case + when parent_run_id is not null then 'subagent' + when created_by = ${ORGANIZE_IDENTIFY_ACTOR} then 'organize_identify' + when trigger = 'schedule' then 'patrol' + when trigger = 'event' then 'investigate' + else 'chat' end`; + +const ORDER: AgentSpendKind[] = ["chat", "patrol", "investigate", "organize_identify", "subagent"]; + +async function bucket(db: Kysely, since: Date): Promise { + const r = await sql<{ kind: AgentSpendKind; runs: number; tokens: string | null; cost: number | null }>` + select ${KIND_SQL} as kind, count(*)::int as runs, + sum(coalesce((usage->>'totalTokens')::bigint, 0))::bigint as tokens, + sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost + from agent_run + where coalesce(started_at, created_at) >= ${since} + group by 1`.execute(db); + const byKind = r.rows + .map((x) => ({ kind: x.kind, runs: x.runs, tokens: Number(x.tokens ?? 0), costUsd: x.cost ?? 0 })) + .sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind)); + return { + runs: byKind.reduce((a, x) => a + x.runs, 0), + tokens: byKind.reduce((a, x) => a + x.tokens, 0), + costUsd: byKind.reduce((a, x) => a + x.costUsd, 0), + byKind, + }; +} + +export async function agentUsage(db: Kysely, now = new Date()): Promise { + const today = new Date(now); + today.setHours(0, 0, 0, 0); + const week = new Date(today.getTime() - 6 * 86_400_000); + const [t, w] = await Promise.all([bucket(db, today), bucket(db, week)]); + return { today: t, last7d: w }; +} diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts index 840aabaf..18d17abb 100644 --- a/apps/server/src/modules/search/actions.ts +++ b/apps/server/src/modules/search/actions.ts @@ -23,13 +23,18 @@ const action = (id: string, group: string, title: string, href: string, keywords export const SEARCH_ACTIONS: readonly SearchAction[] = [ page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]), page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]), - page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]), page("schedules", "概览", "计划", "/console/schedules", ["schedule", "cron", "巡检", "计划", "定时任务"]), page("sites", "获取", "站点", "/console/sites", ["site", "pt", "站点账号", "rss", "feed", "bt"]), page("subscriptions", "获取", "订阅", "/console/subscriptions", ["subscription", "追剧"]), page("rules", "获取", "规则", "/console/rules", ["rules", "收录策略", "policy"]), page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]), page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]), + page("organize-attention", "整理", "需要处理的整理记录", "/console/organize?tab=records&attention=1", [ + "inbox", + "待处理", + "识别不到", + "decision", + ]), page("files", "整理", "文件管理", "/console/files", ["files", "文件"]), page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]), page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]), diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 8eb5ba3f..f1ccd56a 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -25,6 +25,7 @@ import { jsonb } from "../rows.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { ensureUnits } from "./entities.ts"; import { type Layout, seasonOrigins } from "./episode-structure.ts"; +import { isEntityWatched } from "./watched.ts"; // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: @@ -405,6 +406,9 @@ export async function settleEpisodeDecision( : null; await detachFromEpisodeDecisions(ctx, exec, sourceReleaseId, target); if (!target) return; + // 两层(2026-10-06):集数待确认是内部事项(供智能体调查,用户在订阅详情 / 作品页集数映射里看原因), + // 只给关注中的作品开;不在关注范围的作品资源照样待确认(不建集、订阅跳过),进入关注范围补刷新时重算再开 + if (!(await isEntityWatched(exec, entityId))) return; const existing = await exec .selectFrom("decision") diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index b7018efc..280a60cf 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -55,6 +55,7 @@ import { import { loadSubject, searchBangumi } from "./meta-subjects.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; +import { enqueueWatchedRefresh, isEntityWatched } from "./watched.ts"; // resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。 // auto 才建/关联正式作品;ambiguous / no_match / 查询失败进待识别池(候选只存 JSON),有界退避复查。 @@ -900,7 +901,14 @@ async function bindAuto( reason: resolution.reason, }; }); - if (created) await enqueueNewEntityRefresh(ctx, created); + // 两层(2026-10-06):自动新建的作品只在关注范围内才排刷新(用户搜索 / 手动识别 / 接管下载,或已被订阅等); + // RSS / 列表扫进来、没人关心的作品不拉 TMDB 季表,等它进入关注范围(建订阅、搜索、开始下载)时再补排 + const userAsked = lookup === "search" || lookup === "manual" || lookup === "takeover"; + if (created) { + if (userAsked || (await isEntityWatched(ctx.db, created))) await enqueueNewEntityRefresh(ctx, created); + } else if (userAsked && result.mediaEntityId) { + await enqueueWatchedRefresh(ctx, result.mediaEntityId, `lookup:${lookup}`); + } return result; } catch (e) { if (e instanceof IdentityConflictError && attempt < 2) continue; diff --git a/apps/server/src/modules/sources-identity/service/watched.ts b/apps/server/src/modules/sources-identity/service/watched.ts new file mode 100644 index 00000000..13cb7e67 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/watched.ts @@ -0,0 +1,70 @@ +import { sql } from "@umt/db"; +import type { ServerContext } from "../../types.ts"; +import type { Exec } from "./common.ts"; + +// 关注中的作品(2026-10-06,集数识别分两层): +// 确定性的集数校验 / 换算对所有已绑定作品的资源照做(无外部请求、无打扰); +// 有成本或会打扰的后续动作——拉 TMDB、自动新建作品后的元数据刷新、开集数待确认、派智能体——只对关注中的作品做。 +// +// 关注中 = 满足任一: +// 1. 有进行中的订阅(subscription.status in active / pending); +// 2. 用户明确表达过兴趣:手动下载过(acquisition_plan.evaluation.manual = true)、用户搜索 / 手动识别出的资源 +// (source_release.resolution_evidence.lookup in search / manual)、人工 / 智能体确认的外部身份(evidence_level = manual); +// 3. 正在下载或整理:未移除且没入库完的下载任务(直接、经资源或经下载计划关联),或排队 / 执行中的整理记录; +// 4. 已在媒体库:有在用(active)的已入库文件。 +// RSS / 站点列表扫进来、自动建档却没人关心的作品不算。 + +const OPEN_SUBSCRIPTION = ["active", "pending"]; +const DOWNLOAD_DONE = ["imported", "removed"]; + +export async function isEntityWatched(exec: Exec, entityId: string): Promise { + const r = await sql<{ watched: boolean }>` + select ( + exists (select 1 from subscription s where s.media_entity_id = ${entityId} and s.status = any(${OPEN_SUBSCRIPTION})) + or exists (select 1 from media_asset a where a.media_entity_id = ${entityId} and a.status = 'active') + or exists (select 1 from external_identity x + where x.media_entity_id = ${entityId} and x.status = 'accepted' and x.evidence_level = 'manual') + or exists (select 1 from source_release r + where r.media_entity_id = ${entityId} and r.resolution_evidence->>'lookup' in ('search', 'manual')) + or exists (select 1 from acquisition_plan p left join acquisition_slot sl on sl.id = p.slot_id + where (sl.media_entity_id = ${entityId} or p.evaluation->>'mediaEntityId' = ${entityId}::text) + and (p.evaluation->>'manual')::boolean is true) + or exists (select 1 from download_task t + left join source_release r on r.id = t.source_release_id + left join acquisition_plan p on p.id = t.acquisition_plan_id + left join acquisition_slot sl on sl.id = p.slot_id + where t.removed_at is null and t.status <> all(${DOWNLOAD_DONE}) + and ${entityId} in (t.media_entity_id, r.media_entity_id, sl.media_entity_id)) + or exists (select 1 from organization_record o + where o.media_entity_id = ${entityId} and o.status in ('queued', 'running')) + ) as watched`.execute(exec); + return r.rows[0]?.watched === true; +} + +/** + * 作品进入关注范围(建订阅、用户搜索、开始下载)时补排一次 full 元数据刷新:只对从没刷新过的作品排 + * (自动建档时因不在关注范围而没排的),刷新完成会重算待确认 / 滞后放行资源的集数(必要时开集数待确认)。 + * 入队失败只记日志;同一作品同一原因只排一次(幂等键)。 + */ +export async function enqueueWatchedRefresh(ctx: ServerContext, entityId: string, reason: string): Promise { + const e = await ctx.db + .selectFrom("media_entity") + .select(["id", "last_metadata_refresh_at", "status"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!e || e.last_metadata_refresh_at || e.status === "merged") return false; + try { + await ctx.engine.enqueue( + "refreshMetadata", + { mediaEntityId: entityId, full: true }, + { idempotencyKey: `refreshMetadata:watched:${entityId}`, traceId: entityId }, + ); + return true; + } catch (err) { + ctx.logger.debug( + { entityId, reason, err: { message: err instanceof Error ? err.message : String(err) } }, + "进入关注范围的元数据刷新入队失败", + ); + return false; + } +} diff --git a/apps/server/test/acquisition-organize/organize-attention.test.ts b/apps/server/test/acquisition-organize/organize-attention.test.ts new file mode 100644 index 00000000..6b4c6307 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-attention.test.ts @@ -0,0 +1,227 @@ +import { capabilitySpec } from "@umt/contracts"; +import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit"; +import { sql } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { assignOrganizeIdentity } from "../../src/modules/acquisition-organize/services/takeover.ts"; +import { createAuthorizer } from "../../src/modules/agent/authorize.ts"; +import { autoInvestigate } from "../../src/modules/agent/investigate.ts"; +import { ORGANIZE_IDENTIFY_CAPABILITIES } from "../../src/modules/agent/runner.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 去掉待处理中心后的整理侧(2026-10-06): +// - 下载完识别不到 → 整理记录「需要处理」(attention 口径:未被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略); +// - 识别失败自动派一次智能体识别(agent.autoIdentifyOrganize,缺省开;关掉不派), +// 智能体 organize.assign_identity 指定作品后重新整理、事项关闭,旧记录不再算需要处理; +// - 整理记录里「让智能体识别」入队一次识别会话;「忽略」清掉需要处理; +// - /attention:整理需要处理的条数;铃铛只放等你批准的智能体操作与系统健康,整理侧的审批不进铃铛。 + +const hasPg = await pgAvailable(); +const SIZE = 300_000; + +describe.skipIf(!hasPg)("整理记录需要处理 + 识别失败自动叫智能体识别", () => { + let h: Harness; + let qb: MockQb; + let lib: TempTree; + let cache: TempTree; + let n = 0; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" }); + cache = await createTempTree({}, { prefix: "umt-cache-" }); + h = await createHarness("organize_attention", {}, { withAgent: true }); + await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); + await h.setSettings("retention", { seedRetentionDays: 0 }); + }, 60_000); + + afterAll(async () => { + await h?.close(); + await qb?.close(); + await lib?.cleanup(); + await cache?.cleanup(); + }); + + async function adoptCompleted(name: string, file: string) { + n++; + const hash = n.toString(16).padStart(40, "b"); + qb.seed({ hash, name, tags: ["umt"], save_path: cache.root }); + await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] }); + await syncDownloads(h.d); + return h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirstOrThrow(); + } + + /** 接管任务识别不到 → 失败记录 + organize_identity 事项 */ + async function unidentified(name: string) { + const dt = await adoptCompleted(name, `${name}.mkv`); + const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); + expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" }); + const dec = await h.db + .selectFrom("decision") + .selectAll() + .where("dedupe_key", "=", `organize_identity:${dt.id}`) + .executeTakeFirstOrThrow(); + return { dt, recordId: plan.recordId!, dec }; + } + + const attentionIds = async () => { + const r = await h.api("GET", "/organization-records?attention=true&organizeOnly=true"); + expect(r.status).toBe(200); + return (r.body.data.items as { id: string }[]).map((x) => x.id); + }; + + it("识别不到:记录进「需要处理」,行内带原因与可做的动作;统计同口径", async () => { + const { recordId, dec } = await unidentified("Vqzt.Nowhere.2019.1080p.WEB-DL"); + expect(await attentionIds()).toContain(recordId); + const one = await h.api("GET", `/organization-records/${recordId}`); + expect(one.body.data.attention).toMatchObject({ + topic: "identity", + agentIdentify: true, + decision: { id: dec.id, kind: "identity_ambiguous", status: "open" }, + }); + const stats = await h.api("GET", "/organization-records/stats?organizeOnly=true"); + expect(stats.body.data.attention).toBeGreaterThanOrEqual(1); + const summary = await h.api("GET", "/attention"); + expect(summary.status).toBe(200); + expect(summary.body.data.organize).toBe(stats.body.data.attention); + // 整理侧事项不进铃铛(铃铛只放等你批准的智能体操作与系统健康) + const bell = [...summary.body.data.approvals.items, ...summary.body.data.health.items].map( + (x: { id: string }) => x.id, + ); + expect(bell).not.toContain(dec.id); + }); + + it("开关开(缺省):识别失败自动派一次智能体识别;同一事项不重复派", async () => { + const { dec } = await unidentified("Wqxx.Elsewhere.2020.1080p.WEB-DL"); + const out = await autoInvestigate(h.ctx, dec.id); + expect(out).toMatchObject({ created: true, mode: "recover" }); + const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", out.runId!).executeTakeFirstOrThrow(); + expect(run).toMatchObject({ + dedupe_key: `investigate:${dec.id}`, + created_by: "system:organize-identify", + trigger: "event", + mode: "execute", + }); + expect((run.scope as { capabilities: string[] }).capabilities.sort()).toEqual( + [...ORGANIZE_IDENTIFY_CAPABILITIES].sort(), + ); + expect(run.goal).toContain("organize.assign_identity"); + expect(run.goal).toContain(dec.id); + const after = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(after).toMatchObject({ status: "investigating", agent_run_id: run.id }); + expect((await autoInvestigate(h.ctx, dec.id)).created).toBe(false); + }); + + it("开关关:不派智能体,事项留在整理记录里", async () => { + await h.setSettings("agent", { autoIdentifyOrganize: false }); + try { + const { dec } = await unidentified("Xqyy.Nomatch.2021.1080p.WEB-DL"); + expect(await autoInvestigate(h.ctx, dec.id)).toMatchObject({ runId: null, created: false, skipped: "disabled" }); + const runs = await h.db + .selectFrom("agent_run") + .select("id") + .where("dedupe_key", "like", `investigate:${dec.id}%`) + .execute(); + expect(runs).toHaveLength(0); + // 用户在整理记录里点「让智能体识别」不看开关:入队一次识别 + const rec = await h.db + .selectFrom("decision") + .select("subject_id") + .where("id", "=", dec.id) + .executeTakeFirstOrThrow(); + const r = await h.api("POST", `/organization-records/${rec.subject_id}/agent-identify`); + expect(r.status).toBe(200); + expect(r.body.data).toEqual({ decisionId: dec.id, queued: true }); + expect(h.engine.enqueued.at(-1)).toMatchObject({ + task: "resolveDecision", + input: { decisionId: dec.id, trigger: "identify" }, + }); + const manual = await autoInvestigate(h.ctx, dec.id, { manual: true }); + expect(manual.created).toBe(true); + const run = await h.db + .selectFrom("agent_run") + .select("dedupe_key") + .where("id", "=", manual.runId!) + .executeTakeFirstOrThrow(); + expect(run.dedupe_key).toMatch(new RegExp(`^investigate:${dec.id}#\\d+$`)); + } finally { + await h.setSettings("agent", {}); + } + }); + + it("智能体识别成功:assign_identity 只放行本事项的会话;指定作品后自动重新整理,事项关闭,旧记录不再需要处理", async () => { + await h.setSettings("automation", { enabled: true }); + const target = await seedEntity(h.db, { kind: "movie", title: "Silent Meridian", year: 2018 }); + const { recordId, dec } = await unidentified("Smrd.2018.1080p.BluRay.x264-GRP"); + const other = await unidentified("Qqqq.Other.2017.1080p.WEB-DL"); + const out = await autoInvestigate(h.ctx, dec.id); + const authorize = createAuthorizer({ db: h.db, settings: h.ctx.settings }); + const spec = capabilitySpec("organize.assign_identity"); + const ctx = { + actorId: "agent", + taskId: "t", + scopeIds: [], + signal: new AbortController().signal, + source: "agent" as const, + agentRunId: out.runId!, + }; + const input = (decisionId: string) => ({ decisionId, mediaEntityId: target, reasons: ["标题年份一致"] }); + const allowed = await authorize({ descriptor: spec, input: input(dec.id), ctx }); + expect(allowed.allowed).toBe(true); + const denied = await authorize({ descriptor: spec, input: input(other.dec.id), ctx }); + expect(denied).toMatchObject({ allowed: false }); + + const r = await assignOrganizeIdentity( + h.d, + dec.id, + { mediaEntityId: target }, + { type: "agent", id: out.runId! }, + "标题年份一致", + ); + expect(r).toMatchObject({ mediaEntityId: target, status: "running", decisionStatus: "resolved" }); + const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + expect((await executeRecord(h.d, r.recordId!)).status).toBe("succeeded"); + const ids = await attentionIds(); + expect(ids).not.toContain(recordId); + expect(ids).not.toContain(r.recordId); + expect(ids).toContain(other.recordId); + // 同一事项再指定:已处理过 + await expect( + assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: target }, { type: "agent", id: null }, "x"), + ).rejects.toThrow(/已经处理过/); + }); + + it("忽略:失败记录不再需要处理,关联事项一并关闭", async () => { + const { recordId, dec } = await unidentified("Zzyy.Ignore.2016.1080p.WEB-DL"); + const r = await h.api("POST", `/organization-records/${recordId}/dismiss-attention`); + expect(r.status).toBe(200); + expect(r.body.data.attention).toBeNull(); + expect(await attentionIds()).not.toContain(recordId); + const d = await h.db.selectFrom("decision").select("status").where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(d.status).toBe("dismissed"); + }); + + it("/attention:系统健康与等你批准的智能体操作进铃铛,整理记录上的审批不进", async () => { + const run = await h.db + .insertInto("agent_run") + .values({ trigger: "user", goal: "g", status: "waiting", scope: "{}", budget: "{}", created_by: "user:x" }) + .returning("id") + .executeTakeFirstOrThrow(); + const rec = await h.db.selectFrom("organization_record").select("id").limit(1).executeTakeFirstOrThrow(); + const ins = (kind: string, key: string, subjectType: string | null, subjectId: string | null) => + sql`insert into decision (kind, severity, title, subject_type, subject_id, dedupe_key) + values (${kind}, 'warning', ${key}, ${subjectType}, ${subjectId}, ${key})`.execute(h.db); + await ins("cache_volume_unknown", "cache_volume_unknown:vol-1", "downloader_instance", null); + await ins("approval_required", "approval:run", "agent_run", run.id); + await ins("approval_required", "organize_blocked:hnr", "organization_record", rec.id); + await ins("episode_mapping", "episode_mapping:x", "media_entity", null); + const s = (await h.api("GET", "/attention")).body.data; + const keys = (g: { items: { dedupeKey: string }[] }) => g.items.map((x) => x.dedupeKey); + expect(keys(s.health)).toEqual(["cache_volume_unknown:vol-1"]); + expect(keys(s.approvals)).toEqual(["approval:run"]); + expect(s.health.total).toBe(1); + expect(s.approvals.total).toBe(1); + }); +}); diff --git a/apps/server/test/acquisition-organize/records-filter.test.ts b/apps/server/test/acquisition-organize/records-filter.test.ts index f0ee3fa6..8455337c 100644 --- a/apps/server/test/acquisition-organize/records-filter.test.ts +++ b/apps/server/test/acquisition-organize/records-filter.test.ts @@ -107,7 +107,15 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { it("统计与列表同口径,不受状态筛选影响", async () => { const all = await h.api("GET", "/organization-records/stats"); expect(all.status).toBe(200); - expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 }); + expect(all.body.data).toEqual({ + total: 4, + succeeded: 2, + partial: 0, + failed: 2, + running: 0, + cancelled: 0, + attention: 2, + }); const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true"); expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); }); diff --git a/apps/server/test/agent/spend-no-budget.test.ts b/apps/server/test/agent/spend-no-budget.test.ts new file mode 100644 index 00000000..86318ba6 --- /dev/null +++ b/apps/server/test/agent/spend-no-budget.test.ts @@ -0,0 +1,110 @@ +import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings } from "@umt/contracts"; +import type { PiUpstreamRow } from "@umt/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createAgentModule } from "../../src/modules/agent/index.ts"; +import { agentSettings } from "../../src/modules/agent/runner.ts"; +import { ORGANIZE_IDENTIFY_ACTOR } from "../../src/modules/agent/usage.ts"; +import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; +import { DEV_KEY, json } from "../helpers.ts"; + +// 智能体不设硬性预算上限(2026-10-06):每天次数 / 每天花费 / 单次花费 / 单次 token 四个上限不再拦截(旧值兼容读取、不生效), +// 只保留单次工具调用次数与单次最长时长两个防跑飞限制(缺省调宽);花费改为可见:/agent/usage 按会话类型统计今天与最近 7 天。 + +const upstream = { row: { provider: "fake", default_model: null, thinking_level: "off" } as unknown as PiUpstreamRow }; + +describe("预算上限移除,防跑飞限制保留", () => { + it("旧设置里的 token / 费用上限不进运行参数;工具调用次数与时长照用", () => { + const legacy = { + ...DEFAULT_SETTINGS, + agent: { ...DEFAULT_SETTINGS.agent, maxTokensPerRun: 1000, maxCostUsdPerRun: 0.01 }, + budget: { ...DEFAULT_SETTINGS.budget, agentMaxRunsPerDay: 0, agentMaxCostUsdPerDay: 0 }, + } as GlobalSettings; + // biome-ignore lint/suspicious/noExplicitAny: 只取用到的上游字段 + const s = agentSettings(legacy, {}, true, upstream as any); + expect(s).not.toHaveProperty("costBudget"); + expect(s.maxToolCalls).toBe(DEFAULT_SETTINGS.agent.maxToolCalls); + expect(s.maxDurationMs).toBe(DEFAULT_SETTINGS.agent.maxDurationSec * 1000); + // 调用方显式划拨(子会话、巡检预算)仍然生效 + // biome-ignore lint/suspicious/noExplicitAny: 同上 + const sub = agentSettings(legacy, { maxToolCalls: 3, maxCostUsd: 0.5 }, true, upstream as any); + expect(sub.maxToolCalls).toBe(3); + expect(sub.costBudget).toEqual({ maxCostUsd: 0.5 }); + }); + + it("缺省调宽;四个上限登记为弃用字段", () => { + expect(DEFAULT_SETTINGS.agent.maxToolCalls).toBeGreaterThanOrEqual(100); + expect(DEFAULT_SETTINGS.agent.maxDurationSec).toBeGreaterThanOrEqual(1800); + expect(DEFAULT_SETTINGS.agent.investigateMaxToolCalls).toBeGreaterThanOrEqual(20); + expect(DEFAULT_SETTINGS.agent.autoIdentifyOrganize).toBe(true); + expect(DEFAULT_SETTINGS.budget).not.toHaveProperty("agentMaxRunsPerDay"); + expect(DEPRECATED_SETTINGS_FIELDS).toEqual( + expect.arrayContaining([ + "budget.agentMaxRunsPerDay", + "budget.agentMaxCostUsdPerDay", + "agent.maxTokensPerRun", + "agent.maxCostUsdPerRun", + ]), + ); + }); +}); + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("花费统计 /agent/usage(真实 PG)", () => { + let h: Harness; + beforeAll(async () => { + h = await createHarness({ extraModules: [createAgentModule()] }); + }, 120_000); + afterAll(async () => { + await h?.close(); + }); + + it("今天与最近 7 天按会话类型统计次数、token 与费用;超过 7 天的不算", async () => { + const day = 86_400_000; + const now = Date.now(); + const run = async ( + trigger: string, + at: number, + usage: { totalTokens: number; costUsd: number }, + extra: Record = {}, + ) => + ( + await h.db + .insertInto("agent_run") + .values({ + trigger, + goal: "g", + status: "succeeded", + mode: "execute", + usage: JSON.stringify(usage), + started_at: new Date(at), + created_at: new Date(at), + ...extra, + } as never) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const chat = await run("user", now, { totalTokens: 1000, costUsd: 0.1 }); + await run("subagent", now, { totalTokens: 500, costUsd: 0.05 }, { parent_run_id: chat }); + await run("event", now, { totalTokens: 200, costUsd: 0.02 }, { created_by: ORGANIZE_IDENTIFY_ACTOR }); + await run("event", now - 3 * day, { totalTokens: 300, costUsd: 0.03 }); + await run("schedule", now - 3 * day, { totalTokens: 400, costUsd: 0.04 }); + await run("user", now - 10 * day, { totalTokens: 9999, costUsd: 9 }); + + const res = await h.app.request("/api/v1/agent/usage", { headers: { authorization: `Bearer ${DEV_KEY}` } }); + expect(res.status).toBe(200); + const { today, last7d } = (await json(res)).data; + expect(today.runs).toBe(3); + expect(today.tokens).toBe(1700); + expect(today.costUsd).toBeCloseTo(0.17); + expect(today.byKind.map((k: { kind: string }) => k.kind)).toEqual(["chat", "organize_identify", "subagent"]); + expect(last7d.runs).toBe(5); + expect(last7d.costUsd).toBeCloseTo(0.24); + expect(last7d.byKind).toEqual( + expect.arrayContaining([ + { kind: "patrol", runs: 1, tokens: 400, costUsd: 0.04 }, + { kind: "investigate", runs: 1, tokens: 300, costUsd: 0.03 }, + ]), + ); + }); +}); diff --git a/apps/server/test/e2e/organize-identify.test.ts b/apps/server/test/e2e/organize-identify.test.ts new file mode 100644 index 00000000..700eff79 --- /dev/null +++ b/apps/server/test/e2e/organize-identify.test.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import type { FakeScript } from "@umt/agent"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createE2E, type E2E, pgAvailable, runEvents, seedBase, waitFor } from "./harness.ts"; + +// 整理识别不到作品时自动叫智能体识别(2026-10-06,agent.autoIdentifyOrganize 缺省开): +// 用户手动加进 qB 的任务(贴管理标签)下完 → 整理按文件名认不出 → 整理记录失败 + 内部事项 organize_identity +// → decision.created → resolveDecision → 识别会话(白名单只有查询类 + organize.assign_identity,不需要持续授权) +// → 智能体查到本地作品后 organize.assign_identity → 按该作品自动重新整理入库,事项关闭,记录不再需要处理。 + +const hasPg = await pgAvailable(); + +/** 识别会话目标里带着事项 id(脚本从目标里取) */ +const cur: { decisionId?: string; entityId?: string } = {}; + +const identifyScript: FakeScript = { + name: "organize-identify", + match: (goal) => { + const m = /待处理事项 ([0-9a-f-]{36})/.exec(goal); + if (m && goal.includes("识别整理记录里的作品")) cur.decisionId = m[1]; + return goal.includes("识别整理记录里的作品"); + }, + steps: [ + { + text: "先看媒体库里有没有这部。", + toolCalls: [{ capability: "inventory.query", input: { query: "Silent Meridian" } }], + }, + { + text: "标题、年份都对得上,指定为媒体库里的《Silent Meridian》。", + toolCalls: [ + { + capability: "organize.assign_identity", + input: () => ({ + decisionId: cur.decisionId ?? "", + mediaEntityId: cur.entityId ?? "", + reasons: ["种子名缩写 Smrd 对应 Silent Meridian,年份 2018 一致"], + }), + }, + ], + }, + ], + finalText: "已指定作品并重新整理。", +}; + +describe.skipIf(!hasPg)("整理识别不到作品 → 智能体识别 → 自动重新整理", () => { + let e: E2E; + + beforeAll(async () => { + e = await createE2E("orgid", { fakeScripts: [identifyScript] }); + await seedBase(e); + const ent = await e.db + .insertInto("media_entity") + .values({ kind: "movie", title: "Silent Meridian", year: 2018, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + cur.entityId = ent.id; + await e.settings({ retention: { seedRetentionDays: 0 } }); + await e.startWorker(); + }, 120_000); + + afterAll(async () => { + await e?.close(); + }, 60_000); + + it("识别失败自动派一次识别会话;指定作品后自动重新整理入库,事项关闭,记录不再需要处理", async () => { + const name = "Smrd.2018.1080p.BluRay.x264-QQQ"; + const hash = createHash("sha1").update("orgid-1").digest("hex"); + e.qb.seed({ hash, name, size: 400_000, progress: 1, tags: ["umt"], save_path: e.cache.root }); + await e.qb.complete(hash, { files: [{ path: `${name}/${name}.mkv`, size: 400_000 }], writeFiles: true }); + + const dec = await waitFor( + () => + e.db.selectFrom("decision").selectAll().where("dedupe_key", "like", "organize_identity:%").executeTakeFirst(), + 60_000, + "整理识别不到 → organize_identity 事项", + ); + const run = await waitFor( + () => + e.db.selectFrom("agent_run").selectAll().where("dedupe_key", "=", `investigate:${dec.id}`).executeTakeFirst(), + 60_000, + "识别会话", + ); + expect(run).toMatchObject({ trigger: "event", mode: "execute", created_by: "system:organize-identify" }); + + const done = await waitFor( + async () => { + const r = await e.db.selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow(); + return r.finished_at ? r : null; + }, + 120_000, + "识别会话结束", + ); + const starts = (await runEvents(e, run.id)) + .filter((x) => x.type === "tool_call_start") + .map((x) => (x.payload as { capabilityId: string }).capabilityId); + expect(starts).toEqual(["inventory.query", "organize.assign_identity"]); + expect(done.status).toBe("succeeded"); + expect(done.summary).toContain("已恢复"); + + const closed = await e.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + + // 自动重新整理:同一下载任务的新整理记录成功入库,作品是智能体指定的 + const rec = await waitFor( + async () => { + const r = await e.db + .selectFrom("organization_record") + .selectAll() + .where("status", "=", "succeeded") + .where("media_entity_id", "=", cur.entityId!) + .executeTakeFirst(); + return r ?? null; + }, + 90_000, + "重新整理入库", + ); + expect(rec.download_task_id).toBeTruthy(); + const attention = await e.api("GET", "/organization-records?attention=true"); + expect(attention.body.data.items).toEqual([]); + }, 300_000); +}); diff --git a/apps/server/test/e2e/subagents.test.ts b/apps/server/test/e2e/subagents.test.ts index 38719d84..41737c4c 100644 --- a/apps/server/test/e2e/subagents.test.ts +++ b/apps/server/test/e2e/subagents.test.ts @@ -217,8 +217,8 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => { // 子超预算:budget_exceeded,不影响父会话 expect(oppie.status).toBe("budget_exceeded"); expect((oppie.budget as Any).maxToolCalls).toBe(1); - // 缺省划拨:父剩余预算的一半(设置 maxToolCalls 40,派生时父已用 1 次) - expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(20); + // 缺省划拨:父剩余预算的一半(设置 maxToolCalls 缺省 120,派生时父已用 1 次) + expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(60); // maxConcurrent=1:串行(谁先占到名额不定,但两个运行区间不重叠) const [first, second] = [dune, oppie].sort((a, b) => a.started_at!.getTime() - b.started_at!.getTime()) as [ diff --git a/apps/server/test/sources-identity/episode-watch-scope.test.ts b/apps/server/test/sources-identity/episode-watch-scope.test.ts new file mode 100644 index 00000000..9b707613 --- /dev/null +++ b/apps/server/test/sources-identity/episode-watch-scope.test.ts @@ -0,0 +1,150 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit"; +import { sql } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { enqueueWatchedRefresh, isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +// 集数识别分两层(2026-10-06): +// 确定性校验 / 换算对所有已绑定作品的资源照做;拉 TMDB(自动建档后的元数据刷新)、开集数待确认只对关注中的作品做。 +// RSS 扫进来自动建档、没人关心的作品:不排刷新、不开事项;进入关注范围(建订阅等)补排刷新,刷新重算后才开内部事项。 + +const pg = await pgAvailable(); + +const season = (n: number, eps: number, start: string) => ({ + season_number: n, + air_date: start, + names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` }, + episodes: Array.from({ length: eps }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), + names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, + })), +}); + +// 已完结:S1 = 12、S2 = 13;绝对集号 40 超出全部季,刷新后换算不了 → 集数待确认 +const SHOW: TmdbFixture = { + type: "tv", + id: 990301, + original_title: "Yoru no Kurage wa Oyogenai", + original_language: "ja", + origin_country: ["JP"], + date: "2024-04-06", + titles: { "zh-CN": "夜晚的水母不会游泳", "en-US": "Yoru no Kurage wa Oyogenai" }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status: "Ended" }, + seasons: [season(1, 12, "2024-04-06"), season(2, 13, "2025-04-05")], +}; + +describe.skipIf(!pg)("集数识别两层:只对关注中的作品拉元数据、开集数待确认", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] }); + h = await createHarness("episode_watch_scope", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + // 不靠订阅也查 TMDB(旧行为):RSS 扫进来的资源会自动建档,但作品不在关注范围 + await sql`insert into settings (key, value) values ('identity', ${JSON.stringify({ eagerTmdbLookup: true })}::jsonb) + on conflict (key) do update set value = excluded.value`.execute(h.db); + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const observe = async (title: string) => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(830000 + ++n), title, subtitle: "夜晚的水母不会游泳" }) + .returningAll() + .executeTakeFirstOrThrow(); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "observed" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId as string }; + }; + const method = async (id: string) => + ( + ( + await h.db + .selectFrom("source_release") + .select("episode_resolution") + .where("id", "=", id) + .executeTakeFirstOrThrow() + ).episode_resolution as { method: string } + ).method; + const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata"); + const episodeDecisions = () => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .execute(); + + let entityId: string; + let r40: string; + + it("RSS 扫进来自动建档:确定性换算照做,但不排元数据刷新、不开集数待确认", async () => { + const a = await observe("[ANi] Yoru no Kurage wa Oyogenai - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + entityId = a.entityId; + expect(await method(a.id)).toBe("unverified"); + expect(await isEntityWatched(h.db, entityId)).toBe(false); + const b = await observe("[Lilith-Raws] Yoru no Kurage wa Oyogenai - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); + r40 = b.id; + expect(b.entityId).toBe(entityId); + expect(refreshJobs()).toEqual([]); + expect(await episodeDecisions()).toEqual([]); + // 季详情一次都没拉(识别只用搜索与详情) + expect(tmdb.callsTo(/\/season\//)).toHaveLength(0); + }); + + it("即使季表已知、资源换算不了,非关注作品也不开事项", async () => { + // 直接刷新一次(模拟别处拉过季表),非关注作品的待确认资源照样只记在资源上 + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + expect(await method(r40)).toBe("unresolved"); + expect(await episodeDecisions()).toEqual([]); + await h.db.updateTable("media_entity").set({ last_metadata_refresh_at: null }).where("id", "=", entityId).execute(); + }); + + it("进入关注范围(建订阅):补排一次 full 刷新;刷新重算后对换算不了的资源开内部事项", async () => { + await h.db + .insertInto("subscription") + .values({ media_entity_id: entityId, media_kind: "series", status: "active" }) + .execute(); + expect(await isEntityWatched(h.db, entityId)).toBe(true); + expect(await enqueueWatchedRefresh(h.ctx, entityId, "subscription")).toBe(true); + expect(refreshJobs()).toEqual([ + { + task: "refreshMetadata", + input: { mediaEntityId: entityId, full: true }, + key: `refreshMetadata:watched:${entityId}`, + }, + ]); + // 执行入队的刷新(同 refreshMetadata 任务处理器) + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + const ds = await episodeDecisions(); + expect(ds).toHaveLength(1); + expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([r40]); + // 已刷新过的不再补排 + expect(await enqueueWatchedRefresh(h.ctx, entityId, "download")).toBe(false); + }); + + it("用户搜索 / 手动识别出的新作品照常排刷新", async () => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(830000 + ++n), title: "Oppenheimer.2023.2160p.UHD.BluRay" }) + .returningAll() + .executeTakeFirstOrThrow(); + const before = refreshJobs().length; + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + expect(await isEntityWatched(h.db, res.mediaEntityId as string)).toBe(true); + expect(refreshJobs().length).toBe(before + 1); + }); +}); diff --git a/apps/server/test/sources-identity/site-capabilities.test.ts b/apps/server/test/sources-identity/site-capabilities.test.ts index 81a0ae7f..585a5225 100644 --- a/apps/server/test/sources-identity/site-capabilities.test.ts +++ b/apps/server/test/sources-identity/site-capabilities.test.ts @@ -15,8 +15,8 @@ const TiB = 1024 ** 4; const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const; describe("站点能力规格", () => { - it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(46); + it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(47); expect(capabilitySpec("episode_mapping.inspect")).toMatchObject({ kind: "read", replay: "safe" }); expect(capabilitySpec("episode_mapping.simulate")).toMatchObject({ kind: "preview", replay: "safe" }); expect(capabilitySpec("episode_mapping.apply_rule")).toMatchObject({ diff --git a/apps/web/app/api/attention.ts b/apps/web/app/api/attention.ts new file mode 100644 index 00000000..cef9bde8 --- /dev/null +++ b/apps/web/app/api/attention.ts @@ -0,0 +1,37 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AgentSpend, AttentionSummary } from "@umt/contracts"; +import { useRef } from "react"; +import { useSse } from "~/lib/sse.ts"; +import { apiParsed } from "./parse.ts"; + +// 需要关注的汇总(2026-10-06 去掉待处理中心后):铃铛只放等你批准的智能体操作与系统健康, +// 首页 / 运行状态放汇总计数(整理需要处理的条数等),点进去到对应页面。 + +export const attentionKeys = { all: ["attention"] as const, spend: ["attention", "agent-spend"] as const }; + +/** 事项、整理记录变化时(节流)刷新;另有 60 秒兜底轮询 */ +export function useAttention() { + const qc = useQueryClient(); + const timer = useRef | null>(null); + useSse(["decision.created", "decision.updated", "organization.changed"], () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + void qc.invalidateQueries({ queryKey: attentionKeys.all }); + }, 800); + }); + return useQuery({ + queryKey: attentionKeys.all, + queryFn: ({ signal }) => apiParsed(AttentionSummary, "/api/v1/attention", { signal }), + refetchInterval: 60_000, + }); +} + +/** 智能体用量:今天与最近 7 天按会话类型的次数、token 与费用(不设上限,只展示) */ +export function useAgentSpend() { + return useQuery({ + queryKey: attentionKeys.spend, + queryFn: ({ signal }) => apiParsed(AgentSpend, "/api/v1/agent/usage", { signal }), + refetchInterval: 5 * 60_000, + }); +} diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 7a7b62a9..54001624 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -4,6 +4,7 @@ import { cursorPage, LibraryItem, LibraryRoot, + OrganizationAgentIdentifyResult, type OrganizationDeleteRequest, OrganizationDeleteResult, OrganizationStats, @@ -31,6 +32,8 @@ export type RecordFilters = { /** ISO 时间:只看之后创建的 */ since?: string; organizeOnly?: boolean; + /** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */ + attention?: boolean; }; export const recordKeys = { @@ -51,6 +54,33 @@ export function useRecordStats(f: Omit) { }); } +/** 需要处理的记录:忽略(不再算需要处理,关联事项一并关闭) */ +export function useDismissRecordAttention() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/api/v1/organization-records/${id}/dismiss-attention`, { body: {} }).then((d) => + PublicOrganizationRecord.parse(d), + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: recordKeys.all }); + void qc.invalidateQueries({ queryKey: ["attention"] }); + }, + }); +} + +/** 没识别出作品的记录:让智能体识别(认出来自动重新整理) */ +export function useAgentIdentifyRecord() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/api/v1/organization-records/${id}/agent-identify`, { body: {} }).then((d) => + OrganizationAgentIdentifyResult.parse(d), + ), + onSuccess: () => void qc.invalidateQueries({ queryKey: recordKeys.all }), + }); +} + /** HIS-1:删除整理记录(dryRun 只核对会删什么) */ export function useDeleteRecords() { const qc = useQueryClient(); diff --git a/apps/web/app/components/agent/quick-goals.ts b/apps/web/app/components/agent/quick-goals.ts index a5436bc9..ad4d740c 100644 --- a/apps/web/app/components/agent/quick-goals.ts +++ b/apps/web/app/components/agent/quick-goals.ts @@ -30,16 +30,12 @@ const RULES: Rule[] = [ }, { match: /^\/console\/organize/, - goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"], + goals: ["识别需要处理的整理记录里的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"], }, { match: /^\/console\/files/, goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"], }, - { - match: /^\/console\/inbox/, - goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"], - }, { match: /^\/console\/identity/, goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"], diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx index fb4b70a5..b144a66e 100644 --- a/apps/web/app/components/console/inbox-bell.tsx +++ b/apps/web/app/components/console/inbox-bell.tsx @@ -1,71 +1,89 @@ -import { useQueryClient } from "@tanstack/react-query"; -import type { Decision, DecisionKind } from "@umt/contracts"; +import type { Decision } from "@umt/contracts"; import { ArrowRight, Bell, CheckCheck } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useState } from "react"; import { Link, useNavigate } from "react-router"; -import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts"; +import { useAttention } from "~/api/attention.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { SEVERITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; -import { decisionKindLabel, formatRelative } from "~/lib/labels.ts"; -import { useSse } from "~/lib/sse.ts"; +import { formatRelative } from "~/lib/labels.ts"; import { cn } from "~/lib/utils.ts"; -// 顶栏收件箱(IA-3):待处理不进侧栏,铃铛 + 未决角标;下拉按问题类型列最近事项,点开进待处理页。 -// 角标只算需要你处理的事项(警告及以上):"仅信息"类(巡检报告、旧版本待处置等)不计入,在待处理页可展开查看。 -// 身份类事项已按来源 + 标题合并,一条事项可能代表多条资源(标题里写明 N 条)。 +// 顶栏铃铛(2026-10-06 起只放两类):需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手) +// 与系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线,恢复后自动消失)。 +// 整理问题在整理记录里处理,集数待确认在订阅详情 / 作品页集数映射里看,不进铃铛。 -const RECENT = 8; +const RECENT = 6; const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const; -/** 未决数与最近事项;SSE decision.* 到达时刷新 */ -export function useLiveDecisions() { - const qc = useQueryClient(); - const timer = useRef | null>(null); - useSse(["decision.created", "decision.updated"], () => { - if (timer.current) return; - timer.current = setTimeout(() => { - timer.current = null; - void qc.invalidateQueries({ queryKey: decisionKeys.all }); - }, 800); - }); - return useDecisionSummary(undefined, "warning"); +/** 等你批准的事项点开去哪:智能体会话、下载、下载任务;其余去智能体页 */ +export function approvalHref(d: Pick): string { + if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId); + if (d.agentRunId) return consolePath("agent", d.agentRunId); + if (d.subjectType === "acquisition_plan" && d.subjectId) + return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`; + if (d.subjectType === "download_task" && d.subjectId) return consolePath("downloads", d.subjectId); + return consolePath("agent"); } -export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}` : "/console/inbox"); +export const HEALTH_HREF = `${consolePath("status")}#attention`; + +function Group({ + title, + total, + items, + href, + onGo, +}: { + title: string; + total: number; + items: Decision[]; + href: (d: Decision) => string; + onGo: () => void; +}) { + if (!total) return null; + return ( +
+

+ {title} + {total} +

+
    + {items.slice(0, RECENT).map((d) => ( +
  • + + {d.title} + + + {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} + + +
  • + ))} +
+
+ ); +} export function InboxBell() { - const summary = useLiveDecisions(); + const q = useAttention(); const [open, setOpen] = useState(false); - const list = useDecisions({ minSeverity: "warning" }, open); const navigate = useNavigate(); - const total = summary.data?.total ?? 0; - - const groups = useMemo(() => { - const items = (list.data?.pages[0]?.items ?? []).slice(0, RECENT); - const m = new Map(); - for (const d of items) m.set(d.kind, [...(m.get(d.kind) ?? []), d]); - const counts = new Map(); - for (const g of summary.data?.groups ?? []) counts.set(g.kind, (counts.get(g.kind) ?? 0) + g.count); - return [...m.entries()].map(([kind, ds]) => ({ kind, items: ds, count: counts.get(kind) ?? ds.length })); - }, [list.data, summary.data]); - - const go = (to: string) => { - setOpen(false); - navigate(to); - }; + const approvals = q.data?.approvals ?? { total: 0, items: [] }; + const health = q.data?.health ?? { total: 0, items: [] }; + const total = approvals.total + health.total; + const label = total ? `需要你关注 ${total} 项` : "通知"; return ( - - {list.isPending ? ( + {q.isPending ? ( - ) : list.isError ? ( + ) : q.isError ? ( - ) : groups.length === 0 ? ( - + ) : total === 0 ? ( + ) : (
- {groups.map((g) => ( -
- -
    - {g.items.map((d) => ( -
  • - setOpen(false)} - className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50" - > - {d.title} - - - {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} - - -
  • - ))} -
-
- ))} + setOpen(false)} + /> + HEALTH_HREF} + onGo={() => setOpen(false)} + />
)} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 20a94828..1f59cd1b 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -39,10 +39,18 @@ describe("控制台导航", () => { expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); - it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 身份并入别处", () => { + it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => { const paths = NAV_ITEMS.map((i) => i.to); for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p); - expect(pageTitle("/console/inbox")).toBe("待处理"); + expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理"); + expect(pageTitle("/console/inbox")).toBeUndefined(); + }); + + it("旧待处理链接按问题类型落到对应页面", () => { + expect(legacyRedirect("/console/inbox", "?kind=path_conflict")).toBe("/console/organize?tab=records&attention=1"); + expect(legacyRedirect("/console/inbox", "?kind=download_failed")).toBe("/console/downloads"); + expect(legacyRedirect("/console/inbox", "?kind=space_insufficient")).toBe("/console/status#attention"); + expect(legacyRedirect("/console/inbox", "?kind=unknown_kind")).toBe("/console/organize?tab=records&attention=1"); }); it("每个导航项都有路由", () => { diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 535efe5d..542ff2dc 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -42,7 +42,7 @@ export const ACQUIRE_GROUP_TITLE = "获取"; /** * 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。 * 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。 - * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 + * 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ { @@ -139,7 +139,6 @@ export const NAV_GROUPS: NavGroup[] = [ /** 不在侧栏但有页面的路由:顶栏标题用 */ const EXTRA_TITLES: Record = { - "/console/inbox": "待处理", "/console/preferences": "偏好标签", "/console/search": "搜索", }; diff --git a/apps/web/app/components/dashboard/attention-summary.test.ts b/apps/web/app/components/dashboard/attention-summary.test.ts new file mode 100644 index 00000000..0940bb04 --- /dev/null +++ b/apps/web/app/components/dashboard/attention-summary.test.ts @@ -0,0 +1,87 @@ +import type { AttentionSummary, Decision } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { approvalHref, HEALTH_HREF } from "../console/inbox-bell.tsx"; +import { attentionRows } from "./attention-summary.tsx"; + +// 首页 / 运行状态的「需要关注」汇总计数与铃铛跳转(2026-10-06 去掉待处理中心后)。 + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +const dec = (n: number, over: Partial): Decision => + ({ + id: id(n), + kind: "other", + status: "open", + severity: "warning", + title: "t", + summary: null, + subjectType: null, + subjectId: null, + dedupeKey: `k${n}`, + evidence: {}, + options: [], + resolution: null, + taskId: null, + agentRunId: null, + attempts: 0, + nextReviewAt: null, + resolvedBy: null, + resolvedAt: null, + createdAt: "2026-10-06T00:00:00.000Z", + updatedAt: "2026-10-06T00:00:00.000Z", + ...over, + }) as Decision; + +const empty: AttentionSummary = { organize: 0, approvals: { total: 0, items: [] }, health: { total: 0, items: [] } }; + +describe("需要关注汇总", () => { + it("只列非零项", () => { + expect(attentionRows(empty)).toEqual([]); + expect(attentionRows(undefined)).toEqual([]); + }); + + it("整理需要处理 / 等你批准 / 系统健康按问题归类,点进去到对应页面", () => { + const rows = attentionRows({ + organize: 3, + approvals: { + total: 1, + items: [dec(1, { kind: "approval_required", subjectType: "agent_run", subjectId: id(9) })], + }, + health: { + total: 3, + items: [ + dec(2, { kind: "source_unreachable" }), + dec(3, { kind: "downloader_offline" }), + dec(4, { kind: "other", dedupeKey: "library_root_offline:x" }), + ], + }, + }); + expect(rows.map((r) => [r.text, r.to])).toEqual([ + ["3 条整理需要处理", "/console/organize?tab=records&attention=1"], + ["1 个操作等你批准", `/console/agent/${id(9)}`], + ["1 个站点不可达", HEALTH_HREF], + ["1 个下载器离线", HEALTH_HREF], + ["1 个媒体库目录离线", HEALTH_HREF], + ]); + }); + + it("健康问题超过列出的条数时补一行", () => { + const rows = attentionRows({ ...empty, health: { total: 5, items: [dec(2, { kind: "space_insufficient" })] } }); + expect(rows.map((r) => r.text)).toEqual(["1 个下载盘空间不足", "4 个系统问题"]); + }); +}); + +describe("铃铛跳转", () => { + it("智能体会话 / 下载计划 / 下载任务", () => { + expect(approvalHref({ subjectType: "agent_run", subjectId: id(1), agentRunId: null })).toBe( + `/console/agent/${id(1)}`, + ); + expect(approvalHref({ subjectType: "acquisition_plan", subjectId: id(2), agentRunId: null })).toBe( + `/console/downloads?plan=${id(2)}`, + ); + expect(approvalHref({ subjectType: "download_task", subjectId: id(3), agentRunId: null })).toBe( + `/console/downloads/${id(3)}`, + ); + expect(approvalHref({ subjectType: null, subjectId: null, agentRunId: null })).toBe("/console/agent"); + expect(HEALTH_HREF).toBe("/console/status#attention"); + }); +}); diff --git a/apps/web/app/components/dashboard/attention-summary.tsx b/apps/web/app/components/dashboard/attention-summary.tsx new file mode 100644 index 00000000..b8084234 --- /dev/null +++ b/apps/web/app/components/dashboard/attention-summary.tsx @@ -0,0 +1,78 @@ +import type { AttentionSummary, Decision } from "@umt/contracts"; +import { ChevronRight } from "lucide-react"; +import { Link } from "react-router"; +import { approvalHref, HEALTH_HREF } from "~/components/console/inbox-bell.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { ORGANIZE_ATTENTION_PATH } from "~/routes/console/redirects.ts"; + +// 首页与运行状态共用的「需要关注」汇总计数(2026-10-06 去掉待处理中心后): +// 「3 条整理需要处理」「1 个站点不可达」…,点进去到对应页面;只列非零项。 + +/** 系统健康事项按问题归类成一句(量词 + 说法) */ +function healthKind(d: Pick): { key: string; unit: string } { + if (d.dedupeKey.startsWith("library_root_offline:")) return { key: "library_root", unit: "个媒体库目录离线" }; + switch (d.kind) { + case "source_unreachable": + return { key: d.kind, unit: "个站点不可达" }; + case "downloader_offline": + return { key: d.kind, unit: "个下载器离线" }; + case "cache_volume_unknown": + return { key: d.kind, unit: "个下载目录读不到容量" }; + case "space_insufficient": + return { key: d.kind, unit: "个下载盘空间不足" }; + default: + return { key: "other", unit: "个系统问题" }; + } +} + +export interface AttentionRow { + key: string; + text: string; + to: string; +} + +/** 汇总 → 一行一类(非零才列) */ +export function attentionRows(s: AttentionSummary | undefined): AttentionRow[] { + if (!s) return []; + const rows: AttentionRow[] = []; + if (s.organize) rows.push({ key: "organize", text: `${s.organize} 条整理需要处理`, to: ORGANIZE_ATTENTION_PATH }); + if (s.approvals.total) { + const first = s.approvals.items[0]; + rows.push({ + key: "approvals", + text: `${s.approvals.total} 个操作等你批准`, + to: s.approvals.total === 1 && first ? approvalHref(first) : consolePath("agent"), + }); + } + const counts = new Map(); + for (const d of s.health.items) { + const k = healthKind(d); + const cur = counts.get(k.key); + counts.set(k.key, { unit: k.unit, n: (cur?.n ?? 0) + 1 }); + } + const listed = [...counts.values()].reduce((a, x) => a + x.n, 0); + if (s.health.total > listed) { + const cur = counts.get("other"); + counts.set("other", { unit: "个系统问题", n: (cur?.n ?? 0) + s.health.total - listed }); + } + for (const [key, v] of counts) rows.push({ key, text: `${v.n} ${v.unit}`, to: HEALTH_HREF }); + return rows; +} + +export function AttentionList({ rows }: { rows: AttentionRow[] }) { + return ( +
    + {rows.map((r) => ( +
  • + + {r.text} + + +
  • + ))} +
+ ); +} diff --git a/apps/web/app/components/decisions/decision-actions.tsx b/apps/web/app/components/decisions/decision-actions.tsx new file mode 100644 index 00000000..54b00994 --- /dev/null +++ b/apps/web/app/components/decisions/decision-actions.tsx @@ -0,0 +1,199 @@ +import type { Decision, DecisionOption } from "@umt/contracts"; +import { Search } from "lucide-react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { useResolveDecision } from "~/api/decisions.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { FormField } from "~/components/console/form-field.tsx"; +import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx"; +import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +// 事项(decision)的处理按钮:待处理中心取消后(2026-10-06),问题在发生处就地处理(整理记录行内等), +// 选项与执行仍走 POST /decisions/{id}/resolve(后端注册的执行钩子:指定作品后重新整理、替换 / 保留两者 / 跳过…)。 + +/** 处理按钮只用到事项的这几个字段(整理记录里带的是精简版) */ +export type DecisionLike = Pick; + +const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null); + +/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */ +type Requires = "tmdb" | "mediaEntityId" | null; +function requiresOf(option: DecisionOption): Requires { + const r = option.input?.requires; + return r === "tmdb" || r === "mediaEntityId" ? r : null; +} + +/** 「指定作品」两个选项(本地作品 / TMDB)已并进 AssignWorkButton,其余选项各一个按钮 */ +export function plainOptions(d: DecisionLike): DecisionOption[] { + return d.options.filter((o) => o.key !== "dismiss" && requiresOf(o) === null); +} + +/** 事项带不带「指定作品」(本地作品或 TMDB) */ +export function canAssignWork(d: DecisionLike): boolean { + return d.options.some((o) => requiresOf(o) !== null); +} + +export function OptionButton({ + d, + option, + variant = "outline", +}: { + d: DecisionLike; + option: DecisionOption; + variant?: "outline" | "default"; +}) { + const resolve = useResolveDecision(); + const uid = useId(); + const [note, setNote] = useState(""); + const requires = requiresOf(option); + const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie"); + const [tmdbId, setTmdbId] = useState(""); + const [entity, setEntity] = useState(null); + const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim()); + const identity = + requires === "tmdb" + ? tmdbOk + ? { tmdb: { type: tmdbType, id: tmdbId.trim() } } + : null + : requires === "mediaEntityId" + ? entity + ? { mediaEntityId: entity.id } + : null + : undefined; + return ( + + {option.label} + + } + title={option.label} + description={option.description ? `${option.description}。` : undefined} + confirmLabel={option.label} + confirmDisabled={identity === null} + onConfirm={() => + resolve + .mutateAsync({ + id: d.id, + optionKey: option.key, + note: note.trim() || undefined, + ...(identity ? { identity } : {}), + }) + .then( + () => toast.success(`已处理:${option.label}`), + (err) => { + toast.error(errorMessage(err, "处理失败")); + throw err; + }, + ) + } + > + {requires === "tmdb" ? ( +
+ + + + + setTmdbId(e.target.value)} + aria-invalid={tmdbId.trim() !== "" && !tmdbOk} + /> + +
+ ) : requires === "mediaEntityId" ? ( + + + + ) : null} + +