fix(organize): 展示智能体处理状态并刷新恢复结果
This commit is contained in:
1 parent
86351f20f3
commit
b306d27d08
9 files changed
+172
-12
No files matched your search
@@ -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<typeof import("@tanstack/react-query")>()),
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,10 @@ export function useResolveDecision() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||||
void qc.invalidateQueries({ queryKey: ["identity"] });
|
void qc.invalidateQueries({ queryKey: ["identity"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["records"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["attention"] });
|
||||||
|
// 集数确认会写入作品识别词并重算,预览与已保存的规则要一起刷新。
|
||||||
|
void qc.invalidateQueries({ queryKey: ["episode-mapping"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,11 @@ export type RecordFilters = {
|
|||||||
category?: string;
|
category?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 记录执行结束后,关联智能体仍可能在补全事实;此时也要继续刷新处理状态。 */
|
||||||
|
export function recordNeedsRefresh(r: Pick<PublicOrganizationRecord, "status" | "attention">): boolean {
|
||||||
|
return r.status === "running" || r.status === "queued" || r.attention?.decision?.status === "investigating";
|
||||||
|
}
|
||||||
|
|
||||||
export const recordKeys = {
|
export const recordKeys = {
|
||||||
all: ["records"] as const,
|
all: ["records"] as const,
|
||||||
list: (f: RecordFilters) => ["records", "list", f] as const,
|
list: (f: RecordFilters) => ["records", "list", f] as const,
|
||||||
@@ -114,9 +119,7 @@ export function useRecords(f: RecordFilters) {
|
|||||||
}),
|
}),
|
||||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||||
refetchInterval: (q) =>
|
refetchInterval: (q) =>
|
||||||
q.state.data?.pages.some((p) => p.items.some((r) => r.status === "running" || r.status === "queued"))
|
q.state.data?.pages.some((p) => p.items.some(recordNeedsRefresh)) ? 5 * TTL_SECOND : false,
|
||||||
? 5 * TTL_SECOND
|
|
||||||
: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +132,7 @@ export function useRecord(id: string | undefined) {
|
|||||||
pathParams: { id: id ?? "" },
|
pathParams: { id: id ?? "" },
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
refetchInterval: (q) =>
|
refetchInterval: (q) => (q.state.data && recordNeedsRefresh(q.state.data) ? 3 * TTL_SECOND : false),
|
||||||
q.state.data && (q.state.data.status === "running" || q.state.data.status === "queued") ? 3 * TTL_SECOND : false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { Decision, EpisodeMappingPreview } from "@umt/contracts";
|
import type { Decision, EpisodeMappingPreview } from "@umt/contracts";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
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 ENTITY = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
|
||||||
const decision = (over: Partial<Decision> = {}): Decision =>
|
const decision = (over: Partial<Decision> = {}): 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
EpisodeResultBrief,
|
EpisodeResultBrief,
|
||||||
EpisodeSimulationSummary,
|
EpisodeSimulationSummary,
|
||||||
} from "@umt/contracts";
|
} 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 { useId, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,9 +14,11 @@ import { useResolveDecision } from "~/api/decisions.ts";
|
|||||||
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
import { consolePath } from "~/components/console/nav.ts";
|
import { consolePath } from "~/components/console/nav.ts";
|
||||||
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { humanizeReason } from "~/lib/reasons.ts";
|
||||||
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
|
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
|
||||||
|
|
||||||
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议的那一行作品识别词与它的模拟结果;
|
// 作品页集数映射里的「集数待确认」(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 (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge tone={awaiting ? "warning" : "neutral"}>
|
||||||
|
{investigating ? "智能体正在核对季集" : "需要你确认"}
|
||||||
|
</StatusBadge>
|
||||||
|
{investigating ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
|
||||||
|
{d.agentRunId ? (
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to={consolePath("agent", d.agentRunId)}>查看排查过程</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{suggestion ? (
|
||||||
|
<p className="break-words text-caption text-muted-foreground">{humanizeReason(suggestion)}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。
|
* 集数待确认事项的证据与操作。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;
|
const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<EpisodeDecisionStatus d={d} />
|
||||||
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
|
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
|
||||||
{open ? (
|
{open && d.status !== "investigating" ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{view.confirmAgent && view.agentSuggestion ? (
|
{view.confirmAgent && view.agentSuggestion ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Decision } from "@umt/contracts";
|
import type { Decision } from "@umt/contracts";
|
||||||
import { useDecisions } from "~/api/decisions.ts";
|
import { useDecisions } from "~/api/decisions.ts";
|
||||||
import { humanizeReason } from "~/lib/reasons.ts";
|
import { humanizeReason } from "~/lib/reasons.ts";
|
||||||
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { EpisodeDecision } from "./decision.tsx";
|
import { EpisodeDecision } from "./decision.tsx";
|
||||||
|
|
||||||
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
|
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
|
||||||
@@ -16,6 +18,12 @@ function entityOf(d: Decision): string | null {
|
|||||||
|
|
||||||
/** 某作品(可选按来源季)未关闭的集数待确认事项 */
|
/** 某作品(可选按来源季)未关闭的集数待确认事项 */
|
||||||
export function useEpisodeIssues(entityId: string | null | undefined, season?: number | null) {
|
export function useEpisodeIssues(entityId: string | null | undefined, season?: number | null) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
useSse(["decision.created", "decision.updated"], () => {
|
||||||
|
if (!entityId) return;
|
||||||
|
void qc.invalidateQueries({ queryKey: ["decisions"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["episode-mapping"] });
|
||||||
|
});
|
||||||
const q = useDecisions({ kind: "episode_mapping" }, !!entityId);
|
const q = useDecisions({ kind: "episode_mapping" }, !!entityId);
|
||||||
const items = (q.data?.pages.flatMap((p) => p.items) ?? []).filter(
|
const items = (q.data?.pages.flatMap((p) => p.items) ?? []).filter(
|
||||||
(d) =>
|
(d) =>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createElement, type ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { recordNeedsRefresh } from "~/api/records.ts";
|
||||||
import { attentionLine, primaryAction, RecordAttentionActions } from "./record-attention.tsx";
|
import { attentionLine, primaryAction, RecordAttentionActions } from "./record-attention.tsx";
|
||||||
|
|
||||||
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心):原因一句话 + 推荐动作,一键执行。
|
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心):原因一句话 + 推荐动作,一键执行。
|
||||||
@@ -71,6 +72,28 @@ describe("整理记录行内处理", () => {
|
|||||||
expect(out).not.toContain("指定作品");
|
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("目标已存在:按事项选项给替换 / 保留两者 / 跳过", () => {
|
it("目标已存在:按事项选项给替换 / 保留两者 / 跳过", () => {
|
||||||
const r = rec({
|
const r = rec({
|
||||||
topic: "conflict",
|
topic: "conflict",
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attentio
|
|||||||
export function attentionLine(r: Rec): string | null {
|
export function attentionLine(r: Rec): string | null {
|
||||||
const a = r.attention;
|
const a = r.attention;
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
if (a.decision?.status === "investigating") return "智能体正在识别作品,识别出来后自动重新整理";
|
if (a.decision?.status === "investigating")
|
||||||
|
return a.topic === "identity" ? "智能体正在识别作品,识别出来后自动重新整理" : "智能体正在排查,暂时不用处理";
|
||||||
|
if (a.decision?.status === "awaiting_user") {
|
||||||
|
const recovery = a.decision.evidence.autoRecover as { suggestion?: unknown } | undefined;
|
||||||
|
const suggestion = typeof recovery?.suggestion === "string" ? recovery.suggestion : a.decision.summary;
|
||||||
|
return suggestion ? `需要你确认:${humanizeReason(suggestion)}` : "智能体暂时无法确定,需要你确认";
|
||||||
|
}
|
||||||
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
|
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
|
||||||
return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
|
return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
|
||||||
}
|
}
|
||||||
@@ -66,13 +72,26 @@ export function RecordAttentionActions({
|
|||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link to={consolePath("agent", d.agentRunId)}>
|
<Link to={consolePath("agent", d.agentRunId)}>
|
||||||
<LoaderCircle className="animate-spin" />
|
<LoaderCircle className="animate-spin" />
|
||||||
智能体识别中
|
{a.topic === "identity" ? "智能体识别中" : "智能体处理中"}
|
||||||
|
<ChevronRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : investigating ? (
|
||||||
|
<span role="status" className="inline-flex items-center gap-1.5 text-caption text-muted-foreground">
|
||||||
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
智能体处理中
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!investigating && d?.status === "awaiting_user" && d.agentRunId ? (
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to={consolePath("agent", d.agentRunId)}>
|
||||||
|
查看排查过程
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{primary === "assign" && d && !investigating ? <AssignWorkButton d={d} /> : null}
|
{primary === "assign" && d && !investigating ? <AssignWorkButton d={d} /> : null}
|
||||||
{primary === "options" && d
|
{primary === "options" && d && !investigating
|
||||||
? plainOptions(d).map((o, i) => (
|
? plainOptions(d).map((o, i) => (
|
||||||
<OptionButton key={o.key} d={d} option={o} variant={i === 0 ? "default" : "outline"} />
|
<OptionButton key={o.key} d={d} option={o} variant={i === 0 ? "default" : "outline"} />
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export default function OrganizePage() {
|
|||||||
const records = useRecords({ ...base, status });
|
const records = useRecords({ ...base, status });
|
||||||
const stats = useRecordStats({ ...base, attention: undefined });
|
const stats = useRecordStats({ ...base, attention: undefined });
|
||||||
const roots = useLibraryRoots();
|
const roots = useLibraryRoots();
|
||||||
useSse([EventType.organizationChanged], () => {
|
useSse([EventType.organizationChanged, EventType.decisionCreated, EventType.decisionUpdated], () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["records"] });
|
void qc.invalidateQueries({ queryKey: ["records"] });
|
||||||
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user