feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:52:32 +08:00
1 parent 6280d52ec3
commit 7103bc8f7c
35 files changed
+6134 -45

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
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,
};
}