feat(web): W4-A wire console to real endpoints

- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local
  mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status;
  stream auto-follow only while the user stays near the bottom (abort stays reachable)
- settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript)
- download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of
  time-window engine run matching and path matching; new business-task tab with cancel/retry
- workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons
- preference tag revision history; subscription search by title/kind; identity/alias/subscription
  lists show entity titles; slot cards show current asset summary; policy preview sample titles
- DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary /
  OrganizePlanArtifactContent taken from contracts
- docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:32:11 +08:00
1 parent 34e8751b45
commit 1023642ef9
30 files changed
+703 -271

No files matched your search

+20 -57
View File
@@ -1,75 +1,38 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AgentGoalReport,
AgentRun,
AgentRunDetail,
type AgentRunEvent,
AgentRunEventRow,
type AgentRunMessage,
AgentRunMode,
type AgentRunStatus,
agentRunEventFromRow,
cursorPage,
Id,
NonNegInt,
Timestamp,
} from "@umt/contracts";
import { z } from "zod";
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort)。
// 端点未就绪时用 mock 开关(?mock=1 或 VITE_AGENT_MOCK=1)开发。
// 下面的 schema 与 contracts/agent.ts 的 AgentRunDetail / AgentGoalReport 同形;在这里宽松镜像一份,
// 合同字段调整时页面仍能解析(新增字段忽略,缺失的可选字段按未知显示)。
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort),
// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。
// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互。
export const AGENT_BASE = "/api/v1/agent/runs";
/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */
export const AgentMode = z.enum(["execute", "plan_only"]);
export type AgentMode = z.infer<typeof AgentMode>;
export const AgentRunView = AgentRun.extend({
mode: AgentMode.optional(),
dedupeKey: z.string().nullable().optional(),
});
export type AgentRunView = z.infer<typeof AgentRunView>;
export const AgentMode = AgentRunMode;
export type AgentMode = AgentRunMode;
export type AgentRunView = AgentRun;
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
export const GoalReport = z.object({
state: z.enum(["no_actions", "waiting", "completed", "partial", "failed"]),
summary: z.string(),
submitted: NonNegInt,
imported: NonNegInt,
inProgress: NonNegInt,
failed: NonNegInt,
plans: z.array(z.object({ planId: Id, status: z.string() }).passthrough()),
downloads: z.array(z.object({ downloadTaskId: Id, status: z.string(), name: z.string().nullable() }).passthrough()),
organization: z.array(
z.object({ recordId: Id, status: z.string(), targetPath: z.string().nullable() }).passthrough(),
),
modelText: z.string().nullable(),
generatedAt: Timestamp,
});
export type GoalReport = z.infer<typeof GoalReport>;
export const RunMessage = z.object({
id: Id,
createdAt: Timestamp,
text: z.string(),
status: z.string(),
consumedAt: Timestamp.nullable().optional(),
});
export type RunMessage = z.infer<typeof RunMessage>;
export const RunDetail = z.object({
run: AgentRunView,
events: z.array(AgentRunEventRow),
nextAfterSeq: z.number().int().nullable(),
messages: z.array(RunMessage),
report: GoalReport.nullable(),
});
export type RunDetail = z.infer<typeof RunDetail>;
export const GoalReport = AgentGoalReport;
export type GoalReport = AgentGoalReport;
export type RunMessage = AgentRunMessage;
export type RunDetail = AgentRunDetail;
export type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
const RunPage = cursorPage(AgentRunView);
const RunPage = cursorPage(AgentRun);
export function agentMockEnabled(): boolean {
if (import.meta.env.VITE_AGENT_MOCK === "1") return true;
@@ -118,7 +81,7 @@ export function useAgentRun(id: string | undefined) {
enabled: !!id,
queryFn: async ({ signal }): Promise<RunSnapshot> => {
if (mock) return mockDetail(id ?? "");
const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, {
const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, {
pathParams: { id: id ?? "" },
params: { afterSeq: NO_EVENTS, limit: 1 },
signal,
@@ -135,7 +98,7 @@ export async function fetchRunEvents(id: string, after: number, signal?: AbortSi
const out: AgentRunEvent[] = [];
let cursor = after;
for (let page = 0; page < 20; page++) {
const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, {
const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, {
pathParams: { id },
params: { afterSeq: cursor, limit: 500 },
signal,
@@ -158,7 +121,7 @@ export function useCreateAgentRun() {
return useMutation({
mutationFn: async (input: { goal: string; mode: AgentMode }) => {
if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode);
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRunView.parse(d));
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRun.parse(d));
},
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
});
@@ -170,7 +133,7 @@ export function useAbortAgentRun() {
mutationFn: async (id: string) => {
if (agentMockEnabled()) return mockDetail(id, { abort: true }).run;
return apiMutate(`${AGENT_BASE}/:id/abort`, { pathParams: { id }, body: { reason: "用户在控制台中止" } }).then(
(d) => AgentRunView.parse(d),
(d) => AgentRun.parse(d),
);
},
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
@@ -183,7 +146,7 @@ export function useAppendAgentMessage() {
mutationFn: async ({ id, text }: { id: string; text: string }) => {
if (agentMockEnabled()) return mockDetail(id, { append: text }).run;
return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id }, body: { text } }).then((d) =>
AgentRunView.parse(d),
AgentRun.parse(d),
);
},
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.id) }),
+10 -17
View File
@@ -1,6 +1,12 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cursorPage, Decision, DecisionKind, type DecisionResolveInput, type DecisionStatus, Id } from "@umt/contracts";
import { z } from "zod";
import {
cursorPage,
Decision,
type DecisionKind,
type DecisionResolveInput,
type DecisionStatus,
DecisionSummary,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
@@ -14,21 +20,8 @@ export const decisionKeys = {
export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind };
/** 服务端 /decisions/summary 的形状(路由内定义,未进 contracts) */
export const DecisionSummary = z.object({
total: z.number().int(),
groups: z.array(
z.object({
kind: DecisionKind,
mediaEntityId: Id.nullable(),
mediaEntityTitle: z.string().nullable(),
count: z.number().int(),
maxSeverity: z.enum(["info", "warning", "critical"]),
latestAt: z.string(),
}),
),
});
export type DecisionSummary = z.infer<typeof DecisionSummary>;
/** /decisions/summary(合同 DecisionSummary) */
export { DecisionSummary };
const Page = cursorPage(Decision);
+12 -10
View File
@@ -5,13 +5,14 @@ import {
type IdentityCandidateReason,
type IdentityCandidateStatus,
IdentityPoolItem,
IdentityRevision,
IdentityRevisionList,
type IdentityStatus,
type ReleaseIdentityAssign,
ReleaseIdentityResult,
TaskAccepted,
TitleAlias,
type TitleAliasCreate,
} from "@umt/contracts";
import { z } from "zod";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
@@ -32,8 +33,6 @@ export type AliasFilters = { mediaEntityId?: string; q?: string };
const PoolPage = cursorPage(IdentityPoolItem);
const IdentityPage = cursorPage(ExternalIdentity);
const AliasPage = cursorPage(TitleAlias);
/** 服务端返回 { items }(无 nextCursor);兼容数组与分页两种形状 */
const RevisionList = z.union([z.array(IdentityRevision), z.object({ items: z.array(IdentityRevision) })]);
export function useIdentityPool(filters: PoolFilters) {
return useInfiniteQuery({
@@ -61,10 +60,8 @@ export function useIdentityRevisions(id: string | undefined) {
return useQuery({
queryKey: identityKeys.revisions(id ?? ""),
enabled: !!id,
queryFn: async ({ signal }) => {
const r = await getParsed(RevisionList, `/external-identities/${id}/revisions`, undefined, signal);
return Array.isArray(r) ? r : r.items;
},
queryFn: async ({ signal }) =>
(await getParsed(IdentityRevisionList, `/external-identities/${id}/revisions`, undefined, signal)).items,
});
}
@@ -94,7 +91,9 @@ export function useAssignPool() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, ...body }: ReleaseIdentityAssign & { id: string }) =>
api.post<unknown>(`/identity-pool/${id}/assign`, body),
api
.post<unknown>(`/identity-pool/${id}/assign`, body)
.then((d) => parseOrThrow(ReleaseIdentityResult, d, "/identity-pool/assign")),
onSuccess: done,
});
}
@@ -111,7 +110,10 @@ export function useDismissPool() {
export function useRetryPool() {
const done = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.post<unknown>(`/identity-pool/${id}/retry`),
mutationFn: (id: string) =>
api
.post<unknown>(`/identity-pool/${id}/retry`)
.then((d) => parseOrThrow(TaskAccepted, d, "/identity-pool/retry")),
onSuccess: done,
});
}
+4 -12
View File
@@ -1,12 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
cursorPage,
PolicyPreviewSummary,
PolicyRevision,
type PolicyRevisionCreate,
type PolicyRevisionUpdate,
type PolicyStatus,
} from "@umt/contracts";
import { z } from "zod";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
@@ -28,17 +28,9 @@ export function usePolicyRevisions(filters: PolicyFilters = {}) {
});
}
/** 预览结果(服务端 previewPolicy 的形状;contracts 里是 JsonObject) */
export const PolicyPreview = z.object({
evaluatedAt: z.string(),
sampleSize: z.number(),
templates: z.number(),
matched: z.number(),
excluded: z.number(),
samples: z.array(z.object({ sourceReleaseId: z.string(), template: z.string(), decision: z.string() })),
valid: z.boolean(),
});
export type PolicyPreview = z.infer<typeof PolicyPreview>;
/** 预览结果(合同 PolicyPreviewSummary;存在 policy_revision.preview) */
export const PolicyPreview = PolicyPreviewSummary;
export type PolicyPreview = PolicyPreviewSummary;
const parse = (d: unknown) => parseOrThrow(PolicyRevision, d, "/policy-revisions");
+11
View File
@@ -6,6 +6,7 @@ import {
PreferenceSuppression,
PreferenceTag,
type PreferenceTagCreate,
PreferenceTagRevisionList,
type PreferenceTagUpdate,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
@@ -45,6 +46,16 @@ export function useSuppressions(active?: boolean) {
});
}
/** 修订历史(审计事件回放,新→旧) */
export function useTagRevisions(id: string | null | undefined) {
return useQuery({
queryKey: ["preferences", "revisions", id ?? ""],
enabled: !!id,
queryFn: async ({ signal }) =>
(await getParsed(PreferenceTagRevisionList, `/preference-tags/${id}/revisions`, undefined, signal)).items,
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => void qc.invalidateQueries({ queryKey: preferenceKeys.all });
+5 -41
View File
@@ -7,10 +7,10 @@ import {
type OrganizationStatus,
type OrganizationTrigger,
OrganizeAccepted,
OrganizePlanArtifactContent,
type OrganizeRequest,
PublicOrganizationRecord,
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
@@ -109,7 +109,7 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "Agent" };
/** dryRun 生成的整理计划冻结为 artifact(kind=plan);读取端点未提供时返回 not_found,由页面说明 */
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
export function useArtifact(id: string | null | undefined) {
return useQuery({
queryKey: ["artifact", id ?? ""],
@@ -119,45 +119,9 @@ export function useArtifact(id: string | null | undefined) {
});
}
/** artifact.content.plan.organize 的前端视图(与 domain OrganizePlan 同形,只取展示字段) */
export const OrganizePlanPreview = z.object({
request: z.record(z.string(), z.unknown()).optional(),
status: z.string().optional(),
reason: z.string().nullable().optional(),
plan: z
.object({
targetDirs: z.array(z.string()).optional(),
organize: z
.object({
operation: z.string(),
crossDevice: z.boolean(),
requiresApproval: z.boolean(),
files: z.array(
z.object({
src: z.string(),
dst: z.string(),
size: z.number(),
kind: z.string(),
action: z.string(),
skipReason: z.string().optional(),
}),
),
excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })),
conflicts: z.array(
z.object({
kind: z.string(),
blocking: z.boolean(),
src: z.string().optional(),
detail: z.string().optional(),
}),
),
})
.optional(),
})
.nullable()
.optional(),
});
export type OrganizePlanPreview = z.infer<typeof OrganizePlanPreview>;
/** dryRun 整理计划 artifact 的内容形状(合同:OrganizePlanArtifactContent) */
export const OrganizePlanPreview = OrganizePlanArtifactContent;
export type OrganizePlanPreview = OrganizePlanArtifactContent;
/** 整理目标库根 */
export function useLibraryRoots() {
+16 -5
View File
@@ -1,5 +1,10 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AcquisitionPlan, type ResolutionStatus, WorkbenchReleaseDetail, WorkbenchReleasePage } from "@umt/contracts";
import {
ManualAcquisitionResult,
type ResolutionStatus,
WorkbenchReleaseDetail,
WorkbenchReleasePage,
} from "@umt/contracts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
// 站点工作台整理视图(W2-B:/sites/{id}/releases、/sites/{id}/releases/{rid})
@@ -68,12 +73,18 @@ export function useRefreshReleaseDetail() {
}
/**
* 统一下载入口:与自动收录同一条提交流程(计划 → 去重 → 缓存硬阈值 → 实例选择 → 管理标签)。
* 后端对应端点 POST /acquisition-plans(sourceReleaseId + 可选 slotId);未提供时返回 not_found,页面给出说明。
* 统一下载入口 POST /acquisition-plans:与自动收录同一条流程(download.plan → download.submit:
* 硬条件/预算/缓存阈值/授权 → 预留 Slot → submitDownload)。未生成计划时 submitted=false 并给出逐个 Slot 的原因。
*/
export function useSubmitRelease() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { sourceReleaseId: string; slotId?: string; note?: string }) =>
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => AcquisitionPlan.parse(d)),
mutationFn: (body: { sourceReleaseId: string; slotId?: string; entityId?: string; note?: string }) =>
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)),
onSuccess: (r) => {
if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
void qc.invalidateQueries({ queryKey: ["inventory"] });
void qc.invalidateQueries({ queryKey: ["library"] });
},
});
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const slotKeys = {
list: (f: SlotFilters) => ["slots", "list", f] as const,
};
export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus };
export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus; subscriptionId?: string };
const Page = cursorPage(AcquisitionSlot);
+8 -1
View File
@@ -4,6 +4,7 @@ import {
MediaEntity,
Subscription,
type SubscriptionCreate,
type SubscriptionMediaKind,
type SubscriptionStatus,
type SubscriptionUpdate,
} from "@umt/contracts";
@@ -17,7 +18,13 @@ export const subscriptionKeys = {
list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const,
};
export type SubscriptionFilters = { status?: SubscriptionStatus; mediaEntityId?: string };
export type SubscriptionFilters = {
status?: SubscriptionStatus;
mediaEntityId?: string;
mediaKind?: SubscriptionMediaKind;
/** 标题 / 原名 / 别名 / 未建档检索词 */
q?: string;
};
const Page = cursorPage(Subscription);
+73 -79
View File
@@ -4,19 +4,21 @@ import {
cursorPage,
Decision,
type DownloadTask,
EngineRun,
EngineRunDetail,
type PublicOrganizationRecord,
PublicOrganizationRecord,
SourceRelease,
Task,
type TaskKind,
type TaskStatus,
} from "@umt/contracts";
import { PLAN_STATUS_LABEL } from "./downloads.ts";
import { apiParsed, cleanParams } from "./parse.ts";
import { useRecordsForEntity } from "./records.ts";
import { apiParsed } from "./parse.ts";
// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
// 关联键全部由服务端给出:plan →(/tasks?subjectId=planId)submitDownload → download_task
// →(/tasks?subjectId=downloadTaskId)verifyAndImport →(/organization-records?downloadTaskId=)整理记录 → decision/agent_run。
const EnginePage = cursorPage(EngineRun);
const TaskPage = cursorPage(Task);
const RecordPage = cursorPage(PublicOrganizationRecord);
const DecisionPage = cursorPage(Decision);
const RESOLUTION_LABEL = {
@@ -63,44 +65,55 @@ export function useSubjectDecisions(subjectIds: string[]) {
});
}
/**
* 引擎 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;
/** 业务任务:submitDownload 的 subject 是计划,verifyAndImport 的 subject 是下载任务(服务端写入 task 表) */
export function useTraceTasks(opts: { planId?: string | null; downloadTaskId?: string }) {
const ids = [opts.planId, opts.downloadTaskId].filter((x): x is string => !!x);
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 })),
),
queryKey: ["tasks", "trace", ids],
enabled: ids.length > 0,
queryFn: async ({ signal }): Promise<Task[]> => {
const pages = await Promise.all(
ids.map((subjectId) => apiParsed(TaskPage, "/api/v1/tasks", { params: { subjectId, limit: 50 }, signal })),
);
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)
);
});
return pages.flatMap((p) => p.items);
},
refetchInterval: (q) => (q.state.data?.some((t) => ACTIVE_TASK.includes(t.status)) ? 5_000 : false),
});
}
const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
/** 这个下载产生的整理记录(服务端按 download_task_id 关联) */
export function useRecordsForDownload(downloadTaskId: string | undefined) {
return useQuery({
queryKey: ["records", "download", downloadTaskId ?? ""],
enabled: !!downloadTaskId,
queryFn: ({ signal }) =>
apiParsed(RecordPage, "/api/v1/organization-records", {
params: { downloadTaskId: downloadTaskId ?? "", limit: 50 },
signal,
}),
});
}
const TASK_KIND_LABEL: Partial<Record<TaskKind, string>> = {
submitDownload: "提交下载",
verifyAndImport: "核验入库",
evaluateAcquisition: "评估候选",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待办",
};
const TASK_STATUS_TONE: Record<TaskStatus, TimelineTone> = {
pending: "neutral",
running: "neutral",
waiting: "warning",
succeeded: "success",
failed: "destructive",
cancelled: "neutral",
dead: "destructive",
};
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
queued: "neutral",
running: "neutral",
@@ -110,18 +123,6 @@ const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
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;
@@ -129,9 +130,9 @@ export function buildTimeline(input: {
plan?: AcquisitionPlan | null;
records?: PublicOrganizationRecord[];
decisions?: Decision[];
engineRuns?: EngineRunDetail[];
tasks?: Task[];
}): TimelineEvent[] {
const { task, release, plan, records = [], decisions = [], engineRuns = [] } = input;
const { task, release, plan, records = [], decisions = [], tasks = [] } = input;
const ev: TimelineEvent[] = [];
if (release) {
ev.push({
@@ -253,21 +254,20 @@ export function buildTimeline(input: {
href: `/console/agent/${d.agentRunId}`,
});
}
for (const r of engineRuns) {
for (const t of tasks) {
const err = t.lastError
? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError))
: null;
ev.push({
key: `eng-${r.id}`,
key: `task-${t.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",
at: t.startedAt ?? t.createdAt,
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${t.status}`,
detail:
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
tone: TASK_STATUS_TONE[t.status],
...(t.engineRunId ? { href: `/console/engine/${encodeURIComponent(t.engineRunId)}` } : {}),
ongoing: ACTIVE_TASK.includes(t.status),
});
}
return ev.sort((a, b) => a.at.localeCompare(b.at));
@@ -276,30 +276,24 @@ export function buildTimeline(input: {
/** 一站式取 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 records = useRecordsForDownload(task?.id);
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) : [];
const tasks = useTraceTasks({ planId: task?.acquisitionPlanId, downloadTaskId: task?.id });
return {
release,
records,
decisions,
engine,
tasks,
events: task
? buildTimeline({
task,
release: release.data,
plan,
records: matched,
records: records.data?.items ?? [],
decisions: decisions.data,
engineRuns: engine.data,
tasks: tasks.data,
})
: [],
loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading,
loading: release.isLoading || records.isLoading || decisions.isLoading || tasks.isLoading,
};
}