merge: fix/q-inline-handling
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # apps/server/src/modules/sources-identity/service/episode-resolution.ts
This commit is contained in:
commit
c465b533fd
80 files changed
+2766
-1016
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<boolean>`(subject_type = 'agent_run'
|
||||
or (kind = 'approval_required' and coalesce(subject_type, '') <> 'organization_record')
|
||||
or dedupe_key like 'browser.waiting:%')`;
|
||||
/** 系统健康:站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线(恢复后自动关闭) */
|
||||
const HEALTH_SQL = sql<boolean>`(kind in ('source_unreachable', 'downloader_offline', 'cache_volume_unknown', 'space_insufficient')
|
||||
or dedupe_key like 'library_root_offline:%')`;
|
||||
const OPEN_SQL = sql<boolean>`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<string>().as("n"))
|
||||
.where(OPEN_SQL)
|
||||
.where(where)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
return { total: Number(total?.n ?? 0), items: toApiList<Decision>(items) };
|
||||
};
|
||||
const [organize, approvals, health] = await Promise.all([
|
||||
db
|
||||
.selectFrom("organization_record")
|
||||
.select((eb) => eb.fn.countAll<string>().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,
|
||||
{
|
||||
|
||||
@@ -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<string>().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<Q extends SelectQueryBuilder<DB, "organization_record", a
|
||||
if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId);
|
||||
if (query.since) q = q.where("created_at", ">=", 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<DB, "organization_record">) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean>`(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<string, RecordAttention | null>();
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<DownloadTaskRow[]> {
|
||||
if (paths.length === 0) return [];
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, unknown>): string {
|
||||
const parsed = (ev.parsed ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>[]).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<string, unknown>;
|
||||
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<AutoRecoverEvidence> = {
|
||||
runId,
|
||||
outcome,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null> {
|
||||
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;都没有直接失败
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<AgentSpendKind>`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<DB>, since: Date): Promise<AgentSpendBucket> {
|
||||
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<DB>, now = new Date()): Promise<AgentSpend> {
|
||||
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 };
|
||||
}
|
||||
@@ -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", "媒体库", "作品"]),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
) =>
|
||||
(
|
||||
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 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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,
|
||||
});
|
||||
}
|
||||
@@ -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<RecordFilters, "status">) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 需要处理的记录:忽略(不再算需要处理,关联事项一并关闭) */
|
||||
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();
|
||||
|
||||
@@ -30,16 +30,12 @@ const RULES: Rule[] = [
|
||||
},
|
||||
{
|
||||
match: /^\/console\/organize/,
|
||||
goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"],
|
||||
goals: ["识别需要处理的整理记录里的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/files/,
|
||||
goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/inbox/,
|
||||
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/identity/,
|
||||
goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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<Decision, "subjectType" | "subjectId" | "agentRunId">): 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 (
|
||||
<section className="pt-1">
|
||||
<p className="flex items-center justify-between gap-2 px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
<span>{title}</span>
|
||||
<span className="tracking-normal tabular-nums">{total}</span>
|
||||
</p>
|
||||
<ul>
|
||||
{items.slice(0, RECENT).map((d) => (
|
||||
<li key={d.id}>
|
||||
<Link
|
||||
to={href(d)}
|
||||
onClick={onGo}
|
||||
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"
|
||||
>
|
||||
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
|
||||
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<DecisionKind, Decision[]>();
|
||||
for (const d of items) m.set(d.kind, [...(m.get(d.kind) ?? []), d]);
|
||||
const counts = new Map<DecisionKind, number>();
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="relative"
|
||||
aria-label={total ? `待处理 ${total} 项` : "待处理"}
|
||||
title={total ? `待处理 ${total} 项` : "待处理"}
|
||||
>
|
||||
<Button variant="outline" size="icon-sm" className="relative" aria-label={label} title={label}>
|
||||
<Bell className="h-4 w-4" />
|
||||
{total ? (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
|
||||
@@ -78,56 +96,45 @@ export function InboxBell() {
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
aria-label="待处理事项"
|
||||
aria-label="通知"
|
||||
className="pg-float-solid flex max-h-[var(--radix-popover-content-available-height)] w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden p-2"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2 pt-1 pb-2">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
待处理
|
||||
{total ? <span className="ml-1.5 font-normal text-muted-foreground tabular-nums">{total}</span> : null}
|
||||
</p>
|
||||
<Button variant="ghost" size="xs" onClick={() => go(inboxPath())}>
|
||||
查看全部
|
||||
<p className="text-sm font-semibold text-foreground">通知</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate(consolePath("status"));
|
||||
}}
|
||||
>
|
||||
运行状态
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
{list.isPending ? (
|
||||
{q.isPending ? (
|
||||
<LoadingState className="px-4 py-6" />
|
||||
) : list.isError ? (
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error="稍后再试" className="py-8" />
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState plain icon={CheckCheck} title="没有需要处理的事项" className="py-8" />
|
||||
) : total === 0 ? (
|
||||
<EmptyState plain icon={CheckCheck} title="没有等你批准的操作,系统运行正常" className="py-8" />
|
||||
) : (
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain pt-1">
|
||||
{groups.map((g) => (
|
||||
<section key={g.kind} className="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(inboxPath(g.kind))}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<span>{decisionKindLabel(g.kind)}</span>
|
||||
<span className="tracking-normal tabular-nums">{g.count}</span>
|
||||
</button>
|
||||
<ul>
|
||||
{g.items.map((d) => (
|
||||
<li key={d.id}>
|
||||
<Link
|
||||
to={inboxPath(d.kind)}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
|
||||
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<Group
|
||||
title="等你批准"
|
||||
total={approvals.total}
|
||||
items={approvals.items}
|
||||
href={approvalHref}
|
||||
onGo={() => setOpen(false)}
|
||||
/>
|
||||
<Group
|
||||
title="系统健康"
|
||||
total={health.total}
|
||||
items={health.items}
|
||||
href={() => HEALTH_HREF}
|
||||
onGo={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -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("每个导航项都有路由", () => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"/console/inbox": "待处理",
|
||||
"/console/preferences": "偏好标签",
|
||||
"/console/search": "搜索",
|
||||
};
|
||||
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<Decision, "kind" | "dedupeKey">): { 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<string, { unit: string; n: number }>();
|
||||
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 (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.map((r) => (
|
||||
<li key={r.key}>
|
||||
<Link
|
||||
to={r.to}
|
||||
className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm text-foreground hover:underline"
|
||||
>
|
||||
<span className="truncate">{r.text}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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<Decision, "id" | "title" | "options" | "evidence">;
|
||||
|
||||
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<PickedEntity | null>(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 (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant={variant} size="sm">
|
||||
{option.label}
|
||||
</Button>
|
||||
}
|
||||
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" ? (
|
||||
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
|
||||
<FormField label="类型" htmlFor={`${uid}-type`}>
|
||||
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
|
||||
<SelectTrigger id={`${uid}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="movie">电影</SelectItem>
|
||||
<SelectItem value="tv">剧集</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
|
||||
<Input
|
||||
id={`${uid}-tmdb`}
|
||||
inputMode="numeric"
|
||||
value={tmdbId}
|
||||
onChange={(e) => setTmdbId(e.target.value)}
|
||||
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
) : requires === "mediaEntityId" ? (
|
||||
<FormField label="本地作品" required>
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
|
||||
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
|
||||
</FormField>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 事项里能拿来搜作品的名字:代表资源标题 / 种子名 / 下载名 / 标题冒号后半段 */
|
||||
export function subjectName(d: DecisionLike): string | null {
|
||||
const e = d.evidence;
|
||||
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
|
||||
return (
|
||||
str(first?.title) ??
|
||||
str(e.torrentName) ??
|
||||
str(e.title) ??
|
||||
str(e.name) ??
|
||||
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
|
||||
);
|
||||
}
|
||||
|
||||
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
|
||||
export function queryOf(name: string | null): string {
|
||||
if (!name) return "";
|
||||
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
|
||||
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
|
||||
return (m?.[1]?.trim() || base)
|
||||
.replace(/[[\]()]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/** 「没识别出作品」的主操作:搜作品并指定(本地作品 / TMDB),后端随即按该作品重新整理 */
|
||||
export function AssignWorkButton({ d, variant = "default" }: { d: DecisionLike; variant?: "default" | "outline" }) {
|
||||
const resolve = useResolveDecision();
|
||||
const [open, setOpen] = useState(false);
|
||||
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
|
||||
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
|
||||
if (!local && !tmdb) return null;
|
||||
const name = subjectName(d);
|
||||
const assign = (w: AssignedWork) => {
|
||||
const option = "mediaEntityId" in w ? local : tmdb;
|
||||
if (!option) {
|
||||
toast.error("mediaEntityId" in w ? "这条记录只能按 TMDB 指定" : "这条记录只能选媒体库里的作品");
|
||||
return Promise.reject(new Error("unsupported"));
|
||||
}
|
||||
return resolve
|
||||
.mutateAsync({
|
||||
id: d.id,
|
||||
optionKey: option.key,
|
||||
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
|
||||
})
|
||||
.then(
|
||||
() => toast.success(`已指定为「${w.title}」,正在重新整理`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "指定失败"));
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant={variant} onClick={() => setOpen(true)}>
|
||||
<Search />
|
||||
指定作品
|
||||
</Button>
|
||||
<WorkAssignDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
subject={name}
|
||||
initialQuery={queryOf(name)}
|
||||
onAssign={assign}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ describe("下载面板展示换算(DL-2)", () => {
|
||||
expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时");
|
||||
});
|
||||
|
||||
it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有待处理事项时链到待处理", () => {
|
||||
it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有问题的链到整理记录就地处理", () => {
|
||||
expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null });
|
||||
expect(organizeState({ status: "importing", organize: null })?.label).toBe("整理中");
|
||||
expect(organizeState({ status: "imported", organize: null })?.label).toBe("已入库");
|
||||
@@ -48,7 +48,7 @@ describe("下载面板展示换算(DL-2)", () => {
|
||||
tone: "destructive",
|
||||
hint: "没识别出作品。指定作品后重新整理",
|
||||
});
|
||||
expect(failed?.to).toBe("/console/inbox?kind=identity_unresolved");
|
||||
expect(failed?.to).toBe(`/console/organize/${RID}`);
|
||||
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe(
|
||||
"磁盘已满",
|
||||
);
|
||||
@@ -58,7 +58,7 @@ describe("下载面板展示换算(DL-2)", () => {
|
||||
);
|
||||
expect(
|
||||
organizeState({ status: "completed", organize: rec("partial", { reason: "blocked:target_exists" }) })?.hint,
|
||||
).toBe("媒体库里已有同名文件。在待处理里选择替换或跳过");
|
||||
).toBe("媒体库里已有同名文件。在整理记录里选择替换、保留两者或跳过");
|
||||
// 不认识的码不显示
|
||||
expect(
|
||||
organizeState({ status: "completed", organize: rec("failed", { reason: "some_new_code" }) })?.hint,
|
||||
|
||||
@@ -28,7 +28,7 @@ export function formatEta(sec: number | null | undefined): string {
|
||||
export type OrganizeState = {
|
||||
label: string;
|
||||
tone: "success" | "warning" | "destructive" | "neutral";
|
||||
/** 点状态去哪:有未关闭的待处理事项去待处理,否则去整理记录;都没有为 null */
|
||||
/** 点状态去哪:整理记录详情(有问题的在那里就地处理);没整理过为 null */
|
||||
to: string | null;
|
||||
/** 失败原因(人话);原因是内部码时不直接给人看 */
|
||||
hint: string | null;
|
||||
@@ -48,11 +48,8 @@ export function organizeState(
|
||||
): OrganizeState | null {
|
||||
const rec = t.organize;
|
||||
if (t.managed === false && !rec && t.status !== "importing" && t.status !== "imported") return null;
|
||||
const to = rec?.decision
|
||||
? `${consolePath("inbox")}?kind=${rec.decision.kind}`
|
||||
: rec
|
||||
? consolePath("organize", rec.recordId)
|
||||
: null;
|
||||
// 有问题的整理在整理记录详情里就地处理(待处理中心已取消)
|
||||
const to = rec ? consolePath("organize", rec.recordId) : null;
|
||||
if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed"))
|
||||
return { label: "已入库", tone: "success", to, hint: null };
|
||||
if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) };
|
||||
|
||||
@@ -252,7 +252,7 @@ function OrganizeBadge({ t }: { t: DownloadBoardItem }) {
|
||||
<Link
|
||||
to={s.to}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t.organize?.decision ? "去待处理" : "查看整理记录"}
|
||||
title={t.organize?.decision ? "去整理记录处理" : "查看整理记录"}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{badge}
|
||||
|
||||
@@ -144,14 +144,14 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
||||
{importFailed ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||
入库失败:{reasonLine(importFailed.reason)}。
|
||||
<Link to="/console/inbox" className="ml-1 underline underline-offset-4">
|
||||
去待处理
|
||||
<Link to={`/console/organize/${importFailed.id}`} className="ml-1 underline underline-offset-4">
|
||||
去整理记录处理
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{task.noProgressSince ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。
|
||||
自 {formatDateTime(task.noProgressSince)} 起无进展,不会自动换源;开了自动调查时智能体会在授权内尝试恢复。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DownloadBoardPlanItem } from "@umt/contracts";
|
||||
import { Check, Inbox, X } from "lucide-react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
|
||||
@@ -147,18 +147,7 @@ export function PlanDetail({ p }: { p: DownloadBoardPlanItem }) {
|
||||
</div>
|
||||
</div>
|
||||
{p.wait === "approval" ? (
|
||||
<p className="flex flex-wrap items-center gap-1 text-caption text-muted-foreground">
|
||||
这次下载要你批准后才提交,同一件事也在待处理里。
|
||||
{p.approval?.decisionId ? (
|
||||
<Link
|
||||
to={`${consolePath("inbox")}?kind=approval_required`}
|
||||
className="inline-flex items-center gap-0.5 underline underline-offset-4"
|
||||
>
|
||||
<Inbox className="size-3.5" />
|
||||
去待处理
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="text-caption text-muted-foreground">这次下载要你批准后才提交,铃铛里也有提醒。</p>
|
||||
) : hint ? (
|
||||
<p className="text-caption text-muted-foreground">{hint}。</p>
|
||||
) : null}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
|
||||
|
||||
// 待处理中心里的「集数待确认」(episode_mapping):来源季集、原因、建议规则与它的模拟结果;
|
||||
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果;
|
||||
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
|
||||
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
|
||||
// (服务端建规则前按当前资源再模拟一次)。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Decision } from "@umt/contracts";
|
||||
import { useDecisions } from "~/api/decisions.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { EpisodeDecision } from "./decision.tsx";
|
||||
|
||||
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
|
||||
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则仍可在这里一键确认。
|
||||
// 数据来自内部事项 episode_mapping(只给关注中的作品开,见 server sources-identity/service/watched.ts)。
|
||||
|
||||
const OPEN = ["open", "investigating", "awaiting_user"];
|
||||
|
||||
function entityOf(d: Decision): string | null {
|
||||
const m = d.evidence.mediaEntityId;
|
||||
return typeof m === "string" ? m : d.subjectType === "media_entity" ? d.subjectId : null;
|
||||
}
|
||||
|
||||
/** 某作品(可选按来源季)未关闭的集数待确认事项 */
|
||||
export function useEpisodeIssues(entityId: string | null | undefined, season?: number | null) {
|
||||
const q = useDecisions({ kind: "episode_mapping" }, !!entityId);
|
||||
const items = (q.data?.pages.flatMap((p) => p.items) ?? []).filter(
|
||||
(d) =>
|
||||
OPEN.includes(d.status) &&
|
||||
entityOf(d) === entityId &&
|
||||
(season == null || (d.evidence as { season?: unknown }).season === season),
|
||||
);
|
||||
return { ...q, items };
|
||||
}
|
||||
|
||||
/** 作品页集数映射分区:逐组写原因,建议规则 / 智能体建议可确认 */
|
||||
export function EpisodeIssuesList({ entityId }: { entityId: string }) {
|
||||
const { items } = useEpisodeIssues(entityId);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-caption font-medium text-foreground">换算不了的资源({items.length} 组)</p>
|
||||
<ul className="space-y-3">
|
||||
{items.map((d) => (
|
||||
<li key={d.id} className="rounded-lg border border-border/80 p-3">
|
||||
<EpisodeDecision d={d} open />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 订阅详情:缺集原因一句一组(只读,处理去作品页) */
|
||||
export function EpisodeIssueReasons({ entityId, season }: { entityId: string; season: number | null }) {
|
||||
const { items } = useEpisodeIssues(entityId, season);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<ul className="mt-2 space-y-1 text-meta text-muted-foreground">
|
||||
{items.map((d) => (
|
||||
<li key={d.id} className="break-words">
|
||||
{d.summary ? humanizeReason(d.summary) : d.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { EpisodeIssuesList } from "./issues.tsx";
|
||||
import {
|
||||
agentRuleInfo,
|
||||
disabledReasonText,
|
||||
@@ -413,7 +414,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,对不上的进待处理。
|
||||
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,换算不了的在这里列出原因。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -440,6 +441,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
) : null}
|
||||
|
||||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null}
|
||||
<EpisodeIssuesList entityId={entityId} />
|
||||
</Panel>
|
||||
<Panel
|
||||
className="mt-3"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEntityDetail } from "~/api/library.ts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { EpisodeIssueReasons } from "./issues.tsx";
|
||||
import { absoluteGroups, groupMethodsText, mappingHref } from "./model.ts";
|
||||
|
||||
// 订阅详情的集数映射摘要 + 订阅表单的绝对集号提示(批次 4)。数据来自模拟预览(不带草稿 = 当前规则)。
|
||||
@@ -54,6 +55,8 @@ export function SubscriptionMappingSummary({
|
||||
{pending ? (
|
||||
<div className="mt-2">
|
||||
<StatusBadge tone="warning">待确认 {pending} 条</StatusBadge>
|
||||
<p className="mt-1 text-meta text-muted-foreground">这些资源集数对不上,不会下载和整理。</p>
|
||||
<EpisodeIssueReasons entityId={entityId} season={season} />
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts";
|
||||
import { ArrowRight, BellOff, ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { BellOff, ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,25 +19,8 @@ import { EnrichSection } from "./identity-enrich.tsx";
|
||||
import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts";
|
||||
import { Chip, Tone } from "./parts.tsx";
|
||||
|
||||
// 待识别资源详情:候选证据 + 重新识别 + 手动指定。进了待处理的(与订阅相关的歧义 / 无结果)在待处理里选候选;
|
||||
// 没进待处理的(unmatched:未命中订阅,惰性识别没查 TMDB)在这里手动指定 TMDB ID / 本地作品,或忽略。
|
||||
|
||||
/** 与订阅 / 本地作品相关的歧义开"待确认身份"事项,相关却无结果开"待识别"事项(同来源同标题合并);unmatched 不进待处理 */
|
||||
export function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates" | "decisionId">): boolean {
|
||||
return item.status === "pending" && (item.decisionId !== null || hasCandidates(item));
|
||||
}
|
||||
|
||||
function hasCandidates(item: Pick<IdentityPoolItem, "reason" | "candidates">): boolean {
|
||||
return (item.reason === "ambiguous" || item.reason === "proposed") && item.candidates.length > 0;
|
||||
}
|
||||
|
||||
export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`;
|
||||
export const INBOX_UNRESOLVED = `${consolePath("inbox")}?kind=identity_unresolved`;
|
||||
|
||||
/** 条目对应的待处理筛选链接 */
|
||||
export function inboxLink(item: Pick<IdentityPoolItem, "reason" | "candidates">): string {
|
||||
return hasCandidates(item) ? INBOX_IDENTITY : INBOX_UNRESOLVED;
|
||||
}
|
||||
// 未识别资源详情:候选证据 + 重新识别 + 选候选 / 手动指定 / 忽略。
|
||||
// 2026-10-06 起待处理中心取消:搜索 / RSS 扫进来的未识别资源静默留在这里,不再提示;要处理的直接在这里选。
|
||||
|
||||
/** query 摘要:标题 / 年份 / 季集 */
|
||||
export function querySummary(q: Record<string, unknown>): string {
|
||||
@@ -51,7 +34,7 @@ export function querySummary(q: Record<string, unknown>): string {
|
||||
return parts.join(" · ") || "—";
|
||||
}
|
||||
|
||||
function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
|
||||
function CandidateRow({ c, index, onPick }: { c: PoolCandidate; index: number; onPick?: () => void }) {
|
||||
return (
|
||||
<li className="min-w-0 rounded-lg border border-border/80 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -67,6 +50,11 @@ function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<Chip>{EVIDENCE_LEVEL[c.level] ?? c.level}</Chip>
|
||||
{onPick ? (
|
||||
<Button variant="outline" size="xs" className="order-last ml-auto" onClick={onPick}>
|
||||
选这个
|
||||
</Button>
|
||||
) : null}
|
||||
{c.localEntityId ? (
|
||||
<Link
|
||||
to={consolePath("library", c.localEntityId)}
|
||||
@@ -185,8 +173,16 @@ function ManualAssign({ item }: { item: IdentityPoolItem }) {
|
||||
|
||||
export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) {
|
||||
const retry = useRetryPool();
|
||||
const assign = useAssignPool();
|
||||
if (!item) return null;
|
||||
const queued = inInbox(item);
|
||||
const pick = async (index: number) => {
|
||||
try {
|
||||
await assign.mutateAsync({ id: item.id, candidateIndex: index, lock: true });
|
||||
toast.success("已指定");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "指定失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
@@ -196,38 +192,23 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
title={item.release.title}
|
||||
description={item.release.subtitle ?? undefined}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={retry.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await retry.mutateAsync(item.id);
|
||||
toast.success("已重新识别");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
|
||||
重新识别
|
||||
</Button>
|
||||
{queued ? (
|
||||
<Button asChild>
|
||||
<Link to={inboxLink(item)}>
|
||||
去待处理
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={retry.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await retry.mutateAsync(item.id);
|
||||
toast.success("已重新识别");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
|
||||
重新识别
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{queued ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
已进待处理,在那里选择候选或忽略。
|
||||
</p>
|
||||
) : null}
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: <Tone v={POOL_STATUS[item.status]} /> },
|
||||
@@ -242,7 +223,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
]}
|
||||
/>
|
||||
|
||||
{item.status !== "resolved" && !queued ? <ManualAssign item={item} /> : null}
|
||||
{item.status !== "resolved" ? <ManualAssign item={item} /> : null}
|
||||
|
||||
{item.status === "pending" && item.reason !== "ambiguous" && item.reason !== "proposed" ? (
|
||||
<EnrichSection key={item.id} item={item} />
|
||||
@@ -252,7 +233,12 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
{item.candidates.length ? (
|
||||
<ul className="space-y-2">
|
||||
{item.candidates.map((c, i) => (
|
||||
<CandidateRow key={`${c.provider}:${c.entityType}:${c.externalId}`} c={c} index={i} />
|
||||
<CandidateRow
|
||||
key={`${c.provider}:${c.entityType}:${c.externalId}`}
|
||||
c={c}
|
||||
index={i}
|
||||
onPick={item.status === "pending" && !assign.isPending ? () => void pick(i) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
|
||||
@@ -5,14 +5,13 @@ import type {
|
||||
IdentityPoolItem,
|
||||
IdentityStatus,
|
||||
} from "@umt/contracts";
|
||||
import { ArrowRight, Fingerprint, Lock, SearchX } from "lucide-react";
|
||||
import { Fingerprint, Lock, SearchX } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
|
||||
import { inboxLink, inInbox, PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
|
||||
import { PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
|
||||
import {
|
||||
EVIDENCE_LEVEL,
|
||||
IDENTITY_STATUS,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
import { relTime, Tone } from "~/components/library/parts.tsx";
|
||||
|
||||
// 媒体概览的身份视图(原"身份与元数据"页,IA-3 并入 /console/library):待识别资源、外部身份。
|
||||
// 待识别资源只读;选候选、忽略在待处理里做(docs/design/console-ia.md §3.1 判定只在待处理)。
|
||||
// 未识别资源:点开详情选候选、手动指定或忽略(2026-10-06 起待处理中心取消,不再转去待处理)。
|
||||
|
||||
function useDebounced(v: string, ms = 250) {
|
||||
const [d, setD] = useState(v);
|
||||
@@ -69,16 +68,6 @@ export function PoolTab() {
|
||||
cell: (r) => (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Tone v={POOL_REASON[r.reason]} />
|
||||
{inInbox(r) ? (
|
||||
<Link
|
||||
to={inboxLink(r)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
已进待处理
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { PublicOrganizationRecord, RecordAttention } from "@umt/contracts";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { attentionLine, primaryAction, RecordAttentionActions } from "./record-attention.tsx";
|
||||
|
||||
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心):原因一句话 + 推荐动作,一键执行。
|
||||
|
||||
const D = "11111111-1111-4111-8111-111111111111";
|
||||
const R = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const render = (node: ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(QueryClientProvider, { client: new QueryClient() }, createElement(MemoryRouter, null, node)),
|
||||
);
|
||||
|
||||
const decision = (over: Partial<NonNullable<RecordAttention["decision"]>> = {}) => ({
|
||||
id: D,
|
||||
kind: "identity_ambiguous" as const,
|
||||
status: "open" as const,
|
||||
title: "没识别出作品:Foo.2019.1080p",
|
||||
summary: null,
|
||||
options: [
|
||||
{ key: "candidate_0", label: "Foo (2019)", input: { candidate: { mediaEntityId: R } } },
|
||||
{ key: "select_entity", label: "选择作品(本地)", input: { requires: "mediaEntityId" } },
|
||||
{ key: "select_tmdb", label: "选择作品(TMDB)", input: { requires: "tmdb" } },
|
||||
{ key: "provisional", label: "作为临时作品入库", input: {} },
|
||||
{ key: "dismiss", label: "忽略" },
|
||||
],
|
||||
agentRunId: null,
|
||||
evidence: { downloadTaskId: R, torrentName: "Foo.2019.1080p" },
|
||||
...over,
|
||||
});
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
|
||||
const rec = (attention: RecordAttention | null, over: Partial<Rec> = {}): Rec => ({
|
||||
id: R,
|
||||
action: "copy",
|
||||
status: "failed",
|
||||
reason: null,
|
||||
attention,
|
||||
...over,
|
||||
});
|
||||
const html = (r: Rec) => render(createElement(RecordAttentionActions, { r, onReorganize: () => {} }));
|
||||
|
||||
describe("整理记录行内处理", () => {
|
||||
it("没识别出作品:主操作指定作品,可让智能体识别、重新整理、忽略", () => {
|
||||
const r = rec({ topic: "identity", decision: decision(), agentIdentify: true });
|
||||
expect(primaryAction(r)).toBe("assign");
|
||||
expect(attentionLine(r)).toBe("没识别出作品。指定作品,或让智能体识别");
|
||||
const out = html(r);
|
||||
for (const t of ["指定作品", "让智能体识别", "重新整理", "忽略", "Foo (2019)"]) expect(out).toContain(t);
|
||||
// 「选择作品(本地 / TMDB)」并进「指定作品」,不单列;临时作品不在行内
|
||||
expect(out).not.toContain("选择作品(本地)");
|
||||
expect(out).not.toContain("作为临时作品入库");
|
||||
});
|
||||
|
||||
it("智能体识别中:显示进度与会话入口,不再给指定 / 识别按钮", () => {
|
||||
const r = rec({
|
||||
topic: "identity",
|
||||
decision: decision({ status: "investigating", agentRunId: D }),
|
||||
agentIdentify: true,
|
||||
});
|
||||
expect(attentionLine(r)).toContain("智能体正在识别作品");
|
||||
const out = html(r);
|
||||
expect(out).toContain("智能体识别中");
|
||||
expect(out).toContain(`/console/agent/${D}`);
|
||||
expect(out).not.toContain("让智能体识别");
|
||||
expect(out).not.toContain("指定作品");
|
||||
});
|
||||
|
||||
it("目标已存在:按事项选项给替换 / 保留两者 / 跳过", () => {
|
||||
const r = rec({
|
||||
topic: "conflict",
|
||||
decision: decision({
|
||||
kind: "path_conflict",
|
||||
summary: "媒体库里已有同名文件",
|
||||
options: [
|
||||
{ key: "replace", label: "替换" },
|
||||
{ key: "keep_both", label: "保留两者" },
|
||||
{ key: "skip", label: "跳过" },
|
||||
],
|
||||
}),
|
||||
agentIdentify: false,
|
||||
});
|
||||
expect(primaryAction(r)).toBe("options");
|
||||
expect(attentionLine(r)).toBe("媒体库里已有同名文件");
|
||||
const out = html(r);
|
||||
for (const t of ["替换", "保留两者", "跳过", "忽略"]) expect(out).toContain(t);
|
||||
expect(out).not.toContain("让智能体识别");
|
||||
});
|
||||
|
||||
it("其它失败:重新整理为主操作", () => {
|
||||
const r = rec({ topic: "other", decision: null, agentIdentify: false });
|
||||
expect(primaryAction(r)).toBe("reorganize");
|
||||
const out = html(r);
|
||||
expect(out).toContain("重新整理");
|
||||
expect(out).toContain("忽略");
|
||||
});
|
||||
|
||||
it("不需要处理的记录不渲染", () => {
|
||||
expect(html(rec(null, { status: "succeeded" }))).toBe("");
|
||||
expect(attentionLine(rec(null))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { PublicOrganizationRecord } from "@umt/contracts";
|
||||
import { BellOff, Bot, ChevronRight, LoaderCircle, Wand2 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useAgentIdentifyRecord, useDismissRecordAttention } from "~/api/records.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import {
|
||||
AssignWorkButton,
|
||||
canAssignWork,
|
||||
OptionButton,
|
||||
plainOptions,
|
||||
} from "~/components/decisions/decision-actions.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { canReorganize } from "./record-parts.ts";
|
||||
|
||||
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心后,整理侧的问题在这里就地处理):
|
||||
// 没识别出作品 → 指定作品(主操作)/ 让智能体识别 / 忽略;识别中显示进度与会话入口;
|
||||
// 目标已存在 / 路径冲突 → 事项给的选项(替换 / 保留两者 / 跳过);
|
||||
// 其余失败 / 部分完成 → 重新整理 / 忽略。执行都走已有端点(/decisions/{id}/resolve、整理向导、忽略)。
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
|
||||
|
||||
/** 一句话说清这条记录卡在哪(事项摘要优先,其次记录原因) */
|
||||
export function attentionLine(r: Rec): string | null {
|
||||
const a = r.attention;
|
||||
if (!a) return null;
|
||||
if (a.decision?.status === "investigating") return "智能体正在识别作品,认出来会自动重新整理";
|
||||
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
|
||||
return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
|
||||
}
|
||||
|
||||
/** 推荐的主动作(给列表文字提示与测试用):identity → 指定作品;conflict → 事项选项;other → 重新整理 */
|
||||
export function primaryAction(r: Rec): "assign" | "options" | "reorganize" | null {
|
||||
const a = r.attention;
|
||||
if (!a) return null;
|
||||
if (a.topic === "identity" && a.decision && canAssignWork(a.decision)) return "assign";
|
||||
if (a.topic === "conflict" && a.decision && plainOptions(a.decision).length) return "options";
|
||||
return canReorganize(r) ? "reorganize" : null;
|
||||
}
|
||||
|
||||
export function RecordAttentionActions({
|
||||
r,
|
||||
onReorganize,
|
||||
compact = false,
|
||||
}: {
|
||||
r: Rec;
|
||||
onReorganize: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const identify = useAgentIdentifyRecord();
|
||||
const dismiss = useDismissRecordAttention();
|
||||
const a = r.attention;
|
||||
if (!a) return null;
|
||||
const d = a.decision;
|
||||
const investigating = d?.status === "investigating";
|
||||
const primary = primaryAction(r);
|
||||
return (
|
||||
// 行点击会进详情;按钮区不冒泡
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,不是交互元素
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上
|
||||
<div className="flex flex-wrap items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{investigating && d?.agentRunId ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={consolePath("agent", d.agentRunId)}>
|
||||
<LoaderCircle className="animate-spin" />
|
||||
智能体识别中
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{primary === "assign" && d && !investigating ? <AssignWorkButton d={d} /> : null}
|
||||
{primary === "options" && d
|
||||
? plainOptions(d).map((o, i) => (
|
||||
<OptionButton key={o.key} d={d} option={o} variant={i === 0 ? "default" : "outline"} />
|
||||
))
|
||||
: null}
|
||||
{a.topic === "identity" && d && !investigating
|
||||
? plainOptions(d)
|
||||
.filter((o) => !compact && o.key !== "provisional")
|
||||
.slice(0, 2)
|
||||
.map((o) => <OptionButton key={o.key} d={d} option={o} />)
|
||||
: null}
|
||||
{canReorganize(r) && !investigating && (!compact || primary === "reorganize") ? (
|
||||
<Button size="sm" variant={primary === "reorganize" ? "default" : "outline"} onClick={onReorganize}>
|
||||
<Wand2 />
|
||||
重新整理
|
||||
</Button>
|
||||
) : null}
|
||||
{a.agentIdentify && !investigating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={identify.isPending}
|
||||
onClick={() =>
|
||||
identify.mutateAsync(r.id).then(
|
||||
(x) => toast.success(x.queued ? "已让智能体识别,认出来会自动重新整理" : "智能体已经在识别了"),
|
||||
(err) => toast.error(errorMessage(err, "操作失败")),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Bot />
|
||||
让智能体识别
|
||||
</Button>
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<BellOff />
|
||||
忽略
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条记录的问题?"
|
||||
description="不再算需要处理,文件和记录都不动;之后可以随时重新整理。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={() =>
|
||||
dismiss.mutateAsync(r.id).then(
|
||||
() => toast.success("已忽略"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "操作失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { AgentSpendSummary } from "~/components/status/attention-cards.tsx";
|
||||
import { kToTokens, tokensToK } from "~/lib/tokens.ts";
|
||||
import { AgentToolsBlock } from "./agent-tools.tsx";
|
||||
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
|
||||
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
|
||||
import type { FieldSpec, FieldValue } from "./resource-section.tsx";
|
||||
import type { FieldSpec } from "./resource-section.tsx";
|
||||
import { num, str } from "./resource-section.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 预算(budget.agent* 与单次上限)+ 自动调查 + 网页搜索 + 高级
|
||||
|
||||
/** 可清空的数字:空串 → null */
|
||||
const nullNum = (v: FieldValue | undefined) => (str(v) === "" ? null : Number(str(v)));
|
||||
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 用量与限制(单次防跑飞上限 + 花费统计)+ 自动调查 + 网页搜索 + 高级
|
||||
|
||||
const RUNTIME_CARD: CardSpec = {
|
||||
anchor: "runtime",
|
||||
@@ -50,51 +48,45 @@ const RUNTIME_CARD: CardSpec = {
|
||||
}),
|
||||
};
|
||||
|
||||
// 2026-10-06:不设每日次数 / 每日花费 / 单次花费 / 单次 token 上限(旧值兼容读取、不生效),
|
||||
// 只保留单次工具调用次数与时长两个防跑飞限制;花费在这里与运行状态里可见
|
||||
const BUDGET_CARD: CardSpec = {
|
||||
anchor: "budget",
|
||||
title: "预算",
|
||||
description: "超出每日预算的运行停在「超出预算」,等你放行。",
|
||||
title: "用量与限制",
|
||||
description: "不限次数和花费。单次运行只限制工具调用次数和时长,防止跑飞。",
|
||||
fields: [
|
||||
{ name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" },
|
||||
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" },
|
||||
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" },
|
||||
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
|
||||
{
|
||||
name: "agent.maxTokensPerRun",
|
||||
label: "单次 token 上限",
|
||||
kind: "tokens",
|
||||
tokens: { min: 1 },
|
||||
hint: "单位 K,留空不限",
|
||||
},
|
||||
{ name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay),
|
||||
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
|
||||
"agent.maxToolCalls": n(s.agent.maxToolCalls),
|
||||
"agent.maxDurationSec": n(s.agent.maxDurationSec),
|
||||
"agent.maxTokensPerRun": tokensToK(s.agent.maxTokensPerRun),
|
||||
"agent.maxCostUsdPerRun": n(s.agent.maxCostUsdPerRun),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
budget: {
|
||||
agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]),
|
||||
agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]),
|
||||
},
|
||||
agent: {
|
||||
maxToolCalls: num(v["agent.maxToolCalls"]),
|
||||
maxDurationSec: num(v["agent.maxDurationSec"]),
|
||||
maxTokensPerRun: kToTokens(str(v["agent.maxTokensPerRun"])) ?? null,
|
||||
maxCostUsdPerRun: nullNum(v["agent.maxCostUsdPerRun"]),
|
||||
},
|
||||
}),
|
||||
extra: () => (
|
||||
<div className="border-t border-border/60 pt-4">
|
||||
<AgentSpendSummary />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const INVESTIGATE_CARD: CardSpec = {
|
||||
anchor: "investigate",
|
||||
title: "自动调查",
|
||||
description: "待处理事项(没识别出作品、下载失败等)创建时,自动发起一次有界调查。",
|
||||
description: "下载完识别不到作品时叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
|
||||
fields: [
|
||||
{
|
||||
name: "agent.autoIdentifyOrganize",
|
||||
label: "整理识别不到作品时叫智能体识别",
|
||||
kind: "switch",
|
||||
wide: true,
|
||||
hint: "认出来自动重新整理;认不出的留在整理记录「需要处理」里",
|
||||
},
|
||||
{ name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true },
|
||||
{
|
||||
name: "agent.autoInvestigateMode",
|
||||
@@ -131,6 +123,7 @@ const INVESTIGATE_CARD: CardSpec = {
|
||||
},
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"agent.autoIdentifyOrganize": s.agent.autoIdentifyOrganize,
|
||||
"agent.autoInvestigate": s.agent.autoInvestigate,
|
||||
"agent.autoInvestigateMode": s.agent.autoInvestigateMode,
|
||||
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
|
||||
@@ -139,6 +132,7 @@ const INVESTIGATE_CARD: CardSpec = {
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
agent: {
|
||||
autoIdentifyOrganize: v["agent.autoIdentifyOrganize"] === true,
|
||||
autoInvestigate: v["agent.autoInvestigate"] === true,
|
||||
autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate",
|
||||
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
|
||||
|
||||
@@ -172,7 +172,7 @@ function FailureRow({ result, onUseCookie }: { result: SiteLoginResult; onUseCoo
|
||||
) : null}
|
||||
{result.decisionId ? (
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
<Link to={consolePath("inbox")}>查看待处理</Link>
|
||||
<Link to={`${consolePath("status")}#attention`}>查看运行状态</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -317,7 +317,7 @@ export function loginFailureText(
|
||||
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
|
||||
case "challenge":
|
||||
return {
|
||||
text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在待处理里提醒你",
|
||||
text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在铃铛里提醒你",
|
||||
suggestCookie: false,
|
||||
};
|
||||
case "bad_credentials":
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AgentSpendBucket, AgentSpendKind } from "@umt/contracts";
|
||||
import { BellRing, Bot } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useAgentSpend, useAttention } from "~/api/attention.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { settingsPath } from "~/components/console/nav.ts";
|
||||
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
|
||||
// 运行状态页(2026-10-06):「需要关注」汇总(整理需要处理、等你批准、系统健康,系统健康逐条列出、恢复后自动消失)
|
||||
// 与智能体用量(今天 / 最近 7 天按会话类型;不设上限,只展示)。
|
||||
|
||||
function Slab({
|
||||
id,
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
icon: typeof Bot;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="pg-slab min-w-0 scroll-mt-20 rounded-xl p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttentionCard() {
|
||||
const q = useAttention();
|
||||
const rows = attentionRows(q.data);
|
||||
const health = q.data?.health.items ?? [];
|
||||
return (
|
||||
<Slab id="attention" title="需要关注" icon={BellRing}>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
没有需要处理的整理记录,也没有等你批准的操作;站点、下载器和下载目录都正常。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<AttentionList rows={rows} />
|
||||
{health.length ? (
|
||||
<ul className="mt-3 space-y-2 border-t border-border/60 pt-3">
|
||||
{health.map((d) => (
|
||||
<li key={d.id} className="min-w-0 text-caption">
|
||||
<p className="font-medium break-words text-foreground">{d.title}</p>
|
||||
{d.summary ? (
|
||||
<p className="break-words text-meta text-muted-foreground">{humanizeReason(d.summary)}</p>
|
||||
) : null}
|
||||
<p className="text-meta text-muted-foreground">{formatRelative(d.createdAt)}起,恢复后自动消失</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Slab>
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<AgentSpendKind, string> = {
|
||||
chat: "对话",
|
||||
patrol: "巡检",
|
||||
investigate: "后台调查",
|
||||
organize_identify: "整理识别",
|
||||
subagent: "子会话",
|
||||
};
|
||||
|
||||
const usd = (n: number) => `$${n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2)}`;
|
||||
|
||||
function SpendBlock({ label, b }: { label: string; b: AgentSpendBucket }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-meta text-muted-foreground">{label}</p>
|
||||
<p className="text-title-sm font-semibold text-foreground tabular-nums">
|
||||
{usd(b.costUsd)}
|
||||
<span className="ml-1.5 text-caption font-normal text-muted-foreground">{b.runs} 次</span>
|
||||
</p>
|
||||
{b.byKind.length ? (
|
||||
<ul className="mt-1 space-y-0.5 text-meta text-muted-foreground tabular-nums">
|
||||
{b.byKind.map((k) => (
|
||||
<li key={k.kind}>
|
||||
{KIND_LABEL[k.kind]} {k.runs} 次 · {usd(k.costUsd)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 智能体用量(设置页「用量与限制」也用) */
|
||||
export function AgentSpendSummary() {
|
||||
const q = useAgentSpend();
|
||||
if (q.isPending) return <LoadingState rows={2} />;
|
||||
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (!q.data.last7d.runs) return <p className="text-caption text-muted-foreground">最近 7 天智能体没有运行。</p>;
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<SpendBlock label="今天" b={q.data.today} />
|
||||
<SpendBlock label="最近 7 天" b={q.data.last7d} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSpendCard() {
|
||||
return (
|
||||
<Slab title="智能体用量" icon={Bot}>
|
||||
<AgentSpendSummary />
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
不设次数和花费上限,只限制单次的工具调用次数与时长。
|
||||
<Link to={settingsPath("agent", "budget")} className="ml-1 underline-offset-4 hover:underline">
|
||||
调整
|
||||
</Link>
|
||||
</p>
|
||||
</Slab>
|
||||
);
|
||||
}
|
||||
@@ -124,8 +124,9 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
|
||||
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
|
||||
case "site":
|
||||
return { to: `/console/sites/${id}`, label: "站点" };
|
||||
// 事项是内部机制(2026-10-06 待处理中心取消),不单独链出
|
||||
case "decision":
|
||||
return { to: consolePath("inbox"), label: "待处理" };
|
||||
return null;
|
||||
case "media":
|
||||
return { to: `/console/library/${id}`, label: "作品" };
|
||||
default:
|
||||
|
||||
@@ -16,8 +16,8 @@ import type { ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useAttention } from "~/api/attention.ts";
|
||||
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
||||
import { useDecisions } from "~/api/decisions.ts";
|
||||
import { useEngineRunsPage } from "~/api/engine.ts";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { useSystemHealth } from "~/api/system.ts";
|
||||
@@ -25,17 +25,18 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
|
||||
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 首页概览:自动化、存储与下载器状态,以及当前工作、待处理事项和最近入库。
|
||||
// 首页概览:自动化、存储与下载器状态,以及当前工作、要我处理的汇总计数(整理需要处理 / 等你批准 / 系统健康)和最近入库。
|
||||
|
||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||
const DASHBOARD_EVENTS = [
|
||||
@@ -257,33 +258,18 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 要我处理什么:计数 + 前 3 条 ───
|
||||
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
const list = useDecisions({});
|
||||
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
||||
// ─── 要我处理什么:汇总计数(整理需要处理、等你批准、系统健康),点进去到对应页面 ───
|
||||
function NeedsMe() {
|
||||
const q = useAttention();
|
||||
const rows = attentionRows(q.data);
|
||||
return (
|
||||
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||
{n.total === 0 ? (
|
||||
<Section title="要我处理" icon={AlertTriangle}>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
||||
{n.total}
|
||||
<span className="ml-1.5 text-caption font-normal text-muted-foreground">
|
||||
项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}
|
||||
</span>
|
||||
</p>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{top.map((d) => (
|
||||
<Row key={d.id}>
|
||||
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{d.title}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
|
||||
</Link>
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
<AttentionList rows={rows} />
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
@@ -350,7 +336,7 @@ export default function Home() {
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Busy w={q.data.currentWork} />
|
||||
<NeedsMe n={q.data.needsAttention} />
|
||||
<NeedsMe />
|
||||
<RecentImports d={q.data} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,640 +1,9 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
|
||||
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw, Search } from "lucide-react";
|
||||
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { EpisodeDecision } from "~/components/episode-mapping/decision.tsx";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx";
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.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";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览;
|
||||
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
|
||||
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并,
|
||||
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
|
||||
// 2026-10-06:待处理中心取消,问题回到发生处(整理记录「需要处理」、下载面板、运行状态、订阅详情);
|
||||
// 旧链接 /console/inbox?kind= 按问题类型跳到对应页面(redirects.ts inboxRedirect)
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
|
||||
const STATUS_FILTER = [
|
||||
{ value: "resolved", label: "已处理" },
|
||||
{ value: "dismissed", label: "已忽略" },
|
||||
{ value: "expired", label: "已过期" },
|
||||
];
|
||||
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
|
||||
const SEV_FILTER = [{ value: "all", label: "含仅信息" }];
|
||||
/** 缺省展开的组数;其余折叠 */
|
||||
const OPEN_GROUPS = 3;
|
||||
|
||||
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
|
||||
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
|
||||
/** 身份类合并事项的来源分组(后端 evidence.group) */
|
||||
function sourceGroupOf(d: Decision): { siteId: string; siteName: string | null } | null {
|
||||
const g = d.evidence.group as { siteId?: unknown; siteName?: unknown } | undefined;
|
||||
return g && typeof g.siteId === "string" ? { siteId: g.siteId, siteName: str(g.siteName) } : null;
|
||||
}
|
||||
|
||||
/** 合并事项的组内资源 */
|
||||
function GroupReleases({ d }: { d: Decision }) {
|
||||
const list = Array.isArray(d.evidence.releases)
|
||||
? (d.evidence.releases as { sourceReleaseId?: string; title?: string }[])
|
||||
: [];
|
||||
const count = typeof d.evidence.count === "number" ? d.evidence.count : list.length;
|
||||
const [open, setOpen] = useState(false);
|
||||
if (count <= 1) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{open ? "收起" : "展开"}组内 {count} 条资源
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-1.5 space-y-1 border-l border-border/60 pl-3">
|
||||
{list.map((r) => (
|
||||
<li key={r.sourceReleaseId} className="font-mono text-meta break-all text-muted-foreground">
|
||||
{r.title}
|
||||
</li>
|
||||
))}
|
||||
{count > list.length ? (
|
||||
<li className="text-meta text-muted-foreground">
|
||||
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function entityOf(d: Decision): string | null {
|
||||
const m = d.evidence.mediaEntityId;
|
||||
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
|
||||
return d.subjectType === "media_entity" ? d.subjectId : null;
|
||||
}
|
||||
|
||||
const IMPACT_KEY: Record<string, string> = {
|
||||
slots: "收录版本",
|
||||
units: "季与集",
|
||||
files: "文件",
|
||||
releases: "资源",
|
||||
tasks: "任务",
|
||||
entities: "作品",
|
||||
};
|
||||
|
||||
function describe(v: unknown): ReactNode {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
if (Array.isArray(v)) return v.map((x) => (typeof x === "object" ? JSON.stringify(x) : String(x))).join("、");
|
||||
return Object.entries(v as Record<string, unknown>)
|
||||
.map(
|
||||
([k, x]) =>
|
||||
`${IMPACT_KEY[k] ?? k} ${Array.isArray(x) ? x.join("、") : typeof x === "object" ? JSON.stringify(x) : String(x)}`,
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function Evidence({ d }: { d: Decision }) {
|
||||
const e = d.evidence;
|
||||
const suggestion = str(e.suggestion);
|
||||
const impact = describe(e.impact);
|
||||
const preview = describe(e.preview);
|
||||
const checked = Array.isArray(e.checked) ? (e.checked as unknown[]).map(String) : [];
|
||||
const rows: [string, ReactNode][] = [];
|
||||
if (suggestion) rows.push(["建议", suggestion]);
|
||||
if (impact) rows.push(["影响范围", impact]);
|
||||
if (preview) rows.push(["预览", preview]);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.length ? (
|
||||
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted-foreground">{k}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{checked.length ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-meta text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
智能体已检查
|
||||
</span>
|
||||
{checked.map((c) => (
|
||||
<Chip key={c}>{c}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "事项 ID", value: d.id },
|
||||
{ label: "类型", value: d.kind },
|
||||
]}
|
||||
>
|
||||
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
|
||||
</TechInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 身份类手动指定选项需要的输入(后端在 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;
|
||||
}
|
||||
|
||||
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
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<PickedEntity | null>(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 (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
{option.label}
|
||||
</Button>
|
||||
}
|
||||
title={option.label}
|
||||
description={
|
||||
<>
|
||||
{option.description ? `${option.description}。` : ""}
|
||||
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
|
||||
</>
|
||||
}
|
||||
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" ? (
|
||||
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
|
||||
<FormField label="类型" htmlFor={`${uid}-type`}>
|
||||
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
|
||||
<SelectTrigger id={`${uid}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="movie">电影</SelectItem>
|
||||
<SelectItem value="tv">剧集</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
|
||||
<Input
|
||||
id={`${uid}-tmdb`}
|
||||
inputMode="numeric"
|
||||
value={tmdbId}
|
||||
onChange={(e) => setTmdbId(e.target.value)}
|
||||
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
) : requires === "mediaEntityId" ? (
|
||||
<FormField label="本地作品" required>
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
|
||||
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
|
||||
</FormField>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */
|
||||
function subjectName(d: Decision): string | null {
|
||||
const e = d.evidence;
|
||||
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
|
||||
return (
|
||||
str(first?.title) ??
|
||||
str(e.title) ??
|
||||
str(e.name) ??
|
||||
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
|
||||
);
|
||||
}
|
||||
|
||||
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
|
||||
function queryOf(name: string | null): string {
|
||||
if (!name) return "";
|
||||
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
|
||||
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
|
||||
return (m?.[1]?.trim() || base)
|
||||
.replace(/[[\]()]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
const IDENTITY_KINDS: DecisionKind[] = ["identity_ambiguous", "identity_unresolved"];
|
||||
|
||||
/** 「没识别出作品」的主操作:搜作品并指定。资源类事项走 resolve(本地作品 / TMDB),整理类事项打开重新整理 */
|
||||
function AssignWorkButton({ d }: { d: Decision }) {
|
||||
const resolve = useResolveDecision();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [wizard, setWizard] = useState(false);
|
||||
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
|
||||
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
|
||||
const recordId = d.subjectType === "organization_record" ? d.subjectId : null;
|
||||
if (!local && !tmdb && !recordId) return null;
|
||||
const name = subjectName(d);
|
||||
const assign = (w: AssignedWork) => {
|
||||
const option = "mediaEntityId" in w ? local : tmdb;
|
||||
if (!option) {
|
||||
toast.error("mediaEntityId" in w ? "这条事项只能按 TMDB 指定" : "这条事项只能选媒体库里的作品");
|
||||
return Promise.reject(new Error("unsupported"));
|
||||
}
|
||||
return resolve
|
||||
.mutateAsync({
|
||||
id: d.id,
|
||||
optionKey: option.key,
|
||||
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
|
||||
})
|
||||
.then(
|
||||
() => toast.success(`已指定为「${w.title}」`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "指定失败"));
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => (recordId && !local && !tmdb ? setWizard(true) : setOpen(true))}>
|
||||
<Search />
|
||||
指定作品
|
||||
</Button>
|
||||
<WorkAssignDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
subject={name}
|
||||
initialQuery={queryOf(name)}
|
||||
onAssign={assign}
|
||||
/>
|
||||
{wizard && recordId ? (
|
||||
<OrganizeWizard source={{ kind: "record", recordId }} onClose={() => setWizard(false)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DismissButton({ d }: { d: Decision }) {
|
||||
const resolve = useResolveDecision();
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<BellOff />
|
||||
忽略
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条事项?"
|
||||
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
|
||||
() => toast.success("已忽略"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "操作失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DecisionCard({ d }: { d: Decision }) {
|
||||
const open = OPEN.includes(d.status);
|
||||
const identity = IDENTITY_KINDS.includes(d.kind);
|
||||
// 集数待确认:证据与「确认 / 修改 / 忽略」由 EpisodeDecision 渲染
|
||||
const episode = d.kind === "episode_mapping";
|
||||
// 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」
|
||||
const options = d.options.filter(
|
||||
(o) =>
|
||||
o.key !== "dismiss" && !(identity && (o.input?.requires === "tmdb" || o.input?.requires === "mediaEntityId")),
|
||||
);
|
||||
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
|
||||
return (
|
||||
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{d.severity === "critical" ? <Tone v={SEVERITY[d.severity]} /> : null}
|
||||
{d.status === "investigating" ? <Tone v={DECISION_STATUS[d.status]} /> : null}
|
||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
|
||||
{relTime(d.createdAt)}
|
||||
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
|
||||
{d.summary ? (
|
||||
<p className="mt-1 text-caption break-words text-muted-foreground">{humanizeReason(d.summary)}</p>
|
||||
) : null}
|
||||
{episode ? null : <GroupReleases d={d} />}
|
||||
{episode ? (
|
||||
<div className="mt-3">
|
||||
<EpisodeDecision d={d} open={open} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<Evidence d={d} />
|
||||
</div>
|
||||
{open && !episode ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{identity ? <AssignWorkButton d={d} /> : null}
|
||||
{options.map((o) => (
|
||||
<OptionButton key={o.key} d={d} option={o} />
|
||||
))}
|
||||
<DismissButton d={d} />
|
||||
{d.agentRunId ? (
|
||||
<Link
|
||||
to={consolePath("agent", d.agentRunId)}
|
||||
className="ml-auto inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
智能体调查记录
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : open ? null : (
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
{d.resolvedBy ? `${actorText(d.resolvedBy)} ` : ""}于 {formatDateTime(d.resolvedAt)}
|
||||
{resolution?.optionKey
|
||||
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
|
||||
: " 关闭"}
|
||||
{resolution?.note ? `:${resolution.note}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type Group = {
|
||||
key: string;
|
||||
entityId: string | null;
|
||||
source: { siteId: string; siteName: string | null } | null;
|
||||
kind: DecisionKind;
|
||||
items: Decision[];
|
||||
};
|
||||
|
||||
export default function InboxPage() {
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const status = (sp.get("status") ?? "") as DecisionStatus | "";
|
||||
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
|
||||
const sev = sp.get("sev") ?? "";
|
||||
const minSeverity = sev === "all" ? undefined : "warning";
|
||||
const set = (k: string, v: string) =>
|
||||
setSp(
|
||||
(prev) => {
|
||||
const n = new URLSearchParams(prev);
|
||||
if (v) n.set(k, v);
|
||||
else n.delete(k);
|
||||
return n;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const summary = useDecisionSummary(status || undefined, minSeverity);
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
const paged = useListPagination(rows, {
|
||||
hasMore: list.hasNextPage,
|
||||
onLoadMore: () => list.fetchNextPage(),
|
||||
loadingMore: list.isFetchingNextPage,
|
||||
error: list.error,
|
||||
resetKey: JSON.stringify({ status, kind, minSeverity }),
|
||||
});
|
||||
|
||||
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
|
||||
const qc = useQueryClient();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useSse(["decision.created", "decision.updated"], () => {
|
||||
if (timer.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
}, 500);
|
||||
});
|
||||
const live = useSseStatus();
|
||||
|
||||
const titles = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const g of summary.data?.groups ?? [])
|
||||
if (g.mediaEntityId && g.mediaEntityTitle) m.set(g.mediaEntityId, g.mediaEntityTitle);
|
||||
return m;
|
||||
}, [summary.data]);
|
||||
|
||||
const byKind = useMemo(() => {
|
||||
const m = new Map<DecisionKind, { count: number; sev: keyof typeof SEV_RANK }>();
|
||||
for (const g of summary.data?.groups ?? []) {
|
||||
const cur = m.get(g.kind);
|
||||
m.set(g.kind, {
|
||||
count: (cur?.count ?? 0) + g.count,
|
||||
sev: cur && SEV_RANK[cur.sev] > SEV_RANK[g.maxSeverity] ? cur.sev : g.maxSeverity,
|
||||
});
|
||||
}
|
||||
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
|
||||
}, [summary.data]);
|
||||
|
||||
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, Group>();
|
||||
for (const d of paged.items ?? []) {
|
||||
const entityId = entityOf(d);
|
||||
const source = entityId ? null : sourceGroupOf(d);
|
||||
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
|
||||
const g = m.get(key) ?? { key, entityId, source, kind: d.kind, items: [] };
|
||||
g.items.push(d);
|
||||
m.set(key, g);
|
||||
}
|
||||
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
|
||||
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
|
||||
}, [paged.items]);
|
||||
|
||||
const isOpenView = !status;
|
||||
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
|
||||
const [toggled, setToggled] = useState<Record<string, boolean>>({});
|
||||
const isExpanded = (key: string, index: number) => toggled[key] ?? index < OPEN_GROUPS;
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="待处理"
|
||||
description="需要你决定的事"
|
||||
actions={
|
||||
<>
|
||||
{live === "reconnecting" ? (
|
||||
<span className="hidden items-center gap-1.5 text-meta text-warning sm:inline-flex">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
重连中
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void qc.invalidateQueries({ queryKey: decisionKeys.all })}
|
||||
>
|
||||
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{summary.isError ? (
|
||||
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
|
||||
) : (
|
||||
<fieldset className="mb-4 flex min-w-0 flex-wrap items-center gap-1.5 border-0 p-0" aria-label="按问题类型筛选">
|
||||
<Button variant={kind ? "ghost" : "secondary"} size="sm" onClick={() => set("kind", "")} aria-pressed={!kind}>
|
||||
<Inbox />
|
||||
{isOpenView ? "全部" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
||||
<span className="tabular-nums">{summary.isPending ? "…" : (summary.data?.total ?? 0)}</span>
|
||||
</Button>
|
||||
{byKind.map(([k, v]) => (
|
||||
<Button
|
||||
key={k}
|
||||
variant={kind === k ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={kind === k}
|
||||
onClick={() => set("kind", kind === k ? "" : k)}
|
||||
className={v.sev === "critical" ? "text-destructive" : undefined}
|
||||
>
|
||||
{DECISION_KIND[k]}
|
||||
<span className="tabular-nums">{v.count}</span>
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={(v) => set("status", v)}
|
||||
options={STATUS_FILTER}
|
||||
allLabel="未关闭"
|
||||
/>
|
||||
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
|
||||
<FilterSelect label="级别" value={sev} onChange={(v) => set("sev", v)} options={SEV_FILTER} allLabel="需处理" />
|
||||
</FilterBar>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={4} />
|
||||
</div>
|
||||
) : list.isError && !rows ? (
|
||||
<ErrorState error={list.error} onRetry={() => list.refetch()} />
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CheckCheck}
|
||||
title={isOpenView ? "没有需要你处理的事项" : "没有符合条件的事项"}
|
||||
description={isOpenView ? "新事项会实时出现在这里。" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((g, index) => {
|
||||
const title = g.entityId ? titles.get(g.entityId) : null;
|
||||
const expanded = isExpanded(g.key, index);
|
||||
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
|
||||
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
|
||||
"info",
|
||||
);
|
||||
return (
|
||||
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
<header
|
||||
className={`flex flex-wrap items-center gap-2 px-4 py-3 sm:px-5 ${expanded ? "border-b border-border/60" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToggled((t) => ({ ...t, [g.key]: !expanded }))}
|
||||
className="inline-flex cursor-pointer items-center text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "折叠" : "展开"}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
|
||||
{g.entityId ? (
|
||||
<Link
|
||||
to={consolePath("library", g.entityId)}
|
||||
className="min-w-0 truncate text-body font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{title ?? "关联作品"}
|
||||
</Link>
|
||||
) : g.source ? (
|
||||
<span className="min-w-0 truncate text-body font-semibold text-foreground">
|
||||
来源「{g.source.siteName ?? "未知站点"}」
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body font-semibold text-muted-foreground">
|
||||
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
||||
</header>
|
||||
{expanded ? g.items.map((d) => <DecisionCard key={d.id} d={d} />) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{rows?.length || list.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
</>
|
||||
);
|
||||
export default function LegacyRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts";
|
||||
import { Download, Inbox, Library as LibraryIcon, RotateCw } from "lucide-react";
|
||||
import { Download, Library as LibraryIcon, RotateCw } from "lucide-react";
|
||||
import { useDeferredValue } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useLibraryList } from "~/api/library.ts";
|
||||
@@ -9,7 +9,6 @@ import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
|
||||
import { INBOX_IDENTITY } from "~/components/library/identity-pool-sheet.tsx";
|
||||
import { ExternalTab, PoolTab } from "~/components/library/identity-views.tsx";
|
||||
import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
|
||||
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
@@ -60,19 +59,7 @@ export default function LibraryPage() {
|
||||
const view = viewOf(sp);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="媒体库"
|
||||
actions={
|
||||
view === "unidentified" ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={INBOX_IDENTITY}>
|
||||
<Inbox />
|
||||
确认身份
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<PageHeader title="媒体库" />
|
||||
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="works">作品</TabsTrigger>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
||||
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
||||
import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx";
|
||||
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
|
||||
import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts";
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -219,6 +220,12 @@ export default function OrganizeRecordPage() {
|
||||
{reasonLine(r.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
{r.attention ? (
|
||||
<div className="space-y-2 rounded-lg border border-warning/40 px-3 py-2">
|
||||
<p className="text-caption text-foreground">{attentionLine(r) ?? "这条记录需要处理"}</p>
|
||||
<RecordAttentionActions r={r} onReorganize={() => setReorganize(true)} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<PathBlock label="整理前" p={r.source} />
|
||||
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Checkbox } from "~/components/files/file-list.tsx";
|
||||
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
||||
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
||||
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
|
||||
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
|
||||
import {
|
||||
canReorganize,
|
||||
episodeRange,
|
||||
@@ -62,6 +63,8 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;
|
||||
// 缺省按作品分组(一季十集收成一组,组头写季集范围、条数和非成功状态;只有一条的不分组),切换后本浏览器记住;
|
||||
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
|
||||
// 需要处理(2026-10-06 去掉待处理中心):下载完识别不到、目标冲突、被排除、卡住 / 失败的记录在这里就地处理,
|
||||
// 「需要处理」筛选只看这些;行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行。
|
||||
|
||||
type Rec = PublicOrganizationRecord;
|
||||
|
||||
@@ -80,11 +83,11 @@ function sinceOf(t: string): string | undefined {
|
||||
|
||||
const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt;
|
||||
|
||||
/** 状态:成功不加徽章,其余徽章 + 一句原因 */
|
||||
function StatusCell({ r }: { r: Rec }) {
|
||||
/** 状态:成功不加徽章,其余徽章 + 一句原因;需要处理的再给行内动作 */
|
||||
function StatusCell({ r, onReorganize }: { r: Rec; onReorganize?: () => void }) {
|
||||
const progress = recordFileProgress(r);
|
||||
if (r.status === "succeeded" && !r.reason) return <span className="text-muted-foreground">成功</span>;
|
||||
const line = reasonLine(r.reason);
|
||||
if (r.status === "succeeded" && !r.reason && !r.attention) return <span className="text-muted-foreground">成功</span>;
|
||||
const line = attentionLine(r) ?? reasonLine(r.reason);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
@@ -95,10 +98,15 @@ function StatusCell({ r }: { r: Rec }) {
|
||||
</span>
|
||||
) : null}
|
||||
{line ? (
|
||||
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}>
|
||||
<span className="line-clamp-2 max-w-64 text-meta text-muted-foreground" title={line}>
|
||||
{line}
|
||||
</span>
|
||||
) : null}
|
||||
{r.attention && onReorganize ? (
|
||||
<div className="mt-1">
|
||||
<RecordAttentionActions r={r} onReorganize={onReorganize} compact />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -271,14 +279,18 @@ function StatsLine({
|
||||
filters,
|
||||
status,
|
||||
onStatus,
|
||||
attention,
|
||||
onAttention,
|
||||
}: {
|
||||
filters: Omit<RecordFilters, "status">;
|
||||
status: string;
|
||||
onStatus: (s: string) => void;
|
||||
attention: boolean;
|
||||
onAttention: (on: boolean) => void;
|
||||
}) {
|
||||
const stats = useRecordStats(filters);
|
||||
const stats = useRecordStats({ ...filters, attention: undefined });
|
||||
const s = stats.data;
|
||||
if (!s || s.total === 0) return null;
|
||||
if (!s || (s.total === 0 && !s.attention)) return null;
|
||||
const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [
|
||||
{ key: "succeeded", label: "成功", n: s.succeeded },
|
||||
{ key: "failed", label: "失败", n: s.failed, tone: "text-destructive" },
|
||||
@@ -298,6 +310,19 @@ function StatsLine({
|
||||
>
|
||||
共 {s.total} 条
|
||||
</button>
|
||||
{s.attention || attention ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={attention}
|
||||
onClick={() => onAttention(!attention)}
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 text-warning hover:text-foreground",
|
||||
attention && "bg-foreground/[0.06]",
|
||||
)}
|
||||
>
|
||||
需要处理 {s.attention}
|
||||
</button>
|
||||
) : null}
|
||||
{parts
|
||||
.filter((p) => p.n > 0)
|
||||
.map((p) => (
|
||||
@@ -321,11 +346,21 @@ function StatsLine({
|
||||
|
||||
export default function OrganizePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const f = useQueryState(["status", "trigger", "kind", "time", "q", "group"] as const);
|
||||
const f = useQueryState(["status", "trigger", "kind", "time", "q", "group", "attention"] as const);
|
||||
const { set: setFilter, values: v } = f;
|
||||
const [qInput, setQInput] = useState(v.q);
|
||||
const entityId = params.get("mediaEntityId") ?? undefined;
|
||||
const legacyRecords = !!(entityId || params.get("ids") || v.q || v.trigger || v.kind || v.time || v.status);
|
||||
const legacyRecords = !!(
|
||||
entityId ||
|
||||
params.get("ids") ||
|
||||
v.q ||
|
||||
v.trigger ||
|
||||
v.kind ||
|
||||
v.time ||
|
||||
v.status ||
|
||||
v.attention
|
||||
);
|
||||
const attention = v.attention === "1";
|
||||
const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue";
|
||||
const setTab = (next: string) =>
|
||||
setParams(
|
||||
@@ -351,7 +386,8 @@ export default function OrganizePage() {
|
||||
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
|
||||
const since = useMemo(() => sinceOf(v.time), [v.time]);
|
||||
const base: Omit<RecordFilters, "status"> = {
|
||||
phase: v.status === "running" || v.status === "queued" ? undefined : "history",
|
||||
phase: attention || v.status === "running" || v.status === "queued" ? undefined : "history",
|
||||
attention: attention || undefined,
|
||||
organizeOnly: true,
|
||||
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
|
||||
kind: (v.kind || undefined) as RecordFilters["kind"],
|
||||
@@ -376,7 +412,7 @@ export default function OrganizePage() {
|
||||
resetKey: JSON.stringify({ ...base, status, ids }),
|
||||
});
|
||||
const rows = paged.items;
|
||||
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q) || !!entityId || pinned;
|
||||
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q || attention) || !!entityId || pinned;
|
||||
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
@@ -477,9 +513,9 @@ export default function OrganizePage() {
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
{r.status !== "succeeded" || r.reason ? (
|
||||
{r.status !== "succeeded" || r.reason || r.attention ? (
|
||||
<div className="mt-1 sm:hidden">
|
||||
<StatusCell r={r} />
|
||||
<StatusCell r={r} onReorganize={() => reorganize([r])} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -500,7 +536,12 @@ export default function OrganizePage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", hideBelow: "sm", cell: (r) => <StatusCell r={r} /> },
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <StatusCell r={r} onReorganize={() => reorganize([r])} />,
|
||||
},
|
||||
{
|
||||
key: "trigger",
|
||||
header: "来源",
|
||||
@@ -639,7 +680,15 @@ export default function OrganizePage() {
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
|
||||
{pinned ? null : (
|
||||
<StatsLine
|
||||
filters={base}
|
||||
status={v.status}
|
||||
onStatus={(s) => setFilter("status", s)}
|
||||
attention={attention}
|
||||
onAttention={(on) => setFilter("attention", on ? "1" : "")}
|
||||
/>
|
||||
)}
|
||||
{selecting ? (
|
||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
||||
@@ -692,8 +741,12 @@ export default function OrganizePage() {
|
||||
}
|
||||
empty={{
|
||||
icon: FileStack,
|
||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: attention
|
||||
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
|
||||
: filtered
|
||||
? undefined
|
||||
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -39,6 +39,9 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
return "/console/sites";
|
||||
}
|
||||
|
||||
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
|
||||
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
|
||||
|
||||
if (!tab) return null;
|
||||
if (path === "/console/tasks") {
|
||||
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
|
||||
@@ -53,6 +56,30 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 整理侧的问题(下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败)→ 整理记录「需要处理」 */
|
||||
export const ORGANIZE_ATTENTION_PATH = "/console/organize?tab=records&attention=1";
|
||||
|
||||
const INBOX_KIND: Record<string, string> = {
|
||||
identity_ambiguous: ORGANIZE_ATTENTION_PATH,
|
||||
identity_unresolved: ORGANIZE_ATTENTION_PATH,
|
||||
path_conflict: ORGANIZE_ATTENTION_PATH,
|
||||
other: ORGANIZE_ATTENTION_PATH,
|
||||
approval_required: "/console/agent",
|
||||
download_failed: "/console/downloads",
|
||||
download_stalled: "/console/downloads",
|
||||
episode_mapping: "/console/subscriptions",
|
||||
metadata_conflict: "/console/library",
|
||||
source_unreachable: "/console/status#attention",
|
||||
downloader_offline: "/console/status#attention",
|
||||
cache_volume_unknown: "/console/status#attention",
|
||||
space_insufficient: "/console/status#attention",
|
||||
};
|
||||
|
||||
/** 旧待处理链接(?kind=)落到哪;没带类型的去整理记录「需要处理」(真正要人处理的大多在那里) */
|
||||
export function inboxRedirect(kind: string | null): string {
|
||||
return (kind && INBOX_KIND[kind]) || ORGANIZE_ATTENTION_PATH;
|
||||
}
|
||||
|
||||
/** 旧链接样例 → 应落到的新地址(测试与冒烟共用) */
|
||||
export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
|
||||
@@ -79,6 +106,11 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
|
||||
{ from: "/console/inbox", to: "/console/organize?tab=records&attention=1" },
|
||||
{ from: "/console/inbox?kind=identity_unresolved", to: "/console/organize?tab=records&attention=1" },
|
||||
{ from: "/console/inbox?kind=approval_required", to: "/console/agent" },
|
||||
{ from: "/console/inbox?kind=downloader_offline", to: "/console/status#attention" },
|
||||
{ from: "/console/inbox?kind=episode_mapping", to: "/console/subscriptions" },
|
||||
];
|
||||
|
||||
/** 路由模块的 clientLoader:命中旧链接就跳转 */
|
||||
|
||||
@@ -22,6 +22,7 @@ import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.tsx";
|
||||
import { HistoryChart } from "~/components/status/history-chart.tsx";
|
||||
import { StorageSection } from "~/components/status/storage-section.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -33,6 +34,7 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。
|
||||
// 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。
|
||||
|
||||
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
|
||||
ok: { label: "正常", tone: "success" },
|
||||
@@ -511,6 +513,10 @@ export default function StatusPage() {
|
||||
) : d ? (
|
||||
<div className="space-y-4">
|
||||
<Banner d={d} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AttentionCard />
|
||||
<AgentSpendCard />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card title="数据库" icon={Database} level={d.db.level}>
|
||||
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
|
||||
|
||||
Reference in new issue
Block a user