Files
UHD_Media_Tool/apps/web/app/api/agent.ts
T
rootandClaude Opus 5.5 1023642ef9 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]>
2026-09-27 21:32:11 +08:00

186 lines
6.8 KiB
TypeScript

import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AgentGoalReport,
AgentRun,
AgentRunDetail,
type AgentRunEvent,
type AgentRunMessage,
AgentRunMode,
type AgentRunStatus,
agentRunEventFromRow,
cursorPage,
} from "@umt/contracts";
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),
// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。
// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互。
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<RunDetail, "events" | "nextAfterSeq">;
const RunPage = cursorPage(AgentRun);
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<RunSnapshot> => {
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 };
},
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<AgentRunEvent[]> {
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<AgentRunStatus, string> = {
queued: "排队中",
running: "运行中",
waiting: "等待中",
succeeded: "已完成",
failed: "失败",
cancelled: "已中止",
budget_exceeded: "超出预算",
};
export const RUN_STATUS_TONE: Record<AgentRunStatus, "success" | "warning" | "destructive" | "neutral"> = {
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" },
};