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:
1 parent
34e8751b45
commit
1023642ef9
30 files changed
+703
-271
No files matched your search
+20
-57
@@ -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) }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user