diff --git a/apps/web/app/api/attention.ts b/apps/web/app/api/attention.ts new file mode 100644 index 00000000..cef9bde8 --- /dev/null +++ b/apps/web/app/api/attention.ts @@ -0,0 +1,37 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AgentSpend, AttentionSummary } from "@umt/contracts"; +import { useRef } from "react"; +import { useSse } from "~/lib/sse.ts"; +import { apiParsed } from "./parse.ts"; + +// 需要关注的汇总(2026-10-06 去掉待处理中心后):铃铛只放等你批准的智能体操作与系统健康, +// 首页 / 运行状态放汇总计数(整理需要处理的条数等),点进去到对应页面。 + +export const attentionKeys = { all: ["attention"] as const, spend: ["attention", "agent-spend"] as const }; + +/** 事项、整理记录变化时(节流)刷新;另有 60 秒兜底轮询 */ +export function useAttention() { + const qc = useQueryClient(); + const timer = useRef | null>(null); + useSse(["decision.created", "decision.updated", "organization.changed"], () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + void qc.invalidateQueries({ queryKey: attentionKeys.all }); + }, 800); + }); + return useQuery({ + queryKey: attentionKeys.all, + queryFn: ({ signal }) => apiParsed(AttentionSummary, "/api/v1/attention", { signal }), + refetchInterval: 60_000, + }); +} + +/** 智能体用量:今天与最近 7 天按会话类型的次数、token 与费用(不设上限,只展示) */ +export function useAgentSpend() { + return useQuery({ + queryKey: attentionKeys.spend, + queryFn: ({ signal }) => apiParsed(AgentSpend, "/api/v1/agent/usage", { signal }), + refetchInterval: 5 * 60_000, + }); +} diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 7a7b62a9..54001624 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -4,6 +4,7 @@ import { cursorPage, LibraryItem, LibraryRoot, + OrganizationAgentIdentifyResult, type OrganizationDeleteRequest, OrganizationDeleteResult, OrganizationStats, @@ -31,6 +32,8 @@ export type RecordFilters = { /** ISO 时间:只看之后创建的 */ since?: string; organizeOnly?: boolean; + /** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */ + attention?: boolean; }; export const recordKeys = { @@ -51,6 +54,33 @@ export function useRecordStats(f: Omit) { }); } +/** 需要处理的记录:忽略(不再算需要处理,关联事项一并关闭) */ +export function useDismissRecordAttention() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/api/v1/organization-records/${id}/dismiss-attention`, { body: {} }).then((d) => + PublicOrganizationRecord.parse(d), + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: recordKeys.all }); + void qc.invalidateQueries({ queryKey: ["attention"] }); + }, + }); +} + +/** 没识别出作品的记录:让智能体识别(认出来自动重新整理) */ +export function useAgentIdentifyRecord() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/api/v1/organization-records/${id}/agent-identify`, { body: {} }).then((d) => + OrganizationAgentIdentifyResult.parse(d), + ), + onSuccess: () => void qc.invalidateQueries({ queryKey: recordKeys.all }), + }); +} + /** HIS-1:删除整理记录(dryRun 只核对会删什么) */ export function useDeleteRecords() { const qc = useQueryClient(); diff --git a/apps/web/app/components/agent/quick-goals.ts b/apps/web/app/components/agent/quick-goals.ts index a5436bc9..ad4d740c 100644 --- a/apps/web/app/components/agent/quick-goals.ts +++ b/apps/web/app/components/agent/quick-goals.ts @@ -30,16 +30,12 @@ const RULES: Rule[] = [ }, { match: /^\/console\/organize/, - goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"], + goals: ["识别需要处理的整理记录里的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"], }, { match: /^\/console\/files/, goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"], }, - { - match: /^\/console\/inbox/, - goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"], - }, { match: /^\/console\/identity/, goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"], diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx index fb4b70a5..b144a66e 100644 --- a/apps/web/app/components/console/inbox-bell.tsx +++ b/apps/web/app/components/console/inbox-bell.tsx @@ -1,71 +1,89 @@ -import { useQueryClient } from "@tanstack/react-query"; -import type { Decision, DecisionKind } from "@umt/contracts"; +import type { Decision } from "@umt/contracts"; import { ArrowRight, Bell, CheckCheck } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useState } from "react"; import { Link, useNavigate } from "react-router"; -import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts"; +import { useAttention } from "~/api/attention.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { SEVERITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; -import { decisionKindLabel, formatRelative } from "~/lib/labels.ts"; -import { useSse } from "~/lib/sse.ts"; +import { formatRelative } from "~/lib/labels.ts"; import { cn } from "~/lib/utils.ts"; -// 顶栏收件箱(IA-3):待处理不进侧栏,铃铛 + 未决角标;下拉按问题类型列最近事项,点开进待处理页。 -// 角标只算需要你处理的事项(警告及以上):"仅信息"类(巡检报告、旧版本待处置等)不计入,在待处理页可展开查看。 -// 身份类事项已按来源 + 标题合并,一条事项可能代表多条资源(标题里写明 N 条)。 +// 顶栏铃铛(2026-10-06 起只放两类):需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手) +// 与系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线,恢复后自动消失)。 +// 整理问题在整理记录里处理,集数待确认在订阅详情 / 作品页集数映射里看,不进铃铛。 -const RECENT = 8; +const RECENT = 6; const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const; -/** 未决数与最近事项;SSE decision.* 到达时刷新 */ -export function useLiveDecisions() { - const qc = useQueryClient(); - const timer = useRef | null>(null); - useSse(["decision.created", "decision.updated"], () => { - if (timer.current) return; - timer.current = setTimeout(() => { - timer.current = null; - void qc.invalidateQueries({ queryKey: decisionKeys.all }); - }, 800); - }); - return useDecisionSummary(undefined, "warning"); +/** 等你批准的事项点开去哪:智能体会话、下载、下载任务;其余去智能体页 */ +export function approvalHref(d: Pick): string { + if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId); + if (d.agentRunId) return consolePath("agent", d.agentRunId); + if (d.subjectType === "acquisition_plan" && d.subjectId) + return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`; + if (d.subjectType === "download_task" && d.subjectId) return consolePath("downloads", d.subjectId); + return consolePath("agent"); } -export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}` : "/console/inbox"); +export const HEALTH_HREF = `${consolePath("status")}#attention`; + +function Group({ + title, + total, + items, + href, + onGo, +}: { + title: string; + total: number; + items: Decision[]; + href: (d: Decision) => string; + onGo: () => void; +}) { + if (!total) return null; + return ( +
+

