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:
rootandClaude Opus 5.5 committed 2026-09-27 20:52:32 +08:00
1 parent 6280d52ec3
commit 7103bc8f7c
35 files changed
+6134 -45

No files matched your search

+222
View File
@@ -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" },
};
+171
View File
@@ -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<DownloadStatus, string> = {
queued: "排队",
submitting: "提交中",
downloading: "下载中",
paused: "已暂停",
stalled: "无进展",
completed: "下载完成",
importing: "入库中",
imported: "已入库",
failed: "失败",
removed: "已移除",
missing: "下载器中缺失",
};
export const DOWNLOAD_STATUS_TONE: Record<DownloadStatus, "success" | "warning" | "destructive" | "neutral"> = {
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<PlanStatus, string> = {
draft: "草稿",
reserved: "已预留",
submitted: "已提交",
completed: "已完成",
cancelled: "已取消",
failed: "失败",
expired: "已过期",
};
+85
View File
@@ -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;
}
+52
View File
@@ -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<string, InventoryEntry>(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 }),
});
}
+39
View File
@@ -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<S extends z.ZodType>(schema: S, path: string, arg?: FetcherArg): Promise<z.infer<S>> {
const r = await apiFetch<unknown>(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<T = unknown>(path: string, arg: FetcherArg = {}): Promise<T> {
const r = await apiFetch<T>(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<string, string | number | boolean | undefined | null>) {
const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v;
return out;
}
+180
View File
@@ -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<OrganizationStatus, string> = {
queued: "排队",
running: "执行中",
succeeded: "成功",
partial: "部分成功",
failed: "失败",
cancelled: "已取消",
};
export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning" | "destructive" | "neutral"> = {
queued: "neutral",
running: "neutral",
succeeded: "success",
partial: "warning",
failed: "destructive",
cancelled: "neutral",
};
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { 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<typeof OrganizePlanPreview>;
/** 整理目标库根 */
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 }),
});
}
+79
View File
@@ -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)),
});
}
+101
View File
@@ -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<Site, "lastSnapshotAt" | "snapshotIntervalSec">, now = Date.now()) {
if (!site.lastSnapshotAt) return true;
const interval = (site.snapshotIntervalSec ?? 3600) * 1000;
return now - new Date(site.lastSnapshotAt).getTime() > interval * 2;
}
+305
View File
@@ -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<string, unknown>;
return (
(!!planId && input.acquisitionPlanId === planId) ||
(!!downloadTaskId && input.downloadTaskId === downloadTaskId)
);
});
},
});
}
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
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,
};
}