import { keepPreviousData, useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient, } from "@tanstack/react-query"; import { AgentGoalReport, AgentRun, AgentRunDetail, type AgentRunEvent, type AgentRunMessage, AgentRunMode, type AgentRunStatus, Approval, type ApprovalStatus, AuditEvent, agentRunEventFromRow, cursorPage, } from "@umt/contracts"; import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts"; import { TTL_SECOND } from "~/lib/api.ts"; import { useSession } from "./auth.ts"; import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; import { usePiUpstreams } from "./pi.ts"; // Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort), // 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。 // ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互; // 仅开发构建或后端默认上游是 fake 时生效,生产构建连真实模型时忽略(见 agentMockAllowed)。 export const AGENT_BASE = "/api/v1/agent/runs"; /** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */ export const AgentMode = AgentRunMode; export type AgentMode = AgentRunMode; export type AgentRunView = AgentRun; /** 服务端从业务表汇总的目标完成度(不采信模型文本) */ export const GoalReport = AgentGoalReport; export type GoalReport = AgentGoalReport; export type RunMessage = AgentRunMessage; export type RunDetail = AgentRunDetail; export type RunSnapshot = Omit; const RunPage = cursorPage(AgentRun); /** * 演示开关是否可用:只在开发构建(import.meta.env.DEV)或后端默认上游 provider = "fake"(脚本化假模型)时生效。 * 生产构建连着真实模型时一律忽略 ?mock=1 / VITE_AGENT_MOCK,并清掉 sessionStorage 里记住的开关, * 避免把演示数据当成真实运行展示(验收 S7 / AT78)。 */ export function agentMockAllowed(o: { dev: boolean; provider?: string | null | undefined }): boolean { return o.dev || o.provider === "fake"; } interface MockFlagStore { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void; } const MOCK_KEY = "umt-agent-mock"; /** 纯函数:按是否允许、构建期开关、URL 参数与会话存储决定是否进演示模式(单测覆盖) */ export function resolveAgentMock(o: { allowed: boolean; envFlag?: string | undefined; search: string; storage?: MockFlagStore | null; }): boolean { if (!o.allowed) { try { o.storage?.removeItem(MOCK_KEY); } catch { // 存储不可用时无事可清 } return false; } if (o.envFlag === "1") return true; const p = new URLSearchParams(o.search); try { if (!o.storage) return p.get("mock") === "1"; if (p.get("mock") === "1") o.storage.setItem(MOCK_KEY, "1"); if (p.get("mock") === "0") o.storage.removeItem(MOCK_KEY); return o.storage.getItem(MOCK_KEY) === "1"; } catch { return p.get("mock") === "1"; } } /** 最近一次读到的后端默认上游是否为 fake(由 useAgentMockGate 从 /pi/upstreams 缓存同步) */ let fakeProvider = false; export function agentMockEnabled(): boolean { if (typeof window === "undefined") return false; let storage: MockFlagStore | null = null; try { storage = window.sessionStorage; } catch { storage = null; } return resolveAgentMock({ allowed: agentMockAllowed({ dev: import.meta.env.DEV, provider: fakeProvider ? "fake" : null }), envFlag: import.meta.env.VITE_AGENT_MOCK, search: window.location.search, storage, }); } /** Agent 页的列表/详情 hook 里调用:同步后端 provider,再判断演示开关 */ function useAgentMockGate(): boolean { const upstreams = usePiUpstreams(); fakeProvider = upstreams.data?.items.find((u) => u.isDefault)?.provider === "fake"; return agentMockEnabled(); } 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 = useAgentMockGate(); 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 = useAgentMockGate(); return useQuery({ queryKey: [...agentKeys.run(id ?? ""), mock], enabled: !!id, queryFn: async ({ signal }): Promise => { if (mock) return mockDetail(id ?? ""); const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, { pathParams: { id: id ?? "" }, params: { afterSeq: NO_EVENTS, limit: 1 }, signal, }); return { run: d.run, messages: d.messages, report: d.report, parent: d.parent ?? null, children: d.children ?? [], ...(d.familyUsage ? { familyUsage: d.familyUsage } : {}), }; }, 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(AgentRunDetail, `${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) => AgentRun.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) => AgentRun.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) => AgentRun.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" }, }; // ── 会话页附属数据:审批、幂等键、管理员会话判定 ── const ApprovalPage = cursorPage(Approval); /** 本次运行产生的计划上的审批(按 report.plans 的 planId 逐个查;空列表不发请求) */ export function useRunApprovals(planIds: string[]) { const mock = agentMockEnabled(); const results = useQueries({ queries: planIds.map((planId) => ({ queryKey: ["agent", "approvals", planId], enabled: !mock, queryFn: ({ signal }: { signal: AbortSignal }) => apiParsed(ApprovalPage, "/approvals", { params: { planId, limit: 20 }, signal }), refetchInterval: 15 * TTL_SECOND, })), }); return { items: results.flatMap((r) => r.data?.items ?? []), isPending: results.some((r) => r.isPending && r.fetchStatus !== "idle"), error: results.find((r) => r.error)?.error ?? null, }; } /** 批准/拒绝审批:回传 payloadHash,服务端比对防挪用;只接受控制台管理员会话 */ export function useDecideApproval() { const qc = useQueryClient(); return useMutation({ mutationFn: (input: { id: string; status: Extract; payloadHash: string; }) => apiMutate(`/approvals/:id`, { method: "PATCH", pathParams: { id: input.id }, body: { status: input.status, payloadHash: input.payloadHash }, }).then((d) => Approval.parse(d)), onSuccess: (a) => qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] }), }); } /** 当前是否为控制台管理员会话(cookie 会话;API Key 不算)。审批按钮只在这里可用 */ export function useIsAdminSession(): boolean { const s = useSession(); return s.data?.actor?.type === "admin" && s.data.expiresAt !== null; } export type InvocationAudit = { capabilityId: string; at: string; outcome: string; idempotencyKey: string | null; durationMs: number | null; grantId: string | null; approvalId: string | null; }; const AuditPage = cursorPage(AuditEvent); /** * 本次运行的能力调用审计(capability.invoke / capability.denied,按时间升序)。 * 审计按全站新→旧分页,这里最多翻 3 页、按 payload.agentRunId 过滤;较早的运行可能拿不全,界面按"未取到"处理。 */ export function useRunInvocationAudit(runId: string, enabled: boolean, version = 0) { const mock = agentMockEnabled(); return useQuery({ queryKey: ["agent", "invocation-audit", runId, version], placeholderData: keepPreviousData, enabled: enabled && !mock && !!runId, staleTime: 10 * TTL_SECOND, queryFn: async ({ signal }): Promise => { const out: InvocationAudit[] = []; let cursor: string | undefined; for (let page = 0; page < 3; page++) { const d = await apiParsed(AuditPage, "/audit-events", { params: cleanParams({ action: "capability.*", targetType: "capability", limit: 200, cursor }), signal, }); for (const e of d.items) { const p = e.payload as Record; if (p.agentRunId !== runId) continue; out.push({ capabilityId: e.targetId ?? "", at: e.at, outcome: String(p.outcome ?? e.action), idempotencyKey: typeof p.idempotencyKey === "string" ? p.idempotencyKey : null, durationMs: typeof p.durationMs === "number" ? p.durationMs : null, grantId: typeof p.grantId === "string" ? p.grantId : null, approvalId: typeof p.approvalId === "string" ? p.approvalId : null, }); } if (!d.nextCursor) break; cursor = d.nextCursor; } return out.sort((a, b) => a.at.localeCompare(b.at)); }, }); }