- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
type AcquisitionPlan,
|
|
cursorPage,
|
|
Decision,
|
|
type DownloadTask,
|
|
EngineRun,
|
|
EngineRunDetail,
|
|
type PublicOrganizationRecord,
|
|
SourceRelease,
|
|
} from "@umt/contracts";
|
|
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
|
import { apiParsed, cleanParams } from "./parse.ts";
|
|
import { useRecordsForEntity } from "./records.ts";
|
|
|
|
// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
|
// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。
|
|
|
|
const EnginePage = cursorPage(EngineRun);
|
|
const DecisionPage = cursorPage(Decision);
|
|
|
|
const RESOLUTION_LABEL = {
|
|
unresolved: "未识别",
|
|
resolved: "已识别",
|
|
ambiguous: "有歧义,待确认",
|
|
unidentifiable: "无法识别",
|
|
rejected: "已排除",
|
|
} as const;
|
|
|
|
/** 时间线阶段:observation → plan → download → organization → agent */
|
|
export type TimelineStage = "observation" | "plan" | "download" | "organization" | "agent" | "engine";
|
|
export type TimelineTone = "success" | "warning" | "destructive" | "neutral";
|
|
|
|
export type TimelineEvent = {
|
|
key: string;
|
|
stage: TimelineStage;
|
|
at: string;
|
|
title: string;
|
|
detail?: string | null;
|
|
tone: TimelineTone;
|
|
href?: string;
|
|
/** 进行中的阶段(不是终态) */
|
|
ongoing?: boolean;
|
|
};
|
|
|
|
export function useSourceRelease(id: string | null | undefined) {
|
|
return useQuery({
|
|
queryKey: ["source-release", id ?? ""],
|
|
enabled: !!id,
|
|
queryFn: ({ signal }) =>
|
|
apiParsed(SourceRelease, "/api/v1/source-releases/:id", { pathParams: { id: id ?? "" }, signal }),
|
|
});
|
|
}
|
|
|
|
/** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */
|
|
export function useSubjectDecisions(subjectIds: string[]) {
|
|
const ids = subjectIds.filter(Boolean);
|
|
return useQuery({
|
|
queryKey: ["decisions", "subjects", ids],
|
|
enabled: ids.length > 0,
|
|
queryFn: ({ signal }) => apiParsed(DecisionPage, "/api/v1/decisions", { params: { limit: 200 }, signal }),
|
|
select: (d) => d.items.filter((x) => x.subjectId && ids.includes(x.subjectId)),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 引擎 run 关联:submitDownload 的 input 带 acquisitionPlanId,verifyAndImport 的 input 带 downloadTaskId。
|
|
* 服务端没有"按业务对象查 run"的端点,这里在时间窗内取少量候选 run 读详情后按 input 精确匹配。
|
|
*/
|
|
export function useTraceEngineRuns(opts: { planId?: string | null; downloadTaskId?: string; since?: string }) {
|
|
const { planId, downloadTaskId, since } = opts;
|
|
return useQuery({
|
|
queryKey: ["engine", "trace", planId ?? "", downloadTaskId ?? "", since ?? ""],
|
|
enabled: !!since && (!!planId || !!downloadTaskId),
|
|
staleTime: 60_000,
|
|
queryFn: async ({ signal }) => {
|
|
const from = since ? new Date(new Date(since).getTime() - 10 * 60_000).toISOString() : undefined;
|
|
const lists = await Promise.all(
|
|
["submitDownload", "verifyAndImport"].map((workflowName) =>
|
|
apiParsed(EnginePage, "/api/v1/engine/runs", {
|
|
params: cleanParams({ workflowName, from, limit: 15 }),
|
|
signal,
|
|
}).catch(() => ({ items: [], nextCursor: null })),
|
|
),
|
|
);
|
|
const candidates = lists.flatMap((l) => l.items);
|
|
const details = await Promise.all(
|
|
candidates.map((r) =>
|
|
apiParsed(EngineRunDetail, "/api/v1/engine/runs/:id", { pathParams: { id: r.id }, signal }).catch(() => null),
|
|
),
|
|
);
|
|
return details.filter((d): d is EngineRunDetail => {
|
|
if (!d) return false;
|
|
const input = (d.input ?? {}) as Record<string, unknown>;
|
|
return (
|
|
(!!planId && input.acquisitionPlanId === planId) ||
|
|
(!!downloadTaskId && input.downloadTaskId === downloadTaskId)
|
|
);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
|
|
queued: "neutral",
|
|
running: "neutral",
|
|
succeeded: "success",
|
|
partial: "warning",
|
|
failed: "destructive",
|
|
cancelled: "neutral",
|
|
};
|
|
|
|
/** 整理记录与下载的关联:同实例 + 源路径落在下载内容路径下(合同里没有 downloadTaskId,只能按路径匹配) */
|
|
export function recordsForDownload(records: PublicOrganizationRecord[], task: DownloadTask) {
|
|
const base = (task.contentPath ?? task.savePath ?? "").replace(/\/+$/, "");
|
|
return records.filter(
|
|
(r) =>
|
|
r.source.downloaderInstanceId === task.instanceId &&
|
|
!!base &&
|
|
!!r.source.path &&
|
|
(r.source.path === base || r.source.path.startsWith(`${base}/`)),
|
|
);
|
|
}
|
|
|
|
/** 汇总一个下载 trace 的业务事件,按时间升序 */
|
|
export function buildTimeline(input: {
|
|
task: DownloadTask;
|
|
release?: SourceRelease | null;
|
|
plan?: AcquisitionPlan | null;
|
|
records?: PublicOrganizationRecord[];
|
|
decisions?: Decision[];
|
|
engineRuns?: EngineRunDetail[];
|
|
}): TimelineEvent[] {
|
|
const { task, release, plan, records = [], decisions = [], engineRuns = [] } = input;
|
|
const ev: TimelineEvent[] = [];
|
|
if (release) {
|
|
ev.push({
|
|
key: "obs",
|
|
stage: "observation",
|
|
at: release.firstSeenAt,
|
|
title: "首次发现资源",
|
|
detail: release.title,
|
|
tone: "neutral",
|
|
});
|
|
if (release.resolutionStatus !== "unresolved")
|
|
ev.push({
|
|
key: "resolve",
|
|
stage: "observation",
|
|
// 合同里没有识别完成时间,挂在首次发现之后,只表达结果
|
|
at: release.firstSeenAt,
|
|
title:
|
|
release.resolutionStatus === "resolved"
|
|
? "识别结果:已关联本地作品"
|
|
: `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`,
|
|
tone: release.resolutionStatus === "resolved" ? "success" : "warning",
|
|
});
|
|
}
|
|
if (plan) {
|
|
ev.push({
|
|
key: "plan",
|
|
stage: "plan",
|
|
at: plan.createdAt,
|
|
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
|
detail: plan.statusReason,
|
|
tone: "neutral",
|
|
});
|
|
if (plan.updatedAt !== plan.createdAt)
|
|
ev.push({
|
|
key: "plan-status",
|
|
stage: "plan",
|
|
at: plan.updatedAt,
|
|
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
|
detail: plan.statusReason,
|
|
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
|
});
|
|
}
|
|
ev.push({
|
|
key: "dl-submit",
|
|
stage: "download",
|
|
at: task.createdAt,
|
|
title: task.origin === "adopted" ? "接管已有下载" : "提交下载",
|
|
detail: task.name,
|
|
tone: "neutral",
|
|
});
|
|
if (task.pauseReason)
|
|
ev.push({
|
|
key: "dl-pause",
|
|
stage: "download",
|
|
at: task.updatedAt,
|
|
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,系统暂停" : `已暂停(${task.pauseReason})`,
|
|
tone: "warning",
|
|
ongoing: task.status === "paused",
|
|
});
|
|
if (task.noProgressSince)
|
|
ev.push({
|
|
key: "dl-stall",
|
|
stage: "download",
|
|
at: task.noProgressSince,
|
|
title: "无进展(只记录,不自动换源)",
|
|
tone: "warning",
|
|
ongoing: task.status === "stalled",
|
|
});
|
|
if (task.completedAt)
|
|
ev.push({ key: "dl-done", stage: "download", at: task.completedAt, title: "下载完成", tone: "success" });
|
|
else if (task.status === "downloading")
|
|
ev.push({
|
|
key: "dl-progress",
|
|
stage: "download",
|
|
at: task.lastProgressAt ?? task.updatedAt,
|
|
title: `下载中 ${(task.progress * 100).toFixed(1)}%`,
|
|
tone: "neutral",
|
|
ongoing: true,
|
|
});
|
|
if (task.lastError)
|
|
ev.push({
|
|
key: "dl-err",
|
|
stage: "download",
|
|
at: task.updatedAt,
|
|
title: "下载器报错",
|
|
detail: String((task.lastError as { message?: unknown }).message ?? JSON.stringify(task.lastError)),
|
|
tone: "destructive",
|
|
});
|
|
for (const r of records) {
|
|
const c = r.counts;
|
|
ev.push({
|
|
key: `rec-${r.id}`,
|
|
stage: "organization",
|
|
at: r.startedAt ?? r.createdAt,
|
|
title: `整理 ${r.action === "copy" ? "Copy" : r.action === "move" ? "Move" : r.action}:${r.status}`,
|
|
detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`,
|
|
tone: RECORD_TONE[r.status],
|
|
href: `/console/organize/${r.id}`,
|
|
ongoing: r.status === "running" || r.status === "queued",
|
|
});
|
|
}
|
|
for (const d of decisions) {
|
|
ev.push({
|
|
key: `dec-${d.id}`,
|
|
stage: "agent",
|
|
at: d.createdAt,
|
|
title: `待处理:${d.title}`,
|
|
detail: d.summary,
|
|
tone: d.severity === "critical" ? "destructive" : "warning",
|
|
href: "/console/inbox",
|
|
});
|
|
if (d.agentRunId)
|
|
ev.push({
|
|
key: `agent-${d.id}`,
|
|
stage: "agent",
|
|
at: d.updatedAt,
|
|
title: "Agent 已介入调查",
|
|
tone: "neutral",
|
|
href: `/console/agent/${d.agentRunId}`,
|
|
});
|
|
}
|
|
for (const r of engineRuns) {
|
|
ev.push({
|
|
key: `eng-${r.id}`,
|
|
stage: "engine",
|
|
at: r.startedAt ?? r.createdAt,
|
|
title: `引擎 ${r.workflowName}:${r.status}`,
|
|
detail: `${r.stepAttempts.length} 个步骤尝试 · 第 ${r.attempts} 次`,
|
|
tone:
|
|
r.status === "failed"
|
|
? "destructive"
|
|
: r.status === "completed" || r.status === "succeeded"
|
|
? "success"
|
|
: "neutral",
|
|
href: `/console/engine/${encodeURIComponent(r.id)}`,
|
|
ongoing: r.status === "running" || r.status === "pending" || r.status === "sleeping",
|
|
});
|
|
}
|
|
return ev.sort((a, b) => a.at.localeCompare(b.at));
|
|
}
|
|
|
|
/** 一站式取 trace 数据 */
|
|
export function useDownloadTrace(task: DownloadTask | undefined, plan: AcquisitionPlan | null | undefined) {
|
|
const release = useSourceRelease(task?.sourceReleaseId ?? plan?.sourceReleaseId);
|
|
const entityId = release.data?.mediaEntityId ?? null;
|
|
const records = useRecordsForEntity(entityId);
|
|
const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]);
|
|
const engine = useTraceEngineRuns({
|
|
planId: task?.acquisitionPlanId,
|
|
downloadTaskId: task?.id,
|
|
since: plan?.createdAt ?? task?.createdAt,
|
|
});
|
|
const matched = task ? recordsForDownload(records.data?.items ?? [], task) : [];
|
|
return {
|
|
release,
|
|
records,
|
|
decisions,
|
|
engine,
|
|
events: task
|
|
? buildTimeline({
|
|
task,
|
|
release: release.data,
|
|
plan,
|
|
records: matched,
|
|
decisions: decisions.data,
|
|
engineRuns: engine.data,
|
|
})
|
|
: [],
|
|
loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading,
|
|
};
|
|
}
|