459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
import {
|
|
keepPreviousData,
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQueries,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import {
|
|
AgentGoalReport,
|
|
AgentRun,
|
|
AgentRunDetail,
|
|
type AgentRunEvent,
|
|
type AgentRunMessage,
|
|
type AgentRunStatus,
|
|
type AgentTrigger,
|
|
Approval,
|
|
type ApprovalStatus,
|
|
AuditEvent,
|
|
agentRunEventFromRow,
|
|
cursorPage,
|
|
} from "@umt/contracts";
|
|
import { MOCK_RUNS, mockChoose, 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";
|
|
|
|
/** 会话选项(运行模式已取消):proposeFirst = 先看方案再执行 */
|
|
export type AgentSessionOptions = { proposeFirst: boolean };
|
|
/** 会话是否要求先看方案再执行(存在 run.scope 里) */
|
|
export const isProposeFirst = (run: Pick<AgentRun, "scope">) => run.scope.proposeFirst === true;
|
|
|
|
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);
|
|
|
|
/**
|
|
* 演示开关是否可用:只在开发构建(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, trigger?: AgentTrigger, group?: AgentRunGroup) =>
|
|
["agent", "list", status ?? "all", trigger ?? "any", group ?? "any"] 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);
|
|
|
|
/** 会话来源(run.trigger)的界面名:手动 / 巡检 / 事件 / 子会话 */
|
|
export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
|
|
user: "手动",
|
|
schedule: "巡检",
|
|
event: "事件",
|
|
subagent: "子会话",
|
|
};
|
|
|
|
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
|
|
/** 会话分栏:对话 = 用户发起;自动 = 定时巡检 / 事件触发(子会话随父会话) */
|
|
export type AgentRunGroup = "conversation" | "auto";
|
|
const isAutoTrigger = (t: AgentTrigger) => t === "schedule" || t === "event";
|
|
|
|
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) {
|
|
const mock = useAgentMockGate();
|
|
return useInfiniteQuery({
|
|
queryKey: [...agentKeys.runs(status, trigger, group), mock],
|
|
initialPageParam: undefined as string | undefined,
|
|
queryFn: async ({ pageParam, signal }) => {
|
|
if (mock)
|
|
return {
|
|
items: MOCK_RUNS().filter(
|
|
(r) =>
|
|
(!status || r.status === status) &&
|
|
(!trigger || r.trigger === trigger) &&
|
|
(!group || isAutoTrigger(r.trigger) === (group === "auto")),
|
|
),
|
|
nextCursor: null,
|
|
};
|
|
return apiParsed(RunPage, AGENT_BASE, {
|
|
params: cleanParams({ status, trigger, group, 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<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,
|
|
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<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; proposeFirst?: boolean }) => {
|
|
const proposeFirst = input.proposeFirst === true;
|
|
if (agentMockEnabled()) return mockCreateRun(input.goal, { proposeFirst });
|
|
return apiMutate(AGENT_BASE, { body: { goal: input.goal, proposeFirst } }).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) }),
|
|
});
|
|
}
|
|
|
|
/** 回答方案选择:选一个方案,或都不要(optionId=null + 原因);挂起的会话随即继续 */
|
|
export function useChooseOption() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (input: { runId: string; decisionId: string; optionId: string | null; reason?: string }) => {
|
|
if (agentMockEnabled()) return mockChoose(input.runId, input.optionId).run;
|
|
return apiMutate(`${AGENT_BASE}/:id/choice`, {
|
|
pathParams: { id: input.runId },
|
|
body: { decisionId: input.decisionId, optionId: input.optionId, reason: input.reason },
|
|
}).then((d) => AgentRun.parse(d));
|
|
},
|
|
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.runId) }),
|
|
});
|
|
}
|
|
|
|
/** 需要按计划单次审批的能力:这类动作在会话里走审批卡,不另建持续授权 */
|
|
const PLAN_APPROVAL_CAPS = new Set(["download.submit", "organize.execute", "policy.apply"]);
|
|
|
|
/**
|
|
* 「批准并重试」(AGUI-3):被拒绝的调用由管理员批准一次后让智能体重试。
|
|
* 非计划类能力建一条只覆盖这个能力、今天只能成功一次、1 小时后过期的持续授权(服务端核对,不靠模型转述);
|
|
* 计划类能力只追加消息让智能体重试,重试时在步骤处照常出审批卡。
|
|
*/
|
|
export function useApproveAndRetry() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (input: { runId: string; capabilityId: string; title: string }) => {
|
|
const text = PLAN_APPROVAL_CAPS.has(input.capabilityId)
|
|
? `重试「${input.title}」,需要审批时在步骤处等我批准。`
|
|
: `已批准「${input.title}」一次,重试这一步。`;
|
|
if (agentMockEnabled()) return mockDetail(input.runId, { append: text }).run;
|
|
if (!PLAN_APPROVAL_CAPS.has(input.capabilityId))
|
|
await apiMutate("/automation-grants", {
|
|
body: {
|
|
action: input.capabilityId,
|
|
scope: { capabilities: [input.capabilityId] },
|
|
budget: { maxInvocationsPerDay: 1 },
|
|
reason: `智能体会话 ${input.runId.slice(0, 8)} 里批准并重试`,
|
|
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
},
|
|
});
|
|
return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id: input.runId }, body: { text } }).then((d) =>
|
|
AgentRun.parse(d),
|
|
);
|
|
},
|
|
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.runId) }),
|
|
});
|
|
}
|
|
|
|
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" },
|
|
};
|
|
|
|
// ── 会话页附属数据:审批、幂等键、管理员会话判定 ──
|
|
|
|
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<ApprovalStatus, "approved" | "rejected">;
|
|
payloadHash: string;
|
|
/** 拒绝理由 / 建议:写进待处理事项,会话内审批时 Agent 按它调整 */
|
|
reason?: string;
|
|
}) =>
|
|
apiMutate(`/approvals/:id`, {
|
|
method: "PATCH",
|
|
pathParams: { id: input.id },
|
|
body: {
|
|
status: input.status,
|
|
payloadHash: input.payloadHash,
|
|
...(input.reason ? { reason: input.reason } : {}),
|
|
},
|
|
}).then((d) => Approval.parse(d)),
|
|
onSuccess: (a) => {
|
|
void qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] });
|
|
void qc.invalidateQueries({ queryKey: ["agent", "approval", a.id] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 单个审批的当前状态(会话内审批卡用;挂起期间事件流里还没有决定结果) */
|
|
export function useApproval(id: string | null) {
|
|
const mock = agentMockEnabled();
|
|
return useQuery({
|
|
queryKey: ["agent", "approval", id ?? ""],
|
|
enabled: !!id && !mock,
|
|
queryFn: ({ signal }) => apiParsed(Approval, "/approvals/:id", { pathParams: { id: id ?? "" }, signal }),
|
|
refetchInterval: (q) => (q.state.data?.status === "pending" ? 5 * TTL_SECOND : false),
|
|
});
|
|
}
|
|
|
|
/** 当前是否为控制台管理员会话(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<InvocationAudit[]> => {
|
|
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<string, unknown>;
|
|
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));
|
|
},
|
|
});
|
|
}
|