feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -0,0 +1,222 @@
|
||||
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<typeof AgentMode>;
|
||||
|
||||
export const AgentRunView = AgentRun.extend({
|
||||
mode: AgentMode.optional(),
|
||||
dedupeKey: z.string().nullable().optional(),
|
||||
});
|
||||
export type AgentRunView = z.infer<typeof AgentRunView>;
|
||||
|
||||
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
||||
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 type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
|
||||
|
||||
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<RunSnapshot> => {
|
||||
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<AgentRunEvent[]> {
|
||||
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<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" },
|
||||
};
|
||||
Reference in new issue
Block a user