import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AgentRun, type AgentRunEvent, AgentRunEventRow, 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 同形;在这里宽松镜像一份, // 合同字段调整时页面仍能解析(新增字段忽略,缺失的可选字段按未知显示)。 export const AGENT_BASE = "/api/v1/agent/runs"; /** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */ export const AgentMode = z.enum(["execute", "plan_only"]); export type AgentMode = z.infer; export const AgentRunView = AgentRun.extend({ mode: AgentMode.optional(), dedupeKey: z.string().nullable().optional(), }); export type AgentRunView = z.infer; /** 服务端从业务表汇总的目标完成度(不采信模型文本) */ 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; export const RunMessage = z.object({ id: Id, createdAt: Timestamp, text: z.string(), status: z.string(), consumedAt: Timestamp.nullable().optional(), }); export type RunMessage = z.infer; 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; export type RunSnapshot = Omit; const RunPage = cursorPage(AgentRunView); export function agentMockEnabled(): boolean { if (import.meta.env.VITE_AGENT_MOCK === "1") return true; if (typeof window === "undefined") return false; const p = new URLSearchParams(window.location.search); try { if (p.get("mock") === "1") sessionStorage.setItem("umt-agent-mock", "1"); if (p.get("mock") === "0") sessionStorage.removeItem("umt-agent-mock"); return sessionStorage.getItem("umt-agent-mock") === "1"; } catch { return p.get("mock") === "1"; } } export const agentKeys = { all: ["agent"] as const, runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const, run: (id: string) => ["agent", "detail", id] as const, }; const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"]; export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s); export function useAgentRuns(status?: AgentRunStatus) { const mock = agentMockEnabled(); return useInfiniteQuery({ queryKey: [...agentKeys.runs(status), mock], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => { if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null }; return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal }); }, getNextPageParam: (last) => last.nextCursor ?? undefined, refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((r) => isRunActive(r.status))) ? 5 * TTL_SECOND : false, }); } /** 取一个足够大的 afterSeq,只拿 run / 追加消息 / 目标汇报,不拉事件(事件由 useAgentStream 增量读) */ const NO_EVENTS = 2 ** 31 - 1; export function useAgentRun(id: string | undefined) { const mock = agentMockEnabled(); return useQuery({ queryKey: [...agentKeys.run(id ?? ""), mock], enabled: !!id, queryFn: async ({ signal }): Promise => { if (mock) return mockDetail(id ?? ""); const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, { pathParams: { id: id ?? "" }, params: { afterSeq: NO_EVENTS, limit: 1 }, signal, }); return { run: d.run, messages: d.messages, report: d.report }; }, refetchInterval: (q) => (isRunActive(q.state.data?.run.status) ? 3 * TTL_SECOND : false), }); } /** 事件回放:after 为已拿到的最大 seq(-1 = 从头);按 nextAfterSeq 翻页直到最新 */ export async function fetchRunEvents(id: string, after: number, signal?: AbortSignal): Promise { if (agentMockEnabled()) return mockEventsAfter(id, after); const out: AgentRunEvent[] = []; let cursor = after; for (let page = 0; page < 20; page++) { const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, { pathParams: { id }, params: { afterSeq: cursor, limit: 500 }, signal, }); for (const row of d.events) { try { out.push(agentRunEventFromRow(row)); } catch { // 未知/不合法事件跳过,不让一条坏事件打断整段回放 } } if (d.nextAfterSeq === null || d.nextAfterSeq <= cursor) break; cursor = d.nextAfterSeq; } return out; } export function useCreateAgentRun() { const qc = useQueryClient(); 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)); }, onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }), }); } export function useAbortAgentRun() { const qc = useQueryClient(); return useMutation({ 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), ); }, onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }), }); } export function useAppendAgentMessage() { const qc = useQueryClient(); return useMutation({ 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), ); }, onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.id) }), }); } export const RUN_STATUS_LABEL: Record = { queued: "排队中", running: "运行中", waiting: "等待中", succeeded: "已完成", failed: "失败", cancelled: "已中止", budget_exceeded: "超出预算", }; export const RUN_STATUS_TONE: Record = { queued: "neutral", running: "neutral", waiting: "warning", succeeded: "success", failed: "destructive", cancelled: "neutral", budget_exceeded: "warning", }; export const REPORT_STATE: Record< GoalReport["state"], { label: string; tone: "success" | "warning" | "destructive" | "neutral" } > = { no_actions: { label: "没有产生业务动作", tone: "neutral" }, waiting: { label: "已提交,等待下载/入库", tone: "warning" }, completed: { label: "已核验入库", tone: "success" }, partial: { label: "部分完成", tone: "warning" }, failed: { label: "未达成", tone: "destructive" }, };