From 7103bc8f7c95ee3420752b25b73fc2df64cdb980 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 20:52:32 +0800 Subject: [PATCH] feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/app/api/agent.ts | 222 +++++++++ apps/web/app/api/downloads.ts | 171 +++++++ apps/web/app/api/feeds.ts | 85 ++++ apps/web/app/api/inventory.ts | 52 ++ apps/web/app/api/parse.ts | 39 ++ apps/web/app/api/records.ts | 180 +++++++ apps/web/app/api/releases.ts | 79 +++ apps/web/app/api/sites.ts | 101 ++++ apps/web/app/api/tasks.ts | 305 ++++++++++++ .../components/agent/agent-conversation.tsx | 449 ++++++++++++++++++ .../web/app/components/agent/agent-drawer.tsx | 95 ++++ .../app/components/agent/agent-run-panel.tsx | 212 +++++++++ apps/web/app/components/agent/agent-store.ts | 62 +++ apps/web/app/components/agent/mock.ts | 349 ++++++++++++++ .../app/components/agent/use-agent-stream.ts | 254 ++++++++++ .../downloads/business-timeline.tsx | 68 +++ .../components/downloads/cache-volumes.tsx | 124 +++++ .../components/downloads/download-detail.tsx | 202 ++++++++ .../components/downloads/organize-dialog.tsx | 330 +++++++++++++ .../components/workbench/download-dialog.tsx | 143 ++++++ .../app/components/workbench/feed-form.tsx | 266 +++++++++++ .../workbench/release-detail-sheet.tsx | 273 +++++++++++ .../components/workbench/release-fields.ts | 95 ++++ .../app/components/workbench/release-list.tsx | 123 +++++ .../components/workbench/snapshot-panel.tsx | 111 +++++ apps/web/app/routes/console/agent.$runId.tsx | 33 +- apps/web/app/routes/console/agent.tsx | 112 ++++- apps/web/app/routes/console/layout.tsx | 3 + apps/web/app/routes/console/organize.$id.tsx | 236 ++++++++- apps/web/app/routes/console/organize.tsx | 171 ++++++- apps/web/app/routes/console/sites.$siteId.tsx | 407 +++++++++++++++- apps/web/app/routes/console/sites.tsx | 159 ++++++- apps/web/app/routes/console/sources.tsx | 320 ++++++++++++- apps/web/app/routes/console/tasks.$id.tsx | 40 +- apps/web/app/routes/console/tasks.tsx | 308 +++++++++++- 35 files changed, 6134 insertions(+), 45 deletions(-) create mode 100644 apps/web/app/api/agent.ts create mode 100644 apps/web/app/api/downloads.ts create mode 100644 apps/web/app/api/feeds.ts create mode 100644 apps/web/app/api/inventory.ts create mode 100644 apps/web/app/api/parse.ts create mode 100644 apps/web/app/api/records.ts create mode 100644 apps/web/app/api/releases.ts create mode 100644 apps/web/app/api/sites.ts create mode 100644 apps/web/app/api/tasks.ts create mode 100644 apps/web/app/components/agent/agent-conversation.tsx create mode 100644 apps/web/app/components/agent/agent-drawer.tsx create mode 100644 apps/web/app/components/agent/agent-run-panel.tsx create mode 100644 apps/web/app/components/agent/agent-store.ts create mode 100644 apps/web/app/components/agent/mock.ts create mode 100644 apps/web/app/components/agent/use-agent-stream.ts create mode 100644 apps/web/app/components/downloads/business-timeline.tsx create mode 100644 apps/web/app/components/downloads/cache-volumes.tsx create mode 100644 apps/web/app/components/downloads/download-detail.tsx create mode 100644 apps/web/app/components/downloads/organize-dialog.tsx create mode 100644 apps/web/app/components/workbench/download-dialog.tsx create mode 100644 apps/web/app/components/workbench/feed-form.tsx create mode 100644 apps/web/app/components/workbench/release-detail-sheet.tsx create mode 100644 apps/web/app/components/workbench/release-fields.ts create mode 100644 apps/web/app/components/workbench/release-list.tsx create mode 100644 apps/web/app/components/workbench/snapshot-panel.tsx diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts new file mode 100644 index 00000000..a380bf89 --- /dev/null +++ b/apps/web/app/api/agent.ts @@ -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; + +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" }, +}; diff --git a/apps/web/app/api/downloads.ts b/apps/web/app/api/downloads.ts new file mode 100644 index 00000000..b390e1f7 --- /dev/null +++ b/apps/web/app/api/downloads.ts @@ -0,0 +1,171 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AcquisitionPlan, + cursorPage, + Dashboard, + DownloaderInstance, + type DownloadStatus, + DownloadTask, + type PlanStatus, +} from "@umt/contracts"; +import { z } from "zod"; +import { TTL_SECOND } from "~/lib/api.ts"; +import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; + +// 下载任务、下载计划、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard) + +const DownloadPage = cursorPage(DownloadTask); +const PlanPage = cursorPage(AcquisitionPlan); + +export type DownloadFilters = { status?: DownloadStatus; instanceId?: string; managed?: "true" | "false" }; + +export const downloadKeys = { + all: ["downloads"] as const, + list: (f: DownloadFilters) => ["downloads", "list", f] as const, + one: (id: string) => ["downloads", "one", id] as const, + plans: (status?: PlanStatus) => ["downloads", "plans", status ?? "all"] as const, + plan: (id: string) => ["downloads", "plan", id] as const, + cache: () => ["downloads", "cache"] as const, +}; + +const ACTIVE: DownloadStatus[] = ["queued", "submitting", "downloading", "importing", "stalled"]; + +export function useDownloadTasks(f: DownloadFilters) { + return useInfiniteQuery({ + queryKey: downloadKeys.list(f), + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + apiParsed(DownloadPage, "/api/v1/download-tasks", { + params: cleanParams({ ...f, cursor: pageParam, limit: 50 }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + // 有进行中的任务时 10 秒轮询进度(SSE download.progress 到达时也会失效重取) + refetchInterval: (q) => + q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 10 * TTL_SECOND : false, + }); +} + +export function useDownloadTask(id: string | undefined) { + return useQuery({ + queryKey: downloadKeys.one(id ?? ""), + enabled: !!id, + queryFn: ({ signal }) => + apiParsed(DownloadTask, "/api/v1/download-tasks/:id", { pathParams: { id: id ?? "" }, signal }), + }); +} + +export function usePlans(status?: PlanStatus) { + return useInfiniteQuery({ + queryKey: downloadKeys.plans(status), + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + apiParsed(PlanPage, "/api/v1/acquisition-plans", { + params: cleanParams({ status, cursor: pageParam, limit: 50 }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + }); +} + +export function usePlan(id: string | null | undefined) { + return useQuery({ + queryKey: downloadKeys.plan(id ?? ""), + enabled: !!id, + queryFn: ({ signal }) => + apiParsed(AcquisitionPlan, "/api/v1/acquisition-plans/:id", { pathParams: { id: id ?? "" }, signal }), + }); +} + +/** 暂停(记为用户暂停)/ 恢复;阈值暂停在水位未回落前服务端拒绝恢复 */ +export function useDownloadAction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, action }: { id: string; action: "pause" | "resume" }) => + apiMutate(`/api/v1/download-tasks/:id/${action}`, { pathParams: { id } }).then((d) => DownloadTask.parse(d)), + onSuccess: (d) => { + qc.setQueryData(downloadKeys.one(d.id), d); + qc.invalidateQueries({ queryKey: ["downloads", "list"] }); + }, + }); +} + +/** 缓存卷用量/阈值:取首页聚合里的 cache 区块(同一统计口径) */ +export function useCacheVolumes() { + return useQuery({ + queryKey: downloadKeys.cache(), + queryFn: ({ signal }) => apiParsed(Dashboard, "/api/v1/dashboard", { signal }), + select: (d) => d.cache.volumes, + refetchInterval: 30 * TTL_SECOND, + }); +} + +/** 手动清理:只清已验证入库且满足保留/H&R 的纳管任务 */ +export function useCacheCleanup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (downloaderInstanceId?: string) => + apiMutate("/api/v1/download-cache/cleanup", { + body: downloaderInstanceId ? { downloaderInstanceId } : {}, + }).then((d) => z.object({ report: z.record(z.string(), z.unknown()) }).parse(d)), + onSuccess: () => qc.invalidateQueries({ queryKey: downloadKeys.all }), + }); +} + +/** 下载器实例名(列表展示用) */ +export function useDownloaderNames() { + return useQuery({ + queryKey: ["downloads", "instances"], + staleTime: 5 * 60 * TTL_SECOND, + queryFn: ({ signal }) => + apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", { params: { limit: 200 }, signal }), + select: (d) => new Map(d.items.map((i) => [i.id, i.name])), + }); +} + +export const DOWNLOAD_STATUS_LABEL: Record = { + queued: "排队", + submitting: "提交中", + downloading: "下载中", + paused: "已暂停", + stalled: "无进展", + completed: "下载完成", + importing: "入库中", + imported: "已入库", + failed: "失败", + removed: "已移除", + missing: "下载器中缺失", +}; + +export const DOWNLOAD_STATUS_TONE: Record = { + queued: "neutral", + submitting: "neutral", + downloading: "neutral", + paused: "warning", + stalled: "warning", + completed: "success", + importing: "neutral", + imported: "success", + failed: "destructive", + removed: "neutral", + missing: "destructive", +}; + +export const PAUSE_REASON_LABEL = { + user: "用户暂停", + threshold: "阈值暂停", + policy: "策略暂停", + agent: "Agent 暂停", +} as const; + +export const PLAN_STATUS_LABEL: Record = { + draft: "草稿", + reserved: "已预留", + submitted: "已提交", + completed: "已完成", + cancelled: "已取消", + failed: "失败", + expired: "已过期", +}; diff --git a/apps/web/app/api/feeds.ts b/apps/web/app/api/feeds.ts new file mode 100644 index 00000000..c885d1ea --- /dev/null +++ b/apps/web/app/api/feeds.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + cursorPage, + type FeedHealth, + FeedSource, + type FeedSourceCreate, + type FeedSourceUpdate, + Id, +} from "@umt/contracts"; +import { z } from "zod"; +import { TTL_SECOND } from "~/lib/api.ts"; +import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; + +// Feed 来源(W2-B:/feed-sources) + +const FeedPage = cursorPage(FeedSource); + +export const feedKeys = { + all: ["feeds"] as const, + list: (f: { siteId?: string; health?: FeedHealth }) => ["feeds", "list", f] as const, +}; + +export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) { + return useQuery({ + queryKey: feedKeys.list(f), + queryFn: ({ signal }) => + apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), signal }), + refetchInterval: 30 * TTL_SECOND, + }); +} + +export function useCreateFeed() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: FeedSourceCreate) => + apiMutate("/api/v1/feed-sources", { body }).then((d) => FeedSource.parse(d)), + onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }), + }); +} + +export function useUpdateFeed() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: FeedSourceUpdate }) => + apiMutate("/api/v1/feed-sources/:id", { method: "PATCH", pathParams: { id }, body }).then((d) => + FeedSource.parse(d), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }), + }); +} + +/** 启停 / 熔断复位 */ +export function useFeedAction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, action }: { id: string; action: "enable" | "disable" | "reset" }) => + apiMutate(`/api/v1/feed-sources/:id/${action}`, { pathParams: { id } }).then((d) => FeedSource.parse(d)), + onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }), + }); +} + +/** 立即抓取(仍受站点限流),返回任务 ID */ +export function useFetchFeedNow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate("/api/v1/feed-sources/:id/fetch", { pathParams: { id } }).then((d) => + z.object({ taskId: Id }).parse(d), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }), + }); +} + +/** + * 疑似缺口:Feed 窗口与上次零重叠时服务端发 feed.gap_suspected 并排补漏。 + * 列表字段里没有显式标记,这里按 cursor 里的 gapSuspectedAt(若服务端写入)或"距上次成功超过 3 个周期"推断,并标"疑似"。 + */ +export function feedGapHint(f: FeedSource, now = Date.now()): string | null { + const gapAt = typeof f.cursor.gapSuspectedAt === "string" ? f.cursor.gapSuspectedAt : null; + if (gapAt) return `疑似缺口(${new Date(gapAt).toLocaleString("zh-CN", { hour12: false })} 检测,已排补漏)`; + if (!f.enabled || !f.lastSuccessAt) return null; + const gapMs = now - new Date(f.lastSuccessAt).getTime(); + if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项(疑似)"; + return null; +} diff --git a/apps/web/app/api/inventory.ts b/apps/web/app/api/inventory.ts new file mode 100644 index 00000000..234aef61 --- /dev/null +++ b/apps/web/app/api/inventory.ts @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; +import { type InventoryEntry, InventoryResponse, LibraryDetail } from "@umt/contracts"; +import { apiParsed } from "./parse.ts"; + +// 站点工作台库存叠加(W2-C:/inventory?entityIds=,最多 200 个) + +export function useInventory(entityIds: string[]) { + const ids = [...new Set(entityIds)].sort().slice(0, 200); + return useQuery({ + queryKey: ["inventory", ids], + enabled: ids.length > 0, + queryFn: ({ signal }) => + apiParsed(InventoryResponse, "/api/v1/inventory", { params: { entityIds: ids.join(",") }, signal }), + select: (d) => new Map(d.items.map((i) => [i.entityId, i])), + }); +} + +/** 叠加标记:只来自本地身份/Slot/任务,不从网页标题猜确定事实 */ +export type OverlayFlag = { key: string; label: string; tone: "success" | "warning" | "neutral" }; + +export function inventoryFlags(entry: InventoryEntry | undefined, releaseResolution?: string | null): OverlayFlag[] { + if (!entry) return []; + const flags: OverlayFlag[] = []; + const available = entry.versions.filter((v) => v.available); + if (available.length) { + const res = [...new Set(available.map((v) => v.resolution ?? "未知分辨率"))].join(" / "); + flags.push({ key: "have", label: `已有 ${res}`, tone: "success" }); + } + const has4k = available.some((v) => /2160|4k|uhd/i.test(v.resolution ?? "")); + const wants4k = entry.gaps.some((g) => /2160|4k|uhd/i.test(g.name)); + if (wants4k && !has4k) flags.push({ key: "no4k", label: "缺 4K", tone: "warning" }); + for (const g of entry.gaps.filter((g) => !/2160|4k|uhd/i.test(g.name) || has4k)) { + flags.push({ key: `gap-${g.slotId}`, label: `缺口:${g.name}`, tone: "warning" }); + } + if (entry.downloading.length) { + flags.push({ key: "dl", label: `下载中 ${entry.downloading.length}`, tone: "neutral" }); + } + if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) { + flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" }); + } + return flags; +} + +/** 作品详情(候选理由、Slot、下载历史);工作台详情抽屉用它回答"为什么选/不选" */ +export function useEntityDetail(entityId: string | null | undefined) { + return useQuery({ + queryKey: ["library", "detail", entityId ?? ""], + enabled: !!entityId, + queryFn: ({ signal }) => + apiParsed(LibraryDetail, "/api/v1/library/:id", { pathParams: { id: entityId ?? "" }, signal }), + }); +} diff --git a/apps/web/app/api/parse.ts b/apps/web/app/api/parse.ts new file mode 100644 index 00000000..dfa498ad --- /dev/null +++ b/apps/web/app/api/parse.ts @@ -0,0 +1,39 @@ +import type { ApiErrorCode } from "@umt/contracts"; +import type { z } from "zod"; +import { ApiRequestError, apiFetch, type FetcherArg } from "~/lib/api.ts"; + +export { ApiRequestError }; + +/** 请求并用 contracts 的 zod schema 校验 data;失败抛 ApiRequestError(给 react-query 的 error 态) */ +export async function apiParsed(schema: S, path: string, arg?: FetcherArg): Promise> { + const r = await apiFetch(path, arg); + if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message); + const parsed = schema.safeParse(r.data); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new ApiRequestError( + "internal" satisfies ApiErrorCode, + `响应格式与合同不符:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`, + ); + } + return parsed.data; +} + +/** 变更请求:返回 data,失败抛错(给 useMutation,由 onError 统一 toast) */ +export async function apiMutate(path: string, arg: FetcherArg = {}): Promise { + const r = await apiFetch(path, { method: "POST", ...arg }); + if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message); + return r.data; +} + +export const errorCode = (err: unknown): string | null => (err instanceof ApiRequestError ? err.code : null); + +/** 端点是否不存在(后端尚未提供) */ +export const isMissingEndpoint = (err: unknown) => errorCode(err) === "not_found"; + +/** 查询字符串:去掉空值 */ +export function cleanParams(p: Record) { + const out: Record = {}; + for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v; + return out; +} diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts new file mode 100644 index 00000000..529726cd --- /dev/null +++ b/apps/web/app/api/records.ts @@ -0,0 +1,180 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Artifact, + cursorPage, + LibraryItem, + LibraryRoot, + type OrganizationStatus, + type OrganizationTrigger, + OrganizeAccepted, + type OrganizeRequest, + PublicOrganizationRecord, +} from "@umt/contracts"; +import { z } from "zod"; +import { TTL_SECOND } from "~/lib/api.ts"; +import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; + +// 整理记录(W2-C:/organization-records;读 organize.read,发起 organize.execute) + +const RecordPage = cursorPage(PublicOrganizationRecord); + +export type RecordFilters = { + status?: OrganizationStatus; + trigger?: OrganizationTrigger; + mediaEntityId?: string; + q?: string; +}; + +export const recordKeys = { + all: ["records"] as const, + list: (f: RecordFilters) => ["records", "list", f] as const, + one: (id: string) => ["records", "one", id] as const, + entity: (id: string) => ["records", "entity", id] as const, +}; + +export function useRecords(f: RecordFilters) { + return useInfiniteQuery({ + queryKey: recordKeys.list(f), + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + apiParsed(RecordPage, "/api/v1/organization-records", { + params: cleanParams({ ...f, cursor: pageParam, limit: 50 }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + refetchInterval: (q) => + q.state.data?.pages.some((p) => p.items.some((r) => r.status === "running" || r.status === "queued")) + ? 5 * TTL_SECOND + : false, + }); +} + +export function useRecord(id: string | undefined) { + return useQuery({ + queryKey: recordKeys.one(id ?? ""), + enabled: !!id, + queryFn: ({ signal }) => + apiParsed(PublicOrganizationRecord, "/api/v1/organization-records/:id", { + pathParams: { id: id ?? "" }, + signal, + }), + refetchInterval: (q) => + q.state.data && (q.state.data.status === "running" || q.state.data.status === "queued") ? 3 * TTL_SECOND : false, + }); +} + +/** 某作品的整理记录(业务时间线用) */ +export function useRecordsForEntity(entityId: string | null | undefined) { + return useQuery({ + queryKey: recordKeys.entity(entityId ?? ""), + enabled: !!entityId, + queryFn: ({ signal }) => + apiParsed(RecordPage, "/api/v1/organization-records", { + params: { mediaEntityId: entityId ?? "", limit: 100 }, + signal, + }), + }); +} + +/** 手动发起整理;dryRun=true 只生成计划供预览 */ +export function useOrganize() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: OrganizeRequest) => + apiMutate("/api/v1/organization-records", { body }).then((d) => OrganizeAccepted.parse(d)), + onSuccess: (_d, v) => { + if (!v.dryRun) qc.invalidateQueries({ queryKey: recordKeys.all }); + }, + }); +} + +export const RECORD_STATUS_LABEL: Record = { + queued: "排队", + running: "执行中", + succeeded: "成功", + partial: "部分成功", + failed: "失败", + cancelled: "已取消", +}; + +export const RECORD_STATUS_TONE: Record = { + queued: "neutral", + running: "neutral", + succeeded: "success", + partial: "warning", + failed: "destructive", + cancelled: "neutral", +}; + +export const TRIGGER_LABEL: Record = { auto: "自动", manual: "手动", agent: "Agent" }; + +/** dryRun 生成的整理计划冻结为 artifact(kind=plan);读取端点未提供时返回 not_found,由页面说明 */ +export function useArtifact(id: string | null | undefined) { + return useQuery({ + queryKey: ["artifact", id ?? ""], + enabled: !!id, + retry: false, + queryFn: ({ signal }) => apiParsed(Artifact, "/api/v1/artifacts/:id", { pathParams: { id: id ?? "" }, signal }), + }); +} + +/** artifact.content.plan.organize 的前端视图(与 domain OrganizePlan 同形,只取展示字段) */ +export const OrganizePlanPreview = z.object({ + request: z.record(z.string(), z.unknown()).optional(), + status: z.string().optional(), + reason: z.string().nullable().optional(), + plan: z + .object({ + targetDirs: z.array(z.string()).optional(), + organize: z + .object({ + operation: z.string(), + crossDevice: z.boolean(), + requiresApproval: z.boolean(), + files: z.array( + z.object({ + src: z.string(), + dst: z.string(), + size: z.number(), + kind: z.string(), + action: z.string(), + skipReason: z.string().optional(), + }), + ), + excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })), + conflicts: z.array( + z.object({ + kind: z.string(), + blocking: z.boolean(), + src: z.string().optional(), + detail: z.string().optional(), + }), + ), + }) + .optional(), + }) + .nullable() + .optional(), +}); +export type OrganizePlanPreview = z.infer; + +/** 整理目标库根 */ +export function useLibraryRoots() { + return useQuery({ + queryKey: ["records", "library-roots"], + staleTime: 5 * 60 * TTL_SECOND, + queryFn: ({ signal }) => + apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200 }, signal }), + }); +} + +/** 选作品:按标题搜本地媒体库 */ +export function useEntitySearch(q: string) { + return useQuery({ + queryKey: ["records", "entity-search", q], + enabled: q.trim().length > 0, + queryFn: ({ signal }) => + apiParsed(cursorPage(LibraryItem), "/api/v1/library", { params: { q: q.trim(), limit: 10 }, signal }), + }); +} diff --git a/apps/web/app/api/releases.ts b/apps/web/app/api/releases.ts new file mode 100644 index 00000000..dfcd3e2d --- /dev/null +++ b/apps/web/app/api/releases.ts @@ -0,0 +1,79 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AcquisitionPlan, type ResolutionStatus, WorkbenchReleaseDetail, WorkbenchReleasePage } from "@umt/contracts"; +import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; + +// 站点工作台整理视图(W2-B:/sites/{id}/releases、/sites/{id}/releases/{rid}) + +export type ReleaseFilters = { + q?: string; + category?: string; + promotion?: string; + resolution?: ResolutionStatus; +}; + +export const releaseKeys = { + all: ["releases"] as const, + list: (siteId: string, f: ReleaseFilters, live: boolean) => ["releases", "list", siteId, f, live] as const, + detail: (siteId: string, rid: string) => ["releases", "detail", siteId, rid] as const, +}; + +/** + * 统一资源列表,游标分页。live=true 强制实时补查(只对第一页);否则让服务端按"本地无结果或快照过期"自行决定。 + * 翻页时保留上一次数据,避免"提取失败 = 空列表"。 + */ +export function useSiteReleases(siteId: string | undefined, filters: ReleaseFilters, live = false) { + return useInfiniteQuery({ + queryKey: releaseKeys.list(siteId ?? "", filters, live), + enabled: !!siteId, + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + apiParsed(WorkbenchReleasePage, "/api/v1/sites/:id/releases", { + pathParams: { id: siteId ?? "" }, + params: cleanParams({ + ...filters, + cursor: pageParam, + limit: 50, + live: live && !pageParam ? "true" : undefined, + }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + }); +} + +/** 资源详情;refresh=true 时实时复核(失败保留既有数据并带 refreshError) */ +export function useReleaseDetail(siteId: string | undefined, rid: string | undefined) { + return useQuery({ + queryKey: releaseKeys.detail(siteId ?? "", rid ?? ""), + enabled: !!siteId && !!rid, + queryFn: ({ signal }) => + apiParsed(WorkbenchReleaseDetail, "/api/v1/sites/:id/releases/:rid", { + pathParams: { id: siteId ?? "", rid: rid ?? "" }, + signal, + }), + }); +} + +export function useRefreshReleaseDetail() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ siteId, rid }: { siteId: string; rid: string }) => + apiParsed(WorkbenchReleaseDetail, "/api/v1/sites/:id/releases/:rid", { + pathParams: { id: siteId, rid }, + params: { refresh: "true" }, + }), + onSuccess: (d, v) => qc.setQueryData(releaseKeys.detail(v.siteId, v.rid), d), + }); +} + +/** + * 统一下载入口:与自动收录同一条提交流程(计划 → 去重 → 缓存硬阈值 → 实例选择 → 管理标签)。 + * 后端对应端点 POST /acquisition-plans(sourceReleaseId + 可选 slotId);未提供时返回 not_found,页面给出说明。 + */ +export function useSubmitRelease() { + return useMutation({ + mutationFn: (body: { sourceReleaseId: string; slotId?: string; note?: string }) => + apiMutate("/api/v1/acquisition-plans", { body }).then((d) => AcquisitionPlan.parse(d)), + }); +} diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts new file mode 100644 index 00000000..975e31bd --- /dev/null +++ b/apps/web/app/api/sites.ts @@ -0,0 +1,101 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { cursorPage, Id, Site, SiteAccount, SiteDefinitionView, SiteSnapshot } from "@umt/contracts"; +import { z } from "zod"; +import { TTL_SECOND } from "~/lib/api.ts"; +import { apiMutate, apiParsed } from "./parse.ts"; + +// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s)) + +const SitePage = cursorPage(Site); +const AccountPage = cursorPage(SiteAccount); +const SnapshotPage = cursorPage(SiteSnapshot); + +export const siteKeys = { + all: ["sites"] as const, + list: () => ["sites", "list"] as const, + one: (id: string) => ["sites", "one", id] as const, + accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const, + snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const, + definitions: () => ["sites", "definitions"] as const, +}; + +export function useSites() { + return useQuery({ + queryKey: siteKeys.list(), + queryFn: ({ signal }) => apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200 }, signal }), + }); +} + +export function useSite(id: string | undefined) { + return useQuery({ + queryKey: siteKeys.one(id ?? ""), + enabled: !!id && Id.safeParse(id).success, + queryFn: ({ signal }) => apiParsed(Site, "/api/v1/sites/:id", { pathParams: { id: id ?? "" }, signal }), + }); +} + +export function useSiteAccounts(siteId?: string) { + return useQuery({ + queryKey: siteKeys.accounts(siteId), + queryFn: ({ signal }) => + apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }), + }); +} + +export function useSiteDefinitions() { + return useQuery({ + queryKey: siteKeys.definitions(), + staleTime: 5 * 60 * TTL_SECOND, + queryFn: ({ signal }) => + apiParsed(z.object({ items: z.array(SiteDefinitionView) }), "/api/v1/site-definitions", { signal }), + }); +} + +/** 快照/补查记录;有 running 时 5 秒轮询 */ +export function useSiteSnapshots(siteId: string | undefined, limit = 20) { + return useQuery({ + queryKey: siteKeys.snapshots(siteId ?? ""), + enabled: !!siteId, + queryFn: ({ signal }) => + apiParsed(SnapshotPage, "/api/v1/sites/:id/snapshots", { + pathParams: { id: siteId ?? "" }, + params: { limit }, + signal, + }), + refetchInterval: (q) => (q.state.data?.items.some((s) => s.status === "running") ? 5 * TTL_SECOND : false), + }); +} + +/** 启用 / 停用站点 */ +export function useToggleSite() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + apiMutate(`/api/v1/sites/:id/${enabled ? "enable" : "disable"}`, { pathParams: { id } }).then((d) => + Site.parse(d), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + }); +} + +/** 手动快照(受站点限流),返回任务 ID */ +export function useSnapshotSite() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, maxPages }: { id: string; maxPages?: number }) => + apiMutate(`/api/v1/sites/:id/snapshot`, { pathParams: { id }, body: maxPages ? { maxPages } : {} }).then((d) => + z.object({ taskId: Id }).parse(d), + ), + onSuccess: (_d, v) => { + qc.invalidateQueries({ queryKey: siteKeys.snapshots(v.id) }); + qc.invalidateQueries({ queryKey: siteKeys.list() }); + }, + }); +} + +/** 快照新鲜度:从未快照或超过 2 个周期视为过期(与服务端 WorkbenchCoverage.stale 同口径) */ +export function snapshotStale(site: Pick, now = Date.now()) { + if (!site.lastSnapshotAt) return true; + const interval = (site.snapshotIntervalSec ?? 3600) * 1000; + return now - new Date(site.lastSnapshotAt).getTime() > interval * 2; +} diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts new file mode 100644 index 00000000..11f1511d --- /dev/null +++ b/apps/web/app/api/tasks.ts @@ -0,0 +1,305 @@ +import { useQuery } from "@tanstack/react-query"; +import { + type AcquisitionPlan, + cursorPage, + Decision, + type DownloadTask, + EngineRun, + EngineRunDetail, + type PublicOrganizationRecord, + SourceRelease, +} from "@umt/contracts"; +import { PLAN_STATUS_LABEL } from "./downloads.ts"; +import { apiParsed, cleanParams } from "./parse.ts"; +import { useRecordsForEntity } from "./records.ts"; + +// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity) +// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。 + +const EnginePage = cursorPage(EngineRun); +const DecisionPage = cursorPage(Decision); + +const RESOLUTION_LABEL = { + unresolved: "未识别", + resolved: "已识别", + ambiguous: "有歧义,待确认", + unidentifiable: "无法识别", + rejected: "已排除", +} as const; + +/** 时间线阶段:observation → plan → download → organization → agent */ +export type TimelineStage = "observation" | "plan" | "download" | "organization" | "agent" | "engine"; +export type TimelineTone = "success" | "warning" | "destructive" | "neutral"; + +export type TimelineEvent = { + key: string; + stage: TimelineStage; + at: string; + title: string; + detail?: string | null; + tone: TimelineTone; + href?: string; + /** 进行中的阶段(不是终态) */ + ongoing?: boolean; +}; + +export function useSourceRelease(id: string | null | undefined) { + return useQuery({ + queryKey: ["source-release", id ?? ""], + enabled: !!id, + queryFn: ({ signal }) => + apiParsed(SourceRelease, "/api/v1/source-releases/:id", { pathParams: { id: id ?? "" }, signal }), + }); +} + +/** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */ +export function useSubjectDecisions(subjectIds: string[]) { + const ids = subjectIds.filter(Boolean); + return useQuery({ + queryKey: ["decisions", "subjects", ids], + enabled: ids.length > 0, + queryFn: ({ signal }) => apiParsed(DecisionPage, "/api/v1/decisions", { params: { limit: 200 }, signal }), + select: (d) => d.items.filter((x) => x.subjectId && ids.includes(x.subjectId)), + }); +} + +/** + * 引擎 run 关联:submitDownload 的 input 带 acquisitionPlanId,verifyAndImport 的 input 带 downloadTaskId。 + * 服务端没有"按业务对象查 run"的端点,这里在时间窗内取少量候选 run 读详情后按 input 精确匹配。 + */ +export function useTraceEngineRuns(opts: { planId?: string | null; downloadTaskId?: string; since?: string }) { + const { planId, downloadTaskId, since } = opts; + return useQuery({ + queryKey: ["engine", "trace", planId ?? "", downloadTaskId ?? "", since ?? ""], + enabled: !!since && (!!planId || !!downloadTaskId), + staleTime: 60_000, + queryFn: async ({ signal }) => { + const from = since ? new Date(new Date(since).getTime() - 10 * 60_000).toISOString() : undefined; + const lists = await Promise.all( + ["submitDownload", "verifyAndImport"].map((workflowName) => + apiParsed(EnginePage, "/api/v1/engine/runs", { + params: cleanParams({ workflowName, from, limit: 15 }), + signal, + }).catch(() => ({ items: [], nextCursor: null })), + ), + ); + const candidates = lists.flatMap((l) => l.items); + const details = await Promise.all( + candidates.map((r) => + apiParsed(EngineRunDetail, "/api/v1/engine/runs/:id", { pathParams: { id: r.id }, signal }).catch(() => null), + ), + ); + return details.filter((d): d is EngineRunDetail => { + if (!d) return false; + const input = (d.input ?? {}) as Record; + return ( + (!!planId && input.acquisitionPlanId === planId) || + (!!downloadTaskId && input.downloadTaskId === downloadTaskId) + ); + }); + }, + }); +} + +const RECORD_TONE: Record = { + queued: "neutral", + running: "neutral", + succeeded: "success", + partial: "warning", + failed: "destructive", + cancelled: "neutral", +}; + +/** 整理记录与下载的关联:同实例 + 源路径落在下载内容路径下(合同里没有 downloadTaskId,只能按路径匹配) */ +export function recordsForDownload(records: PublicOrganizationRecord[], task: DownloadTask) { + const base = (task.contentPath ?? task.savePath ?? "").replace(/\/+$/, ""); + return records.filter( + (r) => + r.source.downloaderInstanceId === task.instanceId && + !!base && + !!r.source.path && + (r.source.path === base || r.source.path.startsWith(`${base}/`)), + ); +} + +/** 汇总一个下载 trace 的业务事件,按时间升序 */ +export function buildTimeline(input: { + task: DownloadTask; + release?: SourceRelease | null; + plan?: AcquisitionPlan | null; + records?: PublicOrganizationRecord[]; + decisions?: Decision[]; + engineRuns?: EngineRunDetail[]; +}): TimelineEvent[] { + const { task, release, plan, records = [], decisions = [], engineRuns = [] } = input; + const ev: TimelineEvent[] = []; + if (release) { + ev.push({ + key: "obs", + stage: "observation", + at: release.firstSeenAt, + title: "首次发现资源", + detail: release.title, + tone: "neutral", + }); + if (release.resolutionStatus !== "unresolved") + ev.push({ + key: "resolve", + stage: "observation", + // 合同里没有识别完成时间,挂在首次发现之后,只表达结果 + at: release.firstSeenAt, + title: + release.resolutionStatus === "resolved" + ? "识别结果:已关联本地作品" + : `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`, + tone: release.resolutionStatus === "resolved" ? "success" : "warning", + }); + } + if (plan) { + ev.push({ + key: "plan", + stage: "plan", + at: plan.createdAt, + title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`, + detail: plan.statusReason, + tone: "neutral", + }); + if (plan.updatedAt !== plan.createdAt) + ev.push({ + key: "plan-status", + stage: "plan", + at: plan.updatedAt, + title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`, + detail: plan.statusReason, + tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral", + }); + } + ev.push({ + key: "dl-submit", + stage: "download", + at: task.createdAt, + title: task.origin === "adopted" ? "接管已有下载" : "提交下载", + detail: task.name, + tone: "neutral", + }); + if (task.pauseReason) + ev.push({ + key: "dl-pause", + stage: "download", + at: task.updatedAt, + title: task.pauseReason === "threshold" ? "缓存达到硬阈值,系统暂停" : `已暂停(${task.pauseReason})`, + tone: "warning", + ongoing: task.status === "paused", + }); + if (task.noProgressSince) + ev.push({ + key: "dl-stall", + stage: "download", + at: task.noProgressSince, + title: "无进展(只记录,不自动换源)", + tone: "warning", + ongoing: task.status === "stalled", + }); + if (task.completedAt) + ev.push({ key: "dl-done", stage: "download", at: task.completedAt, title: "下载完成", tone: "success" }); + else if (task.status === "downloading") + ev.push({ + key: "dl-progress", + stage: "download", + at: task.lastProgressAt ?? task.updatedAt, + title: `下载中 ${(task.progress * 100).toFixed(1)}%`, + tone: "neutral", + ongoing: true, + }); + if (task.lastError) + ev.push({ + key: "dl-err", + stage: "download", + at: task.updatedAt, + title: "下载器报错", + detail: String((task.lastError as { message?: unknown }).message ?? JSON.stringify(task.lastError)), + tone: "destructive", + }); + for (const r of records) { + const c = r.counts; + ev.push({ + key: `rec-${r.id}`, + stage: "organization", + at: r.startedAt ?? r.createdAt, + title: `整理 ${r.action === "copy" ? "Copy" : r.action === "move" ? "Move" : r.action}:${r.status}`, + detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`, + tone: RECORD_TONE[r.status], + href: `/console/organize/${r.id}`, + ongoing: r.status === "running" || r.status === "queued", + }); + } + for (const d of decisions) { + ev.push({ + key: `dec-${d.id}`, + stage: "agent", + at: d.createdAt, + title: `待处理:${d.title}`, + detail: d.summary, + tone: d.severity === "critical" ? "destructive" : "warning", + href: "/console/inbox", + }); + if (d.agentRunId) + ev.push({ + key: `agent-${d.id}`, + stage: "agent", + at: d.updatedAt, + title: "Agent 已介入调查", + tone: "neutral", + href: `/console/agent/${d.agentRunId}`, + }); + } + for (const r of engineRuns) { + ev.push({ + key: `eng-${r.id}`, + stage: "engine", + at: r.startedAt ?? r.createdAt, + title: `引擎 ${r.workflowName}:${r.status}`, + detail: `${r.stepAttempts.length} 个步骤尝试 · 第 ${r.attempts} 次`, + tone: + r.status === "failed" + ? "destructive" + : r.status === "completed" || r.status === "succeeded" + ? "success" + : "neutral", + href: `/console/engine/${encodeURIComponent(r.id)}`, + ongoing: r.status === "running" || r.status === "pending" || r.status === "sleeping", + }); + } + return ev.sort((a, b) => a.at.localeCompare(b.at)); +} + +/** 一站式取 trace 数据 */ +export function useDownloadTrace(task: DownloadTask | undefined, plan: AcquisitionPlan | null | undefined) { + const release = useSourceRelease(task?.sourceReleaseId ?? plan?.sourceReleaseId); + const entityId = release.data?.mediaEntityId ?? null; + const records = useRecordsForEntity(entityId); + const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]); + const engine = useTraceEngineRuns({ + planId: task?.acquisitionPlanId, + downloadTaskId: task?.id, + since: plan?.createdAt ?? task?.createdAt, + }); + const matched = task ? recordsForDownload(records.data?.items ?? [], task) : []; + return { + release, + records, + decisions, + engine, + events: task + ? buildTimeline({ + task, + release: release.data, + plan, + records: matched, + decisions: decisions.data, + engineRuns: engine.data, + }) + : [], + loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading, + }; +} diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx new file mode 100644 index 00000000..9e47a33a --- /dev/null +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -0,0 +1,449 @@ +import type { AgentRunStatus } from "@umt/contracts"; +import { + Bot, + CheckCircle2, + ChevronRight, + CircleDashed, + CircleX, + Layers, + LoaderCircle, + ShieldAlert, + User, + Wrench, +} from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { + type AgentRunView, + type GoalReport, + isRunActive, + REPORT_STATE, + RUN_STATUS_LABEL, + RUN_STATUS_TONE, +} from "~/api/agent.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { cn } from "~/lib/utils.ts"; +import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; + +const BUDGET_LABEL: Record = { + tool_calls: "工具调用次数", + duration: "运行时长", + tokens: "token", + cost: "费用", +}; +const COMPACT_REASON: Record = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" }; + +function durationText(a: string, b: string | null) { + if (!b) return null; + const ms = new Date(b).getTime() - new Date(a).getTime(); + return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; +} + +/** 摘要块:长文本在块内滚动,不撑坏布局 */ +function Summary({ label, text, tone }: { label: string; text: string; tone?: "destructive" }) { + return ( +
+