+ {title} + {total} +

+
    + {items.slice(0, RECENT).map((d) => ( +
  • + + {d.title} + + + {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} + + +
  • + ))} +
+
+ ); +} export function InboxBell() { - const summary = useLiveDecisions(); + const q = useAttention(); const [open, setOpen] = useState(false); - const list = useDecisions({ minSeverity: "warning" }, open); const navigate = useNavigate(); - const total = summary.data?.total ?? 0; - - const groups = useMemo(() => { - const items = (list.data?.pages[0]?.items ?? []).slice(0, RECENT); - const m = new Map(); - for (const d of items) m.set(d.kind, [...(m.get(d.kind) ?? []), d]); - const counts = new Map(); - for (const g of summary.data?.groups ?? []) counts.set(g.kind, (counts.get(g.kind) ?? 0) + g.count); - return [...m.entries()].map(([kind, ds]) => ({ kind, items: ds, count: counts.get(kind) ?? ds.length })); - }, [list.data, summary.data]); - - const go = (to: string) => { - setOpen(false); - navigate(to); - }; + const approvals = q.data?.approvals ?? { total: 0, items: [] }; + const health = q.data?.health ?? { total: 0, items: [] }; + const total = approvals.total + health.total; + const label = total ? `需要你关注 ${total} 项` : "通知"; return ( - - {list.isPending ? ( + {q.isPending ? ( - ) : list.isError ? ( + ) : q.isError ? ( - ) : groups.length === 0 ? ( - + ) : total === 0 ? ( + ) : (
- {groups.map((g) => ( -
- -
    - {g.items.map((d) => ( -
  • - setOpen(false)} - className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50" - > - {d.title} - - - {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} - - -
  • - ))} -
-
- ))} + setOpen(false)} + /> + HEALTH_HREF} + onGo={() => setOpen(false)} + />
)} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 20a94828..1f59cd1b 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -39,10 +39,18 @@ describe("控制台导航", () => { expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); - it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 身份并入别处", () => { + it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => { const paths = NAV_ITEMS.map((i) => i.to); for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p); - expect(pageTitle("/console/inbox")).toBe("待处理"); + expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理"); + expect(pageTitle("/console/inbox")).toBeUndefined(); + }); + + it("旧待处理链接按问题类型落到对应页面", () => { + expect(legacyRedirect("/console/inbox", "?kind=path_conflict")).toBe("/console/organize?tab=records&attention=1"); + expect(legacyRedirect("/console/inbox", "?kind=download_failed")).toBe("/console/downloads"); + expect(legacyRedirect("/console/inbox", "?kind=space_insufficient")).toBe("/console/status#attention"); + expect(legacyRedirect("/console/inbox", "?kind=unknown_kind")).toBe("/console/organize?tab=records&attention=1"); }); it("每个导航项都有路由", () => { diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 535efe5d..542ff2dc 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -42,7 +42,7 @@ export const ACQUIRE_GROUP_TITLE = "获取"; /** * 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。 * 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。 - * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 + * 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ { @@ -139,7 +139,6 @@ export const NAV_GROUPS: NavGroup[] = [ /** 不在侧栏但有页面的路由:顶栏标题用 */ const EXTRA_TITLES: Record = { - "/console/inbox": "待处理", "/console/preferences": "偏好标签", "/console/search": "搜索", }; diff --git a/apps/web/app/components/dashboard/attention-summary.test.ts b/apps/web/app/components/dashboard/attention-summary.test.ts new file mode 100644 index 00000000..0940bb04 --- /dev/null +++ b/apps/web/app/components/dashboard/attention-summary.test.ts @@ -0,0 +1,87 @@ +import type { AttentionSummary, Decision } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { approvalHref, HEALTH_HREF } from "../console/inbox-bell.tsx"; +import { attentionRows } from "./attention-summary.tsx"; + +// 首页 / 运行状态的「需要关注」汇总计数与铃铛跳转(2026-10-06 去掉待处理中心后)。 + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +const dec = (n: number, over: Partial): Decision => + ({ + id: id(n), + kind: "other", + status: "open", + severity: "warning", + title: "t", + summary: null, + subjectType: null, + subjectId: null, + dedupeKey: `k${n}`, + evidence: {}, + options: [], + resolution: null, + taskId: null, + agentRunId: null, + attempts: 0, + nextReviewAt: null, + resolvedBy: null, + resolvedAt: null, + createdAt: "2026-10-06T00:00:00.000Z", + updatedAt: "2026-10-06T00:00:00.000Z", + ...over, + }) as Decision; + +const empty: AttentionSummary = { organize: 0, approvals: { total: 0, items: [] }, health: { total: 0, items: [] } }; + +describe("需要关注汇总", () => { + it("只列非零项", () => { + expect(attentionRows(empty)).toEqual([]); + expect(attentionRows(undefined)).toEqual([]); + }); + + it("整理需要处理 / 等你批准 / 系统健康按问题归类,点进去到对应页面", () => { + const rows = attentionRows({ + organize: 3, + approvals: { + total: 1, + items: [dec(1, { kind: "approval_required", subjectType: "agent_run", subjectId: id(9) })], + }, + health: { + total: 3, + items: [ + dec(2, { kind: "source_unreachable" }), + dec(3, { kind: "downloader_offline" }), + dec(4, { kind: "other", dedupeKey: "library_root_offline:x" }), + ], + }, + }); + expect(rows.map((r) => [r.text, r.to])).toEqual([ + ["3 条整理需要处理", "/console/organize?tab=records&attention=1"], + ["1 个操作等你批准", `/console/agent/${id(9)}`], + ["1 个站点不可达", HEALTH_HREF], + ["1 个下载器离线", HEALTH_HREF], + ["1 个媒体库目录离线", HEALTH_HREF], + ]); + }); + + it("健康问题超过列出的条数时补一行", () => { + const rows = attentionRows({ ...empty, health: { total: 5, items: [dec(2, { kind: "space_insufficient" })] } }); + expect(rows.map((r) => r.text)).toEqual(["1 个下载盘空间不足", "4 个系统问题"]); + }); +}); + +describe("铃铛跳转", () => { + it("智能体会话 / 下载计划 / 下载任务", () => { + expect(approvalHref({ subjectType: "agent_run", subjectId: id(1), agentRunId: null })).toBe( + `/console/agent/${id(1)}`, + ); + expect(approvalHref({ subjectType: "acquisition_plan", subjectId: id(2), agentRunId: null })).toBe( + `/console/downloads?plan=${id(2)}`, + ); + expect(approvalHref({ subjectType: "download_task", subjectId: id(3), agentRunId: null })).toBe( + `/console/downloads/${id(3)}`, + ); + expect(approvalHref({ subjectType: null, subjectId: null, agentRunId: null })).toBe("/console/agent"); + expect(HEALTH_HREF).toBe("/console/status#attention"); + }); +}); diff --git a/apps/web/app/components/dashboard/attention-summary.tsx b/apps/web/app/components/dashboard/attention-summary.tsx new file mode 100644 index 00000000..b8084234 --- /dev/null +++ b/apps/web/app/components/dashboard/attention-summary.tsx @@ -0,0 +1,78 @@ +import type { AttentionSummary, Decision } from "@umt/contracts"; +import { ChevronRight } from "lucide-react"; +import { Link } from "react-router"; +import { approvalHref, HEALTH_HREF } from "~/components/console/inbox-bell.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { ORGANIZE_ATTENTION_PATH } from "~/routes/console/redirects.ts"; + +// 首页与运行状态共用的「需要关注」汇总计数(2026-10-06 去掉待处理中心后): +// 「3 条整理需要处理」「1 个站点不可达」…,点进去到对应页面;只列非零项。 + +/** 系统健康事项按问题归类成一句(量词 + 说法) */ +function healthKind(d: Pick): { key: string; unit: string } { + if (d.dedupeKey.startsWith("library_root_offline:")) return { key: "library_root", unit: "个媒体库目录离线" }; + switch (d.kind) { + case "source_unreachable": + return { key: d.kind, unit: "个站点不可达" }; + case "downloader_offline": + return { key: d.kind, unit: "个下载器离线" }; + case "cache_volume_unknown": + return { key: d.kind, unit: "个下载目录读不到容量" }; + case "space_insufficient": + return { key: d.kind, unit: "个下载盘空间不足" }; + default: + return { key: "other", unit: "个系统问题" }; + } +} + +export interface AttentionRow { + key: string; + text: string; + to: string; +} + +/** 汇总 → 一行一类(非零才列) */ +export function attentionRows(s: AttentionSummary | undefined): AttentionRow[] { + if (!s) return []; + const rows: AttentionRow[] = []; + if (s.organize) rows.push({ key: "organize", text: `${s.organize} 条整理需要处理`, to: ORGANIZE_ATTENTION_PATH }); + if (s.approvals.total) { + const first = s.approvals.items[0]; + rows.push({ + key: "approvals", + text: `${s.approvals.total} 个操作等你批准`, + to: s.approvals.total === 1 && first ? approvalHref(first) : consolePath("agent"), + }); + } + const counts = new Map(); + for (const d of s.health.items) { + const k = healthKind(d); + const cur = counts.get(k.key); + counts.set(k.key, { unit: k.unit, n: (cur?.n ?? 0) + 1 }); + } + const listed = [...counts.values()].reduce((a, x) => a + x.n, 0); + if (s.health.total > listed) { + const cur = counts.get("other"); + counts.set("other", { unit: "个系统问题", n: (cur?.n ?? 0) + s.health.total - listed }); + } + for (const [key, v] of counts) rows.push({ key, text: `${v.n} ${v.unit}`, to: HEALTH_HREF }); + return rows; +} + +export function AttentionList({ rows }: { rows: AttentionRow[] }) { + return ( +
    + {rows.map((r) => ( +
  • + + {r.text} + + +
  • + ))} +
+ ); +} diff --git a/apps/web/app/components/decisions/decision-actions.tsx b/apps/web/app/components/decisions/decision-actions.tsx new file mode 100644 index 00000000..54b00994 --- /dev/null +++ b/apps/web/app/components/decisions/decision-actions.tsx @@ -0,0 +1,199 @@ +import type { Decision, DecisionOption } from "@umt/contracts"; +import { Search } from "lucide-react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { useResolveDecision } from "~/api/decisions.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { FormField } from "~/components/console/form-field.tsx"; +import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx"; +import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +// 事项(decision)的处理按钮:待处理中心取消后(2026-10-06),问题在发生处就地处理(整理记录行内等), +// 选项与执行仍走 POST /decisions/{id}/resolve(后端注册的执行钩子:指定作品后重新整理、替换 / 保留两者 / 跳过…)。 + +/** 处理按钮只用到事项的这几个字段(整理记录里带的是精简版) */ +export type DecisionLike = Pick; + +const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null); + +/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */ +type Requires = "tmdb" | "mediaEntityId" | null; +function requiresOf(option: DecisionOption): Requires { + const r = option.input?.requires; + return r === "tmdb" || r === "mediaEntityId" ? r : null; +} + +/** 「指定作品」两个选项(本地作品 / TMDB)已并进 AssignWorkButton,其余选项各一个按钮 */ +export function plainOptions(d: DecisionLike): DecisionOption[] { + return d.options.filter((o) => o.key !== "dismiss" && requiresOf(o) === null); +} + +/** 事项带不带「指定作品」(本地作品或 TMDB) */ +export function canAssignWork(d: DecisionLike): boolean { + return d.options.some((o) => requiresOf(o) !== null); +} + +export function OptionButton({ + d, + option, + variant = "outline", +}: { + d: DecisionLike; + option: DecisionOption; + variant?: "outline" | "default"; +}) { + const resolve = useResolveDecision(); + const uid = useId(); + const [note, setNote] = useState(""); + const requires = requiresOf(option); + const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie"); + const [tmdbId, setTmdbId] = useState(""); + const [entity, setEntity] = useState(null); + const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim()); + const identity = + requires === "tmdb" + ? tmdbOk + ? { tmdb: { type: tmdbType, id: tmdbId.trim() } } + : null + : requires === "mediaEntityId" + ? entity + ? { mediaEntityId: entity.id } + : null + : undefined; + return ( + + {option.label} + + } + title={option.label} + description={option.description ? `${option.description}。` : undefined} + confirmLabel={option.label} + confirmDisabled={identity === null} + onConfirm={() => + resolve + .mutateAsync({ + id: d.id, + optionKey: option.key, + note: note.trim() || undefined, + ...(identity ? { identity } : {}), + }) + .then( + () => toast.success(`已处理:${option.label}`), + (err) => { + toast.error(errorMessage(err, "处理失败")); + throw err; + }, + ) + } + > + {requires === "tmdb" ? ( +
+ + + + + setTmdbId(e.target.value)} + aria-invalid={tmdbId.trim() !== "" && !tmdbOk} + /> + +
+ ) : requires === "mediaEntityId" ? ( + + + + ) : null} + +