From 1023642ef945c6690c4f5bcac91e3d88b867d3df Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 21:32:11 +0800 Subject: [PATCH] feat(web): W4-A wire console to real endpoints - Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status; stream auto-follow only while the user stays near the bottom (abort stays reachable) - settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript) - download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of time-window engine run matching and path matching; new business-task tab with cancel/retry - workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons - preference tag revision history; subscription search by title/kind; identity/alias/subscription lists show entity titles; slot cards show current asset summary; policy preview sample titles - DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary / OrganizePlanArtifactContent taken from contracts - docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/agent.ts | 77 ++---- apps/web/app/api/decisions.ts | 27 +-- apps/web/app/api/identity.ts | 22 +- apps/web/app/api/policies.ts | 16 +- apps/web/app/api/preferences.ts | 11 + apps/web/app/api/records.ts | 46 +--- apps/web/app/api/releases.ts | 21 +- apps/web/app/api/slots.ts | 2 +- apps/web/app/api/subscriptions.ts | 9 +- apps/web/app/api/tasks.ts | 152 ++++++------ .../components/agent/agent-conversation.tsx | 16 +- .../app/components/agent/agent-run-panel.tsx | 22 +- apps/web/app/components/agent/mock.ts | 16 +- .../components/downloads/business-tasks.tsx | 226 ++++++++++++++++++ .../downloads/business-timeline.tsx | 2 +- .../components/downloads/organize-dialog.tsx | 2 +- .../components/library/identity-aliases.tsx | 6 +- .../library/subscriptions-policy.tsx | 7 +- .../library/subscriptions-slots.tsx | 13 +- .../components/workbench/download-dialog.tsx | 49 ++-- apps/web/app/routes/console/agent.tsx | 16 +- apps/web/app/routes/console/identity.tsx | 10 + apps/web/app/routes/console/preferences.tsx | 42 ++++ .../app/routes/console/settings/prefs-tab.tsx | 61 ++++- apps/web/app/routes/console/subscriptions.tsx | 42 +++- apps/web/app/routes/console/tasks.tsx | 5 + docs/runbook/agent.md | 8 +- docs/runbook/auth.md | 2 + docs/runbook/dev.md | 3 +- packages/contracts/src/organization.ts | 43 ++++ 30 files changed, 703 insertions(+), 271 deletions(-) create mode 100644 apps/web/app/components/downloads/business-tasks.tsx diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index a380bf89..56edb3b6 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -1,75 +1,38 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + AgentGoalReport, AgentRun, + AgentRunDetail, type AgentRunEvent, - AgentRunEventRow, + type AgentRunMessage, + AgentRunMode, 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 同形;在这里宽松镜像一份, -// 合同字段调整时页面仍能解析(新增字段忽略,缺失的可选字段按未知显示)。 +// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort), +// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。 +// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互。 export const AGENT_BASE = "/api/v1/agent/runs"; /** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */ -export const AgentMode = 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 AgentMode = AgentRunMode; +export type AgentMode = AgentRunMode; +export type AgentRunView = AgentRun; /** 服务端从业务表汇总的目标完成度(不采信模型文本) */ -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 const GoalReport = AgentGoalReport; +export type GoalReport = AgentGoalReport; +export type RunMessage = AgentRunMessage; +export type RunDetail = AgentRunDetail; export type RunSnapshot = Omit; -const RunPage = cursorPage(AgentRunView); +const RunPage = cursorPage(AgentRun); export function agentMockEnabled(): boolean { if (import.meta.env.VITE_AGENT_MOCK === "1") return true; @@ -118,7 +81,7 @@ export function useAgentRun(id: string | undefined) { enabled: !!id, queryFn: async ({ signal }): Promise => { if (mock) return mockDetail(id ?? ""); - const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, { + const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, { pathParams: { id: id ?? "" }, params: { afterSeq: NO_EVENTS, limit: 1 }, signal, @@ -135,7 +98,7 @@ export async function fetchRunEvents(id: string, after: number, signal?: AbortSi const out: AgentRunEvent[] = []; let cursor = after; for (let page = 0; page < 20; page++) { - const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, { + const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, { pathParams: { id }, params: { afterSeq: cursor, limit: 500 }, signal, @@ -158,7 +121,7 @@ export function useCreateAgentRun() { 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)); + return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRun.parse(d)); }, onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }), }); @@ -170,7 +133,7 @@ export function useAbortAgentRun() { 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), + (d) => AgentRun.parse(d), ); }, onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }), @@ -183,7 +146,7 @@ export function useAppendAgentMessage() { 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), + AgentRun.parse(d), ); }, onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.id) }), diff --git a/apps/web/app/api/decisions.ts b/apps/web/app/api/decisions.ts index 41042c28..92e919a3 100644 --- a/apps/web/app/api/decisions.ts +++ b/apps/web/app/api/decisions.ts @@ -1,6 +1,12 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { cursorPage, Decision, DecisionKind, type DecisionResolveInput, type DecisionStatus, Id } from "@umt/contracts"; -import { z } from "zod"; +import { + cursorPage, + Decision, + type DecisionKind, + type DecisionResolveInput, + type DecisionStatus, + DecisionSummary, +} from "@umt/contracts"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; @@ -14,21 +20,8 @@ export const decisionKeys = { export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind }; -/** 服务端 /decisions/summary 的形状(路由内定义,未进 contracts) */ -export const DecisionSummary = z.object({ - total: z.number().int(), - groups: z.array( - z.object({ - kind: DecisionKind, - mediaEntityId: Id.nullable(), - mediaEntityTitle: z.string().nullable(), - count: z.number().int(), - maxSeverity: z.enum(["info", "warning", "critical"]), - latestAt: z.string(), - }), - ), -}); -export type DecisionSummary = z.infer; +/** /decisions/summary(合同 DecisionSummary) */ +export { DecisionSummary }; const Page = cursorPage(Decision); diff --git a/apps/web/app/api/identity.ts b/apps/web/app/api/identity.ts index 5833a9ba..e203792a 100644 --- a/apps/web/app/api/identity.ts +++ b/apps/web/app/api/identity.ts @@ -5,13 +5,14 @@ import { type IdentityCandidateReason, type IdentityCandidateStatus, IdentityPoolItem, - IdentityRevision, + IdentityRevisionList, type IdentityStatus, type ReleaseIdentityAssign, + ReleaseIdentityResult, + TaskAccepted, TitleAlias, type TitleAliasCreate, } from "@umt/contracts"; -import { z } from "zod"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; @@ -32,8 +33,6 @@ export type AliasFilters = { mediaEntityId?: string; q?: string }; const PoolPage = cursorPage(IdentityPoolItem); const IdentityPage = cursorPage(ExternalIdentity); const AliasPage = cursorPage(TitleAlias); -/** 服务端返回 { items }(无 nextCursor);兼容数组与分页两种形状 */ -const RevisionList = z.union([z.array(IdentityRevision), z.object({ items: z.array(IdentityRevision) })]); export function useIdentityPool(filters: PoolFilters) { return useInfiniteQuery({ @@ -61,10 +60,8 @@ export function useIdentityRevisions(id: string | undefined) { return useQuery({ queryKey: identityKeys.revisions(id ?? ""), enabled: !!id, - queryFn: async ({ signal }) => { - const r = await getParsed(RevisionList, `/external-identities/${id}/revisions`, undefined, signal); - return Array.isArray(r) ? r : r.items; - }, + queryFn: async ({ signal }) => + (await getParsed(IdentityRevisionList, `/external-identities/${id}/revisions`, undefined, signal)).items, }); } @@ -94,7 +91,9 @@ export function useAssignPool() { const done = useInvalidate(); return useMutation({ mutationFn: ({ id, ...body }: ReleaseIdentityAssign & { id: string }) => - api.post(`/identity-pool/${id}/assign`, body), + api + .post(`/identity-pool/${id}/assign`, body) + .then((d) => parseOrThrow(ReleaseIdentityResult, d, "/identity-pool/assign")), onSuccess: done, }); } @@ -111,7 +110,10 @@ export function useDismissPool() { export function useRetryPool() { const done = useInvalidate(); return useMutation({ - mutationFn: (id: string) => api.post(`/identity-pool/${id}/retry`), + mutationFn: (id: string) => + api + .post(`/identity-pool/${id}/retry`) + .then((d) => parseOrThrow(TaskAccepted, d, "/identity-pool/retry")), onSuccess: done, }); } diff --git a/apps/web/app/api/policies.ts b/apps/web/app/api/policies.ts index e156e40e..a6850ae1 100644 --- a/apps/web/app/api/policies.ts +++ b/apps/web/app/api/policies.ts @@ -1,12 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cursorPage, + PolicyPreviewSummary, PolicyRevision, type PolicyRevisionCreate, type PolicyRevisionUpdate, type PolicyStatus, } from "@umt/contracts"; -import { z } from "zod"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; @@ -28,17 +28,9 @@ export function usePolicyRevisions(filters: PolicyFilters = {}) { }); } -/** 预览结果(服务端 previewPolicy 的形状;contracts 里是 JsonObject) */ -export const PolicyPreview = z.object({ - evaluatedAt: z.string(), - sampleSize: z.number(), - templates: z.number(), - matched: z.number(), - excluded: z.number(), - samples: z.array(z.object({ sourceReleaseId: z.string(), template: z.string(), decision: z.string() })), - valid: z.boolean(), -}); -export type PolicyPreview = z.infer; +/** 预览结果(合同 PolicyPreviewSummary;存在 policy_revision.preview) */ +export const PolicyPreview = PolicyPreviewSummary; +export type PolicyPreview = PolicyPreviewSummary; const parse = (d: unknown) => parseOrThrow(PolicyRevision, d, "/policy-revisions"); diff --git a/apps/web/app/api/preferences.ts b/apps/web/app/api/preferences.ts index 2cd4ab62..bbdb09f7 100644 --- a/apps/web/app/api/preferences.ts +++ b/apps/web/app/api/preferences.ts @@ -6,6 +6,7 @@ import { PreferenceSuppression, PreferenceTag, type PreferenceTagCreate, + PreferenceTagRevisionList, type PreferenceTagUpdate, } from "@umt/contracts"; import { api } from "~/lib/api.ts"; @@ -45,6 +46,16 @@ export function useSuppressions(active?: boolean) { }); } +/** 修订历史(审计事件回放,新→旧) */ +export function useTagRevisions(id: string | null | undefined) { + return useQuery({ + queryKey: ["preferences", "revisions", id ?? ""], + enabled: !!id, + queryFn: async ({ signal }) => + (await getParsed(PreferenceTagRevisionList, `/preference-tags/${id}/revisions`, undefined, signal)).items, + }); +} + function useInvalidate() { const qc = useQueryClient(); return () => void qc.invalidateQueries({ queryKey: preferenceKeys.all }); diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 529726cd..1bb58dd2 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -7,10 +7,10 @@ import { type OrganizationStatus, type OrganizationTrigger, OrganizeAccepted, + OrganizePlanArtifactContent, type OrganizeRequest, PublicOrganizationRecord, } from "@umt/contracts"; -import { z } from "zod"; import { TTL_SECOND } from "~/lib/api.ts"; import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; @@ -109,7 +109,7 @@ export const RECORD_STATUS_TONE: Record = { auto: "自动", manual: "手动", agent: "Agent" }; -/** dryRun 生成的整理计划冻结为 artifact(kind=plan);读取端点未提供时返回 not_found,由页面说明 */ +/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */ export function useArtifact(id: string | null | undefined) { return useQuery({ queryKey: ["artifact", id ?? ""], @@ -119,45 +119,9 @@ export function useArtifact(id: string | null | undefined) { }); } -/** 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; +/** dryRun 整理计划 artifact 的内容形状(合同:OrganizePlanArtifactContent) */ +export const OrganizePlanPreview = OrganizePlanArtifactContent; +export type OrganizePlanPreview = OrganizePlanArtifactContent; /** 整理目标库根 */ export function useLibraryRoots() { diff --git a/apps/web/app/api/releases.ts b/apps/web/app/api/releases.ts index dfcd3e2d..7ec8ddc6 100644 --- a/apps/web/app/api/releases.ts +++ b/apps/web/app/api/releases.ts @@ -1,5 +1,10 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AcquisitionPlan, type ResolutionStatus, WorkbenchReleaseDetail, WorkbenchReleasePage } from "@umt/contracts"; +import { + ManualAcquisitionResult, + type ResolutionStatus, + WorkbenchReleaseDetail, + WorkbenchReleasePage, +} from "@umt/contracts"; import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; // 站点工作台整理视图(W2-B:/sites/{id}/releases、/sites/{id}/releases/{rid}) @@ -68,12 +73,18 @@ export function useRefreshReleaseDetail() { } /** - * 统一下载入口:与自动收录同一条提交流程(计划 → 去重 → 缓存硬阈值 → 实例选择 → 管理标签)。 - * 后端对应端点 POST /acquisition-plans(sourceReleaseId + 可选 slotId);未提供时返回 not_found,页面给出说明。 + * 统一下载入口 POST /acquisition-plans:与自动收录同一条流程(download.plan → download.submit: + * 硬条件/预算/缓存阈值/授权 → 预留 Slot → submitDownload)。未生成计划时 submitted=false 并给出逐个 Slot 的原因。 */ export function useSubmitRelease() { + const qc = useQueryClient(); return useMutation({ - mutationFn: (body: { sourceReleaseId: string; slotId?: string; note?: string }) => - apiMutate("/api/v1/acquisition-plans", { body }).then((d) => AcquisitionPlan.parse(d)), + mutationFn: (body: { sourceReleaseId: string; slotId?: string; entityId?: string; note?: string }) => + apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)), + onSuccess: (r) => { + if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] }); + void qc.invalidateQueries({ queryKey: ["inventory"] }); + void qc.invalidateQueries({ queryKey: ["library"] }); + }, }); } diff --git a/apps/web/app/api/slots.ts b/apps/web/app/api/slots.ts index 1360604f..c4069d8b 100644 --- a/apps/web/app/api/slots.ts +++ b/apps/web/app/api/slots.ts @@ -16,7 +16,7 @@ export const slotKeys = { list: (f: SlotFilters) => ["slots", "list", f] as const, }; -export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus }; +export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus; subscriptionId?: string }; const Page = cursorPage(AcquisitionSlot); diff --git a/apps/web/app/api/subscriptions.ts b/apps/web/app/api/subscriptions.ts index 9fb4688b..2c39e93b 100644 --- a/apps/web/app/api/subscriptions.ts +++ b/apps/web/app/api/subscriptions.ts @@ -4,6 +4,7 @@ import { MediaEntity, Subscription, type SubscriptionCreate, + type SubscriptionMediaKind, type SubscriptionStatus, type SubscriptionUpdate, } from "@umt/contracts"; @@ -17,7 +18,13 @@ export const subscriptionKeys = { list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const, }; -export type SubscriptionFilters = { status?: SubscriptionStatus; mediaEntityId?: string }; +export type SubscriptionFilters = { + status?: SubscriptionStatus; + mediaEntityId?: string; + mediaKind?: SubscriptionMediaKind; + /** 标题 / 原名 / 别名 / 未建档检索词 */ + q?: string; +}; const Page = cursorPage(Subscription); diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index 11f1511d..7f88e20a 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -4,19 +4,21 @@ import { cursorPage, Decision, type DownloadTask, - EngineRun, - EngineRunDetail, - type PublicOrganizationRecord, + PublicOrganizationRecord, SourceRelease, + Task, + type TaskKind, + type TaskStatus, } from "@umt/contracts"; import { PLAN_STATUS_LABEL } from "./downloads.ts"; -import { apiParsed, cleanParams } from "./parse.ts"; -import { useRecordsForEntity } from "./records.ts"; +import { apiParsed } from "./parse.ts"; -// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity) -// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。 +// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity) +// 关联键全部由服务端给出:plan →(/tasks?subjectId=planId)submitDownload → download_task +// →(/tasks?subjectId=downloadTaskId)verifyAndImport →(/organization-records?downloadTaskId=)整理记录 → decision/agent_run。 -const EnginePage = cursorPage(EngineRun); +const TaskPage = cursorPage(Task); +const RecordPage = cursorPage(PublicOrganizationRecord); const DecisionPage = cursorPage(Decision); const RESOLUTION_LABEL = { @@ -63,44 +65,55 @@ export function useSubjectDecisions(subjectIds: string[]) { }); } -/** - * 引擎 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; +/** 业务任务:submitDownload 的 subject 是计划,verifyAndImport 的 subject 是下载任务(服务端写入 task 表) */ +export function useTraceTasks(opts: { planId?: string | null; downloadTaskId?: string }) { + const ids = [opts.planId, opts.downloadTaskId].filter((x): x is string => !!x); 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 })), - ), + queryKey: ["tasks", "trace", ids], + enabled: ids.length > 0, + queryFn: async ({ signal }): Promise => { + const pages = await Promise.all( + ids.map((subjectId) => apiParsed(TaskPage, "/api/v1/tasks", { params: { subjectId, limit: 50 }, signal })), ); - 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) - ); - }); + return pages.flatMap((p) => p.items); }, + refetchInterval: (q) => (q.state.data?.some((t) => ACTIVE_TASK.includes(t.status)) ? 5_000 : false), }); } +const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"]; + +/** 这个下载产生的整理记录(服务端按 download_task_id 关联) */ +export function useRecordsForDownload(downloadTaskId: string | undefined) { + return useQuery({ + queryKey: ["records", "download", downloadTaskId ?? ""], + enabled: !!downloadTaskId, + queryFn: ({ signal }) => + apiParsed(RecordPage, "/api/v1/organization-records", { + params: { downloadTaskId: downloadTaskId ?? "", limit: 50 }, + signal, + }), + }); +} + +const TASK_KIND_LABEL: Partial> = { + submitDownload: "提交下载", + verifyAndImport: "核验入库", + evaluateAcquisition: "评估候选", + cleanupDownloadCache: "清理缓存", + resolveDecision: "处理待办", +}; + +const TASK_STATUS_TONE: Record = { + pending: "neutral", + running: "neutral", + waiting: "warning", + succeeded: "success", + failed: "destructive", + cancelled: "neutral", + dead: "destructive", +}; + const RECORD_TONE: Record = { queued: "neutral", running: "neutral", @@ -110,18 +123,6 @@ const RECORD_TONE: Record = { 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; @@ -129,9 +130,9 @@ export function buildTimeline(input: { plan?: AcquisitionPlan | null; records?: PublicOrganizationRecord[]; decisions?: Decision[]; - engineRuns?: EngineRunDetail[]; + tasks?: Task[]; }): TimelineEvent[] { - const { task, release, plan, records = [], decisions = [], engineRuns = [] } = input; + const { task, release, plan, records = [], decisions = [], tasks = [] } = input; const ev: TimelineEvent[] = []; if (release) { ev.push({ @@ -253,21 +254,20 @@ export function buildTimeline(input: { href: `/console/agent/${d.agentRunId}`, }); } - for (const r of engineRuns) { + for (const t of tasks) { + const err = t.lastError + ? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError)) + : null; ev.push({ - key: `eng-${r.id}`, + key: `task-${t.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", + at: t.startedAt ?? t.createdAt, + title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${t.status}`, + detail: + [t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null, + tone: TASK_STATUS_TONE[t.status], + ...(t.engineRunId ? { href: `/console/engine/${encodeURIComponent(t.engineRunId)}` } : {}), + ongoing: ACTIVE_TASK.includes(t.status), }); } return ev.sort((a, b) => a.at.localeCompare(b.at)); @@ -276,30 +276,24 @@ export function buildTimeline(input: { /** 一站式取 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 records = useRecordsForDownload(task?.id); 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) : []; + const tasks = useTraceTasks({ planId: task?.acquisitionPlanId, downloadTaskId: task?.id }); return { release, records, decisions, - engine, + tasks, events: task ? buildTimeline({ task, release: release.data, plan, - records: matched, + records: records.data?.items ?? [], decisions: decisions.data, - engineRuns: engine.data, + tasks: tasks.data, }) : [], - loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading, + loading: release.isLoading || records.isLoading || decisions.isLoading || tasks.isLoading, }; } diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 9e47a33a..4aae7063 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -372,6 +372,9 @@ export function FinalReport({ } /** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */ +/** 末尾标记离视口底部不超过这么多像素算"停在底部" */ +const FOLLOW_SLACK_PX = 160; + export function AgentConversation({ run, live, @@ -386,9 +389,20 @@ export function AgentConversation({ const endRef = useRef(null); const active = isRunActive(run.status) && !live.finished; const count = live.items.length; + // 有新内容时跟随到底,但只在用户停在底部附近时跟随:往上翻(去点"中止"、看早先的工具卡)后不再把视口拽回去, + // 翻回底部附近自动恢复。捕获阶段监听 scroll,侧栏抽屉里的滚动容器也算。 + const stick = useRef(true); + useEffect(() => { + const onScroll = () => { + const el = endRef.current; + if (el) stick.current = el.getBoundingClientRect().top - window.innerHeight < FOLLOW_SLACK_PX; + }; + document.addEventListener("scroll", onScroll, { capture: true, passive: true }); + return () => document.removeEventListener("scroll", onScroll, { capture: true }); + }, []); // biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底 useEffect(() => { - if (active) endRef.current?.scrollIntoView({ block: "nearest" }); + if (active && stick.current) endRef.current?.scrollIntoView({ block: "nearest" }); }, [count, active]); // 追加的用户消息按时间插入 diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index 94b96027..7193b354 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -1,8 +1,11 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { EventType } from "@umt/contracts"; import { Send, Square } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { type AgentMode, + agentKeys, agentMockEnabled, isRunActive, useAbortAgentRun, @@ -15,6 +18,7 @@ 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 { useSse } from "~/lib/sse.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx"; import { useAgentStream } from "./use-agent-stream.ts"; @@ -120,6 +124,16 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa const abort = useAbortAgentRun(); const append = useAppendAgentMessage(); const [msg, setMsg] = useState(""); + const qc = useQueryClient(); + // run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询 + useSse( + [EventType.agentRunStatus], + (e) => { + if ((e.payload as { runId?: string } | null)?.runId === runId) + void qc.invalidateQueries({ queryKey: agentKeys.run(runId) }); + }, + !agentMockEnabled(), + ); if (run.isPending) return ; if (run.error || !run.data) return void run.refetch()} />; @@ -129,7 +143,13 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa return (
- + 0) + ? "running" + : r.status + } + /> {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)} {r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""} diff --git a/apps/web/app/components/agent/mock.ts b/apps/web/app/components/agent/mock.ts index 4e8359c9..afb9ef4a 100644 --- a/apps/web/app/components/agent/mock.ts +++ b/apps/web/app/components/agent/mock.ts @@ -288,9 +288,12 @@ export function mockDetail(id: string, op?: { abort?: boolean; append?: string } const at = Date.now() - e.startedAt; e.appended.push({ id: uuid(9000 + e.appended.length), + runId: id, createdAt: iso(Date.now()), + updatedAt: iso(Date.now()), text: op.append, status: "pending", + createdBy: "user:mock", consumedAt: null, }); const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed"); @@ -325,9 +328,18 @@ function report(e: Entry, run: AgentRunView): GoalReport | null { imported: 0, inProgress: submitted ? 1 : 0, failed: 0, - plans: submitted ? [{ planId: uuid(7001), status: "submitted" }] : [], + plans: submitted + ? [{ planId: uuid(7001), status: "submitted", slotId: uuid(7003), sourceReleaseId: uuid(7004) }] + : [], downloads: submitted - ? [{ downloadTaskId: uuid(7002), status: "downloading", name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX" }] + ? [ + { + downloadTaskId: uuid(7002), + planId: uuid(7001), + status: "downloading", + name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX", + }, + ] : [], organization: [], modelText: text || null, diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx new file mode 100644 index 00000000..d8e24671 --- /dev/null +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -0,0 +1,226 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { cursorPage, Task, type TaskKind, type TaskStatus } from "@umt/contracts"; +import { ListTodo, RotateCcw, Square } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { apiMutate, apiParsed, cleanParams } from "~/api/parse.ts"; +import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; +import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { relTime } from "~/components/workbench/release-fields.ts"; +import { errorMessage, TTL_SECOND } from "~/lib/api.ts"; + +// 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。 + +const TaskPage = cursorPage(Task); + +export const TASK_KIND_LABEL: Record = { + ingestFeed: "抓取 Feed", + resolveObservation: "识别资源", + evaluateAcquisition: "评估候选", + submitDownload: "提交下载", + verifyAndImport: "核验入库", + refreshMetadata: "刷新元数据", + reconcileLibrary: "资产对账", + cleanupDownloadCache: "清理缓存", + resolveDecision: "处理待办", + agentRun: "Agent 运行", +}; + +export const TASK_STATUS: Record< + TaskStatus, + { label: string; tone: "success" | "warning" | "destructive" | "neutral" } +> = { + pending: { label: "排队", tone: "neutral" }, + running: { label: "运行中", tone: "neutral" }, + waiting: { label: "等待", tone: "warning" }, + succeeded: { label: "成功", tone: "success" }, + failed: { label: "失败", tone: "destructive" }, + cancelled: { label: "已取消", tone: "neutral" }, + dead: { label: "放弃", tone: "destructive" }, +}; + +const ACTIVE: TaskStatus[] = ["pending", "running", "waiting"]; + +function subjectLink(t: Task): string | null { + if (!t.subjectId) return null; + if (t.subjectType === "download_task") return `/console/tasks/${t.subjectId}`; + if (t.subjectType === "organization_record") return `/console/organize/${t.subjectId}`; + if (t.subjectType === "media_entity") return `/console/library/${t.subjectId}`; + return null; +} + +export function BusinessTasks() { + const [kind, setKind] = useState(""); + const [status, setStatus] = useState(""); + const qc = useQueryClient(); + const list = useInfiniteQuery({ + queryKey: ["tasks", "list", kind, status], + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + apiParsed(TaskPage, "/api/v1/tasks", { + params: cleanParams({ kind, status, cursor: pageParam, limit: 50 }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + refetchInterval: (q) => + q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 5 * TTL_SECOND : false, + }); + const action = useMutation({ + mutationFn: ({ id, op }: { id: string; op: "cancel" | "retry" }) => + apiMutate(`/api/v1/tasks/:id/${op}`, { + pathParams: { id }, + body: op === "cancel" ? { reason: "控制台取消" } : {}, + }).then((d) => Task.parse(d)), + onSuccess: (_t, v) => { + toast.success(v.op === "cancel" ? "已请求取消(只停后续步骤)" : "已重新排队"); + void qc.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (e) => toast.error(errorMessage(e)), + }); + const rows = list.data?.pages.flatMap((p) => p.items); + + const columns: DataColumn[] = [ + { + key: "kind", + header: "任务", + cell: (t) => { + const href = subjectLink(t); + return ( +
+

{TASK_KIND_LABEL[t.kind] ?? t.kind}

+ {href ? ( + + {t.subjectType} · {t.subjectId?.slice(0, 8)} + + ) : ( +

{t.subjectType ?? "—"}

+ )} +
+ ); + }, + }, + { + key: "status", + header: "状态", + cell: (t) => ( +
+ {TASK_STATUS[t.status].label} + {t.waitReason ? {t.waitReason} : null} +
+ ), + }, + { + key: "attempts", + header: "尝试", + hideBelow: "md", + align: "right", + cell: (t) => ( + + {t.attempts}/{t.maxAttempts} + + ), + }, + { + key: "by", + header: "触发", + hideBelow: "lg", + cell: (t) => {t.triggeredBy ?? "—"}, + }, + { + key: "at", + header: "创建", + hideBelow: "sm", + cell: (t) => {relTime(t.createdAt)}, + }, + { + key: "ops", + header: "", + align: "right", + cell: (t) => ( +
+ {t.engineRunId ? ( + + ) : null} + {ACTIVE.includes(t.status) ? ( + + ) : null} + {t.status === "failed" || t.status === "dead" ? ( + + ) : null} +
+ ), + }, + ]; + + return ( + <> + { + setKind(""); + setStatus(""); + } + : undefined + } + > + ({ value, label }))} + /> + ({ value, label: v.label }))} + /> + + t.id} + loading={list.isPending} + error={list.error} + onRetry={() => void list.refetch()} + hasMore={list.hasNextPage} + onLoadMore={() => void list.fetchNextPage()} + loadingMore={list.isFetchingNextPage} + empty={{ + icon: ListTodo, + title: kind || status ? "没有符合筛选的任务" : "还没有业务任务", + description: "提交下载、核验入库、缓存清理、对账等后台工作都会记在这里。", + }} + /> + + ); +} diff --git a/apps/web/app/components/downloads/business-timeline.tsx b/apps/web/app/components/downloads/business-timeline.tsx index da41b092..55b20cfd 100644 --- a/apps/web/app/components/downloads/business-timeline.tsx +++ b/apps/web/app/components/downloads/business-timeline.tsx @@ -14,7 +14,7 @@ export const STAGE_LABEL: Record = { download: "下载", organization: "整理", agent: "Agent", - engine: "引擎", + engine: "任务", }; const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"]; diff --git a/apps/web/app/components/downloads/organize-dialog.tsx b/apps/web/app/components/downloads/organize-dialog.tsx index d73a3128..8054d9f8 100644 --- a/apps/web/app/components/downloads/organize-dialog.tsx +++ b/apps/web/app/components/downloads/organize-dialog.tsx @@ -45,7 +45,7 @@ function PlanPreview({ planId }: { planId: string }) {