{label}

+
+        {text}
+      
+
+ ); +} + +const TOOL_STATUS: Record< + ToolCallView["status"], + { label: string; tone: "success" | "destructive" | "warning" | "neutral" } +> = { + running: { label: "执行中", tone: "neutral" }, + succeeded: { label: "成功", tone: "success" }, + failed: { label: "失败", tone: "destructive" }, + denied: { label: "已拒绝", tone: "destructive" }, +}; + +/** 工具调用卡片:默认折叠,展开看脱敏输入/输出 */ +export function ToolCallCard({ call }: { call: ToolCallView }) { + const [open, setOpen] = useState(false); + const st = TOOL_STATUS[call.status]; + if (call.status === "denied") { + return ( +
+
+ + 服务端拒绝调用 + {call.capabilityId} +
+

{call.reason ?? "未授权"}

+

授权校验在服务端完成;Agent 没有绕过的途径。

+
+ ); + } + const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2; + return ( +
+ + {!open && call.outputSummary ? ( +

{call.outputSummary}

+ ) : null} + {open ? ( +
+ {call.inputSummary ? : null} + {call.outputSummary ? ( + + ) : call.status === "running" ? ( +

等待结果…

+ ) : null} +
+ ) : null} +
+ ); +} + +function StreamBlock({ item }: { item: StreamItem }) { + const [open, setOpen] = useState(false); + switch (item.kind) { + case "text": + return ( +
+ + + +

+ {item.text} +

+
+ ); + case "thinking": + return ( +
+ + {open ? ( +

+ {item.text} +

+ ) : null} +
+ ); + case "tool": + return ( +
+ +
+ ); + case "compaction": + return ( +
+ + + 上下文压缩({COMPACT_REASON[item.reason] ?? item.reason}) + {item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"} + +
+ ); + case "end": + if (item.ok) return null; + return ( +
+

+ {item.reason === "budget" + ? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""}` + : item.reason === "aborted" + ? "已中止" + : "运行失败"} +

+

{item.message}

+
+ ); + } +} + +function Meter({ + label, + used, + max, + format, +}: { + label: string; + used: number | undefined; + max?: number; + format: (n: number) => string; +}) { + const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null; + return ( +
+
+ {label} + + {used === undefined ? "未报告" : format(used)} + {max ? / {format(max)} : null} + +
+
+ {pct !== null ? ( +
= 90 ? "bg-warning" : "bg-foreground/60")} + style={{ width: `${pct}%` }} + /> + ) : null} +
+
+ ); +} + +/** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */ +export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) { + const u = { ...run.usage, ...(live.usage ?? {}) }; + const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); + const durationMs = + u.durationMs ?? + (run.startedAt + ? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime() + : undefined); + return ( +
+ String(n)} /> + (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))} + /> + `${n}s`} + /> + `$${n.toFixed(3)}`} /> +
+ ); +} + +/** 最终汇报:模型说的 vs 系统核实的(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ +export function FinalReport({ + run, + report, + live, +}: { + run: AgentRunView; + report: GoalReport | null; + live: AgentStreamState; +}) { + const lastTool = live.items.map((i) => i.kind).lastIndexOf("tool"); + const tail = live.items + .slice(lastTool + 1) + .filter((i): i is Extract => i.kind === "text") + .map((i) => i.text) + .join(""); + const said = report?.modelText ?? (tail || run.summary || ""); + const calls = live.items + .filter((i): i is Extract => i.kind === "tool") + .map((i) => i.call); + const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length; + const state = report ? REPORT_STATE[report.state] : null; + + return ( +
+
+

+ + 模型说的 +

+

+ {said || "(没有文字汇报)"} +

+

仅供参考,不作为完成依据。

+
+
+

+ + 系统核实的结果 +

+
+ {state ? {state.label} : null} + 运行{RUN_STATUS_LABEL[run.status]} + 工具成功 {count("succeeded")} + {count("failed") ? 失败 {count("failed")} : null} + {count("denied") ? 被拒绝 {count("denied")} : null} +
+ {report ? ( + <> +

{report.summary}

+
+ {( + [ + ["已提交", report.submitted], + ["已入库", report.imported], + ["进行中", report.inProgress], + ["失败", report.failed], + ] as const + ).map(([k, v]) => ( +
+
{v}
+
{k}
+
+ ))} +
+
    + {report.downloads.map((d) => ( +
  • + + 下载 · {d.name ?? d.downloadTaskId.slice(0, 8)} + + · {d.status} +
  • + ))} + {report.organization.map((o) => ( +
  • + + 整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)} + + · {o.status} +
  • + ))} +
+ + ) : ( +

服务端汇报尚未生成;以任务页、整理记录的实际状态为准。

+ )} + {run.error ? ( +

+ {String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))} +

+ ) : null} +
+
+ ); +} + +/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */ +export function AgentConversation({ + run, + live, + userMessages = [], + compact = false, +}: { + run: AgentRunView; + live: AgentStreamState; + userMessages?: { at: string; text: string; pending?: boolean }[]; + compact?: boolean; +}) { + const endRef = useRef(null); + const active = isRunActive(run.status) && !live.finished; + const count = live.items.length; + // biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底 + useEffect(() => { + if (active) endRef.current?.scrollIntoView({ block: "nearest" }); + }, [count, active]); + + // 追加的用户消息按时间插入 + type Row = { key: string; at: string; node: ReactNode }; + const rows: Row[] = live.items.map((it) => ({ + key: it.key, + at: it.kind === "tool" ? it.call.startedAt : it.at, + node: , + })); + for (const m of userMessages) + rows.push({ + key: `u${m.at}`, + at: m.at, + node: , + }); + rows.sort((a, b) => a.at.localeCompare(b.at)); + + return ( +
+ + {rows.map((r) => ( +
{r.node}
+ ))} + {active ? ( +
+ + {run.status === "waiting" ? "等待中…" : "正在处理…"} +
+ ) : null} + {live.error ?

事件读取失败:{live.error}(稍后自动重试)

: null} +
+
+ ); +} + +function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) { + return ( +
+
+ {mode === "plan_only" ?

只要方案

: null} +

{text}

+ {note ?

{note}

: null} +
+ + + +
+ ); +} + +export function RunStatusBadge({ status }: { status: AgentRunStatus }) { + return ( + + {isRunActive(status) ? : null} + {RUN_STATUS_LABEL[status]} + + ); +} diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx new file mode 100644 index 00000000..f717beec --- /dev/null +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -0,0 +1,95 @@ +import { Bot, ExternalLink, Plus } from "lucide-react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { useCreateAgentRun } from "~/api/agent.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; +import { + contextPrefix, + openAgentDrawer, + setAgentDrawerOpen, + setAgentDrawerRun, + useAgentDrawer, +} from "./agent-store.ts"; + +/** + * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。 + * 挂载:在 console layout 里渲染一次 ;顶栏放 。 + * 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。 + */ +export function AgentDrawer() { + const { open, context, runId } = useAgentDrawer(); + const create = useCreateAgentRun(); + const prefix = contextPrefix(context); + + return ( + + + + + + Agent + + + {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"} + + +
+ {runId ? ( + + ) : ( + + create.mutate( + { goal: `${prefix}${goal}`, mode }, + { + onSuccess: (run) => setAgentDrawerRun(run.id), + onError: (e) => toast.error(errorMessage(e)), + }, + ) + } + /> + )} +
+
+ {runId ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +} + +/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */ +export function AgentDrawerTrigger() { + return ( + + ); +} diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx new file mode 100644 index 00000000..94b96027 --- /dev/null +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -0,0 +1,212 @@ +import { Send, Square } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + type AgentMode, + agentMockEnabled, + isRunActive, + useAbortAgentRun, + useAgentRun, + useAppendAgentMessage, +} from "~/api/agent.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn, formatDateTime } from "~/lib/utils.ts"; +import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx"; +import { useAgentStream } from "./use-agent-stream.ts"; + +const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const; + +/** 目标输入:execute / plan_only 分段切换 + 文本框 */ +export function GoalComposer({ + onSubmit, + pending, + prefix, + defaultGoal = "", + compact = false, +}: { + onSubmit: (goal: string, mode: AgentMode) => void; + pending: boolean; + /** 上下文前缀(只读展示) */ + prefix?: string; + defaultGoal?: string; + compact?: boolean; +}) { + const [goal, setGoal] = useState(defaultGoal); + const [mode, setMode] = useState("execute"); + const submit = () => { + const g = goal.trim(); + if (!g) { + toast.error("先写下目标"); + return; + } + onSubmit(g, mode); + }; + return ( +
{ + e.preventDefault(); + submit(); + }} + > + {prefix ? ( +

+ {prefix} +

+ ) : null} +