diff --git a/apps/web/app/api/decisions.test.ts b/apps/web/app/api/decisions.test.ts new file mode 100644 index 00000000..3a054600 --- /dev/null +++ b/apps/web/app/api/decisions.test.ts @@ -0,0 +1,43 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ client: null as QueryClient | null, onSuccess: null as (() => void) | null })); +vi.mock("@tanstack/react-query", async (original) => ({ + ...(await original()), + useQueryClient: () => state.client, + useMutation: (options: { onSuccess: () => void }) => { + state.onSuccess = options.onSuccess; + return {}; + }, +})); + +import { useResolveDecision } from "./decisions.ts"; + +afterEach(() => { + state.client?.clear(); + state.client = null; + state.onSuccess = null; +}); + +describe("事项处理后的业务状态刷新", () => { + it("指定作品或确认映射后,记录、统计、关注汇总和规则预览都会重新读取", () => { + const client = new QueryClient(); + state.client = client; + const keys = [ + ["decisions", "list"], + ["identity", "entity"], + ["records", "one", "record"], + ["records", "list", { attention: true }], + ["records", "stats", {}], + ["attention"], + ["episode-mapping", "words", "entity"], + ["episode-mapping", "preview", "entity", {}], + ]; + for (const key of keys) client.setQueryData(key, { previous: true }); + client.setQueryData(["settings"], { unrelated: true }); + useResolveDecision(); + state.onSuccess!(); + for (const key of keys) expect(client.getQueryState(key)?.isInvalidated, JSON.stringify(key)).toBe(true); + expect(client.getQueryState(["settings"])?.isInvalidated).toBe(false); + }); +}); diff --git a/apps/web/app/api/decisions.ts b/apps/web/app/api/decisions.ts index 25b0157b..69b03845 100644 --- a/apps/web/app/api/decisions.ts +++ b/apps/web/app/api/decisions.ts @@ -55,6 +55,10 @@ export function useResolveDecision() { onSuccess: () => { void qc.invalidateQueries({ queryKey: decisionKeys.all }); void qc.invalidateQueries({ queryKey: ["identity"] }); + void qc.invalidateQueries({ queryKey: ["records"] }); + void qc.invalidateQueries({ queryKey: ["attention"] }); + // 集数确认会写入作品识别词并重算,预览与已保存的规则要一起刷新。 + void qc.invalidateQueries({ queryKey: ["episode-mapping"] }); }, }); } diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 59799532..5f4423f8 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -42,6 +42,11 @@ export type RecordFilters = { category?: string; }; +/** 记录执行结束后,关联智能体仍可能在补全事实;此时也要继续刷新处理状态。 */ +export function recordNeedsRefresh(r: Pick): boolean { + return r.status === "running" || r.status === "queued" || r.attention?.decision?.status === "investigating"; +} + export const recordKeys = { all: ["records"] as const, list: (f: RecordFilters) => ["records", "list", f] as const, @@ -114,9 +119,7 @@ export function useRecords(f: RecordFilters) { }), 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, + q.state.data?.pages.some((p) => p.items.some(recordNeedsRefresh)) ? 5 * TTL_SECOND : false, }); } @@ -129,8 +132,7 @@ export function useRecord(id: string | undefined) { pathParams: { id: id ?? "" }, signal, }), - refetchInterval: (q) => - q.state.data && (q.state.data.status === "running" || q.state.data.status === "queued") ? 3 * TTL_SECOND : false, + refetchInterval: (q) => (q.state.data && recordNeedsRefresh(q.state.data) ? 3 * TTL_SECOND : false), }); } diff --git a/apps/web/app/components/episode-mapping/decision.test.ts b/apps/web/app/components/episode-mapping/decision.test.ts index 6d4c0d65..300d60fb 100644 --- a/apps/web/app/components/episode-mapping/decision.test.ts +++ b/apps/web/app/components/episode-mapping/decision.test.ts @@ -1,8 +1,15 @@ import type { Decision, EpisodeMappingPreview } from "@umt/contracts"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; -import { agentSimulationText, EpisodeDecisionBody, episodeDecisionView, simulationText } from "./decision.tsx"; +import { + agentSimulationText, + EpisodeDecisionBody, + EpisodeDecisionStatus, + episodeDecisionView, + simulationText, +} from "./decision.tsx"; const ENTITY = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60"; const decision = (over: Partial = {}): Decision => @@ -155,3 +162,27 @@ describe("待处理「集数待确认」", () => { ); }); }); + +describe("季集映射调查状态", () => { + const renderStatus = (d: Decision) => + renderToStaticMarkup(createElement(MemoryRouter, null, createElement(EpisodeDecisionStatus, { d }))); + it("调查中显示状态与已有会话入口,没有会话也能显示进度", () => { + const html = renderStatus(decision({ status: "investigating", agentRunId: "agent-run" })); + expect(html).toContain("智能体正在核对季集"); + expect(html).toContain("/console/agent/agent-run"); + expect(renderStatus(decision({ status: "investigating", agentRunId: null }))).toContain("智能体正在核对季集"); + }); + it("交还人工显示恢复建议,并保留排查入口", () => { + const html = renderStatus( + decision({ + status: "awaiting_user", + agentRunId: "agent-run", + evidence: { autoRecover: { suggestion: "请补充资源站分季详情" } }, + }), + ); + expect(html).toContain("需要你确认"); + expect(html).toContain("请补充资源站分季详情"); + expect(html).toContain("查看排查过程"); + expect(renderStatus(decision())).toBe(""); + }); +}); diff --git a/apps/web/app/components/episode-mapping/decision.tsx b/apps/web/app/components/episode-mapping/decision.tsx index 2069d7d4..7b9e2a80 100644 --- a/apps/web/app/components/episode-mapping/decision.tsx +++ b/apps/web/app/components/episode-mapping/decision.tsx @@ -6,7 +6,7 @@ import type { EpisodeResultBrief, EpisodeSimulationSummary, } from "@umt/contracts"; -import { BellOff, Bot, Check, PencilLine } from "lucide-react"; +import { BellOff, Bot, Check, LoaderCircle, PencilLine } from "lucide-react"; import { useId, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; @@ -14,9 +14,11 @@ import { useResolveDecision } from "~/api/decisions.ts"; import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { consolePath } from "~/components/console/nav.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; +import { humanizeReason } from "~/lib/reasons.ts"; import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts"; // 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议的那一行作品识别词与它的模拟结果; @@ -234,6 +236,33 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) { ); } +/** 在原来的映射事项里显示智能体进度和交还人工的原因。 */ +export function EpisodeDecisionStatus({ d }: { d: Decision }) { + const investigating = d.status === "investigating"; + const awaiting = d.status === "awaiting_user"; + if (!investigating && !awaiting) return null; + const recovery = d.evidence.autoRecover as { suggestion?: unknown } | undefined; + const suggestion = awaiting && typeof recovery?.suggestion === "string" ? recovery.suggestion : d.summary; + return ( +
+
+ + {investigating ? "智能体正在核对季集" : "需要你确认"} + + {investigating ? : null} + {d.agentRunId ? ( + + ) : null} +
+ {suggestion ? ( +

{humanizeReason(suggestion)}

+ ) : null} +
+ ); +} + /** * 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。 */ @@ -260,8 +289,9 @@ export function EpisodeDecision({ d, open, onEdit }: { d: Decision; open: boolea const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null; return (
+ - {open ? ( + {open && d.status !== "investigating" ? (
{view.confirmAgent && view.agentSuggestion ? ( { + if (!entityId) return; + void qc.invalidateQueries({ queryKey: ["decisions"] }); + void qc.invalidateQueries({ queryKey: ["episode-mapping"] }); + }); const q = useDecisions({ kind: "episode_mapping" }, !!entityId); const items = (q.data?.pages.flatMap((p) => p.items) ?? []).filter( (d) => diff --git a/apps/web/app/components/organize/record-attention.test.ts b/apps/web/app/components/organize/record-attention.test.ts index a066015f..5a09c5eb 100644 --- a/apps/web/app/components/organize/record-attention.test.ts +++ b/apps/web/app/components/organize/record-attention.test.ts @@ -4,6 +4,7 @@ import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; +import { recordNeedsRefresh } from "~/api/records.ts"; import { attentionLine, primaryAction, RecordAttentionActions } from "./record-attention.tsx"; // 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心):原因一句话 + 推荐动作,一键执行。 @@ -71,6 +72,28 @@ describe("整理记录行内处理", () => { expect(out).not.toContain("指定作品"); }); + it("未创建会话时仍显示智能体处理中,失败记录持续刷新直到交还人工", () => { + const r = rec({ topic: "identity", decision: decision({ status: "investigating" }), agentIdentify: true }); + expect(html(r)).toContain("智能体处理中"); + expect(html(r)).not.toContain("指定作品"); + expect(recordNeedsRefresh(r)).toBe(true); + const awaiting = rec({ + topic: "identity", + decision: decision({ + status: "awaiting_user", + agentRunId: D, + evidence: { autoRecover: { suggestion: "请确认这两个同名作品" } }, + }), + agentIdentify: true, + }); + expect(attentionLine(awaiting)).toBe("需要你确认:请确认这两个同名作品"); + expect(html(awaiting)).toContain("查看排查过程"); + expect(html(awaiting)).toContain("指定作品"); + expect(recordNeedsRefresh(awaiting)).toBe(false); + expect(recordNeedsRefresh(rec(null, { status: "partial" }))).toBe(false); + expect(recordNeedsRefresh(rec(null, { status: "running" }))).toBe(true); + }); + it("目标已存在:按事项选项给替换 / 保留两者 / 跳过", () => { const r = rec({ topic: "conflict", diff --git a/apps/web/app/components/organize/record-attention.tsx b/apps/web/app/components/organize/record-attention.tsx index a939bd51..510aa4ad 100644 --- a/apps/web/app/components/organize/record-attention.tsx +++ b/apps/web/app/components/organize/record-attention.tsx @@ -27,7 +27,13 @@ type Rec = Pick - 智能体识别中 + {a.topic === "identity" ? "智能体识别中" : "智能体处理中"} + + + + ) : investigating ? ( + + + 智能体处理中 + + ) : null} + {!investigating && d?.status === "awaiting_user" && d.agentRunId ? ( + ) : null} {primary === "assign" && d && !investigating ? : null} - {primary === "options" && d + {primary === "options" && d && !investigating ? plainOptions(d).map((o, i) => ( )) diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index f49ef379..9bcefa01 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -429,7 +429,7 @@ export default function OrganizePage() { const records = useRecords({ ...base, status }); const stats = useRecordStats({ ...base, attention: undefined }); const roots = useLibraryRoots(); - useSse([EventType.organizationChanged], () => { + useSse([EventType.organizationChanged, EventType.decisionCreated, EventType.decisionUpdated], () => { void qc.invalidateQueries({ queryKey: ["records"] }); if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] }); });