计划已生成(planId {planId.slice(0, 8)}…), {isMissingEndpoint(art.error) - ? "但后端还没有提供读取计划内容的端点(GET /artifacts/{id}),无法逐文件预览。" + ? "计划内容不存在或已过期,请重新预览。" : `读取计划失败:${errorMessage(art.error)}`}

); diff --git a/apps/web/app/components/library/identity-aliases.tsx b/apps/web/app/components/library/identity-aliases.tsx index a3001373..091f0405 100644 --- a/apps/web/app/components/library/identity-aliases.tsx +++ b/apps/web/app/components/library/identity-aliases.tsx @@ -152,10 +152,10 @@ export function AliasesPanel() { cell: (a) => ( - 查看 - + {a.mediaEntityTitle ?? "查看"} + ), }, diff --git a/apps/web/app/components/library/subscriptions-policy.tsx b/apps/web/app/components/library/subscriptions-policy.tsx index 2c8ab873..3c392277 100644 --- a/apps/web/app/components/library/subscriptions-policy.tsx +++ b/apps/web/app/components/library/subscriptions-policy.tsx @@ -576,7 +576,12 @@ function PreviewBlock({ value }: { value: JsonObject | null }) { > {s.template} - {s.sourceReleaseId} + + {s.title ?? s.sourceReleaseId} + ))} diff --git a/apps/web/app/components/library/subscriptions-slots.tsx b/apps/web/app/components/library/subscriptions-slots.tsx index 0709f8a4..38cf51df 100644 --- a/apps/web/app/components/library/subscriptions-slots.tsx +++ b/apps/web/app/components/library/subscriptions-slots.tsx @@ -11,6 +11,7 @@ import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { ApiRequestError, errorMessage } from "~/lib/api.ts"; +import { formatBytes } from "~/lib/utils.ts"; import { SlotCard } from "./slot-card.tsx"; import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx"; @@ -75,7 +76,17 @@ export function SlotsPanel({ key={s.id} slot={s} currentAsset={ - s.currentAssetId ? {s.currentAssetId} : undefined + s.currentAsset ? ( + + {[s.currentAsset.resolution, s.currentAsset.sourceType, s.currentAsset.releaseGroup] + .filter(Boolean) + .join(" · ") || "交付特征未知"} + {s.currentAsset.sizeBytes ? ` · ${formatBytes(s.currentAsset.sizeBytes, 1)}` : ""} + {s.currentAsset.status !== "active" ? ` · ${s.currentAsset.status}` : ""} + + ) : s.currentAssetId ? ( + {s.currentAssetId} + ) : undefined } actions={ - diff --git a/apps/web/app/routes/console/agent.tsx b/apps/web/app/routes/console/agent.tsx index 81c1cb42..3e674b28 100644 --- a/apps/web/app/routes/console/agent.tsx +++ b/apps/web/app/routes/console/agent.tsx @@ -4,7 +4,6 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts"; -import { isMissingEndpoint } from "~/api/parse.ts"; import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx"; import { GoalComposer } from "~/components/agent/agent-run-panel.tsx"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; @@ -47,7 +46,6 @@ export default function AgentPage() { const create = useCreateAgentRun(); const navigate = useNavigate(); const mock = agentMockEnabled(); - const missing = !mock && isMissingEndpoint(runs.error); const rows = runs.data?.pages.flatMap((p) => p.items); return ( @@ -56,12 +54,12 @@ export default function AgentPage() { title="Agent 会话" description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。" /> - {missing ? ( + {mock ? (
- Agent 会话端点还没上线(W2-D 实现中)。可以先用 mock 数据预览交互。 -
) : null} @@ -89,14 +87,14 @@ export default function AgentPage() { r.id} loading={runs.isPending} - error={missing ? undefined : runs.error} + error={runs.error} onRetry={() => void runs.refetch()} empty={{ icon: Bot, - title: missing ? "会话端点未就绪" : "还没有会话", + title: "还没有会话", description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。", }} onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)} diff --git a/apps/web/app/routes/console/identity.tsx b/apps/web/app/routes/console/identity.tsx index 9163fd7a..f941cef5 100644 --- a/apps/web/app/routes/console/identity.tsx +++ b/apps/web/app/routes/console/identity.tsx @@ -166,6 +166,16 @@ function ExternalTab() {
), }, + { + key: "entity", + header: "作品", + hideBelow: "sm", + cell: (r) => ( + + {r.mediaEntityTitle ?? 未关联} + + ), + }, { key: "status", header: "状态", cell: (r) => }, { key: "level", diff --git a/apps/web/app/routes/console/preferences.tsx b/apps/web/app/routes/console/preferences.tsx index 7b9738eb..780bd713 100644 --- a/apps/web/app/routes/console/preferences.tsx +++ b/apps/web/app/routes/console/preferences.tsx @@ -7,6 +7,7 @@ import { useLiftSuppression, usePreferenceTags, useSuppressions, + useTagRevisions, useUpdateTag, } from "~/api/preferences.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; @@ -315,6 +316,9 @@ export default function PreferencesPage() { + + + ) : null} @@ -324,6 +328,44 @@ export default function PreferencesPage() { ); } +const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const; +const FIELD_LABEL: Record = { + label: "名称", + kind: "类型", + scope: "作用范围", + rule: "规则", + description: "说明", + evidence: "证据", + status: "状态", +}; + +function TagRevisions({ id }: { id: string }) { + const q = useTagRevisions(id); + if (q.isPending) return ; + if (q.isError) return q.refetch()} />; + if (q.data.length === 0) + return

没有修订记录(W4 之前创建的标签不带历史)。

; + return ( +
    + {q.data.map((r) => ( +
  • +
    + {REV_ACTION[r.action]} + {r.revision ? r{r.revision} : null} + {r.actorType === "api_key" ? "API Key" : "用户"} + {formatDateTime(r.at)} +
    +

    + {r.changedFields.length + ? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}` + : `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`} +

    +
  • + ))} +
+ ); +} + function SuppressionRow({ s, label, diff --git a/apps/web/app/routes/console/settings/prefs-tab.tsx b/apps/web/app/routes/console/settings/prefs-tab.tsx index 34a37584..6576fbac 100644 --- a/apps/web/app/routes/console/settings/prefs-tab.tsx +++ b/apps/web/app/routes/console/settings/prefs-tab.tsx @@ -124,7 +124,17 @@ const PI_CARD: CardSpec = { title: "Pi 上游与模型", description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。", fields: [ - { name: "pi.provider", label: "Provider", kind: "text", required: true, placeholder: "anthropic" }, + { + name: "pi.provider", + label: "Provider", + kind: "select", + required: true, + options: [ + { value: "anthropic", label: "anthropic" }, + { value: "openai-compatible", label: "openai-compatible(baseUrl 带 /v1)" }, + { value: "fake", label: "fake(脚本化假模型,不联网,演示用)" }, + ], + }, { name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" }, { name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true }, { name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true }, @@ -216,11 +226,58 @@ const AGENT_BUDGET_CARD: CardSpec = { }), }; +const AGENT_RUNTIME_CARD: CardSpec = { + title: "Agent 运行", + description: "单次运行预算、后台自动调查;provider 选 fake 时按下面的脚本回放工具调用(留空用内置演示脚本)。", + fields: [ + { name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1 – 500" }, + { name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" }, + { name: "agent.autoInvestigate", label: "待处理事项自动调查", kind: "switch" }, + { + name: "agent.investigateMaxToolCalls", + label: "自动调查工具调用上限", + kind: "number", + visible: (v) => v["agent.autoInvestigate"] === true, + }, + { name: "agent.goalPollSec", label: "目标落定轮询(秒)", kind: "number" }, + { name: "agent.goalWaitHours", label: "目标最长等待(小时)", kind: "number" }, + { + name: "agent.fakeScript", + label: "fake 脚本(JSON)", + kind: "textarea", + hint: '{"match":"收进来","steps":[{"toolCalls":[{"capability":"inventory.query","input":{"query":"沙丘2"}}]}],"finalText":"…"},格式见 docs/runbook/agent.md', + }, + ], + fromSettings: (s) => ({ + "agent.maxToolCalls": n(s.agent.maxToolCalls), + "agent.maxDurationSec": n(s.agent.maxDurationSec), + "agent.autoInvestigate": s.agent.autoInvestigate, + "agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls), + "agent.goalPollSec": n(s.agent.goalPollSec), + "agent.goalWaitHours": n(s.agent.goalWaitHours), + "agent.fakeScript": s.agent.fakeScript ?? "", + }), + toPatch: (v) => ({ + agent: { + maxToolCalls: num(v["agent.maxToolCalls"]), + maxDurationSec: num(v["agent.maxDurationSec"]), + autoInvestigate: v["agent.autoInvestigate"] === true, + investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]), + goalPollSec: num(v["agent.goalPollSec"]), + goalWaitHours: num(v["agent.goalWaitHours"]), + fakeScript: str(v["agent.fakeScript"]).trim() || null, + }, + }), +}; + export function PiTab() { return useSettingsGate((s) => (
- +
+ + +
)); } diff --git a/apps/web/app/routes/console/subscriptions.tsx b/apps/web/app/routes/console/subscriptions.tsx index 3434eeaa..abb78eec 100644 --- a/apps/web/app/routes/console/subscriptions.tsx +++ b/apps/web/app/routes/console/subscriptions.tsx @@ -9,7 +9,7 @@ import { type SubscriptionStatus, } from "@umt/contracts"; import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react"; -import { useId, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useEntitySearch } from "~/api/library.ts"; @@ -115,8 +115,20 @@ export default function SubscriptionsPage() { // ── 订阅列表 ── +function useDebounced(v: string, ms = 300) { + const [d, setD] = useState(v); + useEffect(() => { + const t = setTimeout(() => setD(v), ms); + return () => clearTimeout(t); + }, [v, ms]); + return d; +} + function EntityTitle({ sub }: { sub: Subscription }) { - const q = useEntityTitle(sub.mediaEntityId); + // 列表响应已带 mediaEntityTitle;没有时(旧数据/详情)再按 ID 取 + const q = useEntityTitle(sub.mediaEntityTitle ? null : sub.mediaEntityId); + if (sub.mediaEntityId && sub.mediaEntityTitle) + return {sub.mediaEntityTitle}; if (!sub.mediaEntityId) return ( @@ -137,7 +149,15 @@ function EntityTitle({ sub }: { sub: Subscription }) { function SubscriptionsTab() { const [status, setStatus] = useState(""); - const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"] }; + const [kind, setKind] = useState(""); + const [search, setSearch] = useState(""); + const dq = useDebounced(search.trim()); + const filters: SubscriptionFilters = { + status: (status || undefined) as SubscriptionFilters["status"], + mediaKind: (kind || undefined) as SubscriptionFilters["mediaKind"], + q: dq || undefined, + }; + const filtered = !!status || !!kind || !!search; const q = useSubscriptions(filters); const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]); const [openId, setOpenId] = useState(null); @@ -186,8 +206,20 @@ function SubscriptionsTab() { return ( <> - setStatus("") : undefined}> + { + setStatus(""); + setKind(""); + setSearch(""); + } + : undefined + } + > + diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx index 58abcab7..3c088fc4 100644 --- a/apps/web/app/routes/console/tasks.tsx +++ b/apps/web/app/routes/console/tasks.tsx @@ -18,6 +18,7 @@ import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { BusinessTasks } from "~/components/downloads/business-tasks.tsx"; import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx"; import { DownloadDetail, @@ -171,6 +172,7 @@ export default function TasksPage() { 下载任务 下载计划 + 业务任务 + + + ` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。 +**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=&limit=` 取 JSON 页轮询(见 auth.md「SSE 多进程总线」)。 + +控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出)。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。 ## runAgent 做什么 diff --git a/docs/runbook/auth.md b/docs/runbook/auth.md index dc8e5cbf..62195662 100644 --- a/docs/runbook/auth.md +++ b/docs/runbook/auth.md @@ -79,5 +79,7 @@ update admin_session set revoked_at = now(), revoke_reason = 'reset' where revok - `PgEventBus`(`http/sse-pg.ts`):`publish` 在事务里持咨询锁插 `sse_event`(seq = bigserial,提交顺序即 seq 顺序)并 `pg_notify('umt_events', seq)`;每个进程一条 LISTEN 连接,收到通知按 seq 从表里补读并分发给本进程订阅者;断线自动重连并补读。 - `GET /events?after=`(或 `Last-Event-ID`)从表里补读;表只保留最近 10000 条(每 200 次发布裁剪),起点已被裁剪时先发 `system.resync`。 +- **谁用 SSE,谁用 JSON 轮询**(W4-A 决定):浏览器 `EventSource` 带不了 `Authorization` 头,所以 SSE 只给控制台用(cookie 会话 `umt_session`,同源,`withCredentials`)。外部程序 / API Key 调用者用同一个端点取 JSON 页: + `GET /events?after=&limit=<1-1000>`(带 `limit`,或只发 `Accept: application/json` 时返回 JSON,缺省 100 条),响应 `{ items: EventEnvelope[], nextAfter, hasMore, latestSeq, gap }`。下次用 `after=nextAfter` 续读;`hasMore=true` 时立即再取,否则按自己的节奏轮询;`gap=true` 表示起点已被裁剪,先重拉业务快照再从 `latestSeq` 续读。数据来自同一张 `sse_event` 表,和 SSE 看到的 seq 一致。合同:`Api.events.list`(`EventReplayQuery` / `EventReplayPage`)。 - `ctx.events.publish(type, payload)` 返回 `Promise`,永不 reject(失败记日志),可以不 await。 - 测试/无库场景用进程内 `EventBus`(`publishSync` / `sinceSync` 便于断言)。 diff --git a/docs/runbook/dev.md b/docs/runbook/dev.md index 4d0091d9..9a84aa91 100644 --- a/docs/runbook/dev.md +++ b/docs/runbook/dev.md @@ -22,7 +22,8 @@ deno task dev # 同时起 server(4380)与 web(5180) ```sh curl localhost:4380/api/v1/health # 开发模式可用 .env 的 DEV_ADMIN_KEY 作 admin -curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0" +curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0" # SSE 流(控制台用) +curl -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0&limit=50" # JSON 补读页(API Key 调用者用) ``` - OpenAPI:http://localhost:4380/api/openapi.json,文档页 http://localhost:4380/api/docs diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 14692e7b..556705b4 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -551,3 +551,46 @@ export const Dashboard = z.object({ ), }); export type Dashboard = z.infer; + +/** + * 手动整理 dryRun 冻结的计划 artifact(kind=plan,title=organize_plan)的 content: + * 原请求 + 冻结计划(与 domain OrganizePlan 同形,这里只约束展示字段)。GET /artifacts/{id} 读取。 + */ +export const OrganizePlanArtifactContent = 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 OrganizePlanArtifactContent = z.infer;