feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底
支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
65d6b61d5d
commit
f109a95535
65 files changed
+10342
-245
No files matched your search
@@ -85,7 +85,7 @@ import {
|
||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from "@/core/utils/files";
|
||||
import {
|
||||
downloadMermaidSvgFile,
|
||||
downloadSvgElementAsPng,
|
||||
isMermaidFile,
|
||||
openMermaidSvgInNewTab,
|
||||
renderMermaidToSvg,
|
||||
@@ -428,15 +429,15 @@ export function ArtifactFileDetail({
|
||||
if (streamPerfEnabled && isLiveStreaming) return raw;
|
||||
return desensitize(raw);
|
||||
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
isArtifactContentLoading;
|
||||
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
|
||||
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
|
||||
const streamingWriteContentPending =
|
||||
useWriteFileStreaming && writeFileInFlight && !content;
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
isArtifactContentLoading;
|
||||
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
|
||||
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
|
||||
const streamingWriteContentPending =
|
||||
useWriteFileStreaming && writeFileInFlight && !content;
|
||||
const artifactContentUnavailable =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
@@ -1770,10 +1771,10 @@ export function ArtifactFileDetail({
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||
</div>
|
||||
)}
|
||||
{artifactContentUnavailable && viewMode === "code" && (
|
||||
@@ -1791,7 +1792,7 @@ export function ArtifactFileDetail({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
@@ -2011,6 +2012,9 @@ export function ArtifactFilePreview({
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const mermaidChartRef = useRef<HTMLDivElement>(null);
|
||||
useStreamdownMermaidPngDownload(markdownPreviewRef);
|
||||
const researchSourcesQuery = useResearchSources(
|
||||
language === "markdown" ? (researchSessionId ?? null) : null,
|
||||
);
|
||||
@@ -2135,6 +2139,7 @@ export function ArtifactFilePreview({
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div
|
||||
ref={markdownPreviewRef}
|
||||
data-artifact-preview-scroll
|
||||
data-markdown-anchor-root
|
||||
className={cn(
|
||||
@@ -2199,10 +2204,42 @@ export function ArtifactFilePreview({
|
||||
)}
|
||||
>
|
||||
{mermaidSvg ? (
|
||||
<div
|
||||
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
|
||||
/>
|
||||
<div className="relative mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 pt-12 shadow-sm">
|
||||
<div className="absolute right-3 top-3 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
title="下载图片"
|
||||
onClick={() => {
|
||||
const svg = mermaidChartRef.current?.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) {
|
||||
toast.error("流程图尚未渲染完成,请稍后再下载");
|
||||
return;
|
||||
}
|
||||
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
|
||||
console.error("Failed to download mermaid image:", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载流程图失败");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
title="新窗口打开"
|
||||
onClick={() => {
|
||||
void openMermaidSvgInNewTab(content ?? "").catch((error) => {
|
||||
console.error("Failed to open mermaid preview:", error);
|
||||
toast.error(error instanceof Error ? error.message : "打开流程图失败");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={mermaidChartRef} dangerouslySetInnerHTML={{ __html: mermaidSvg }} />
|
||||
</div>
|
||||
) : mermaidError ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
isInPageAnchor,
|
||||
@@ -57,7 +57,9 @@ export function MarkdownContent({
|
||||
linkClassName,
|
||||
onLinkClick,
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
@@ -138,7 +140,7 @@ export function MarkdownContent({
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div data-markdown-anchor-root className="contents">
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
@@ -136,6 +136,7 @@ export function MessageList({
|
||||
onSetupReportStructureReject,
|
||||
researchApprovalSlot,
|
||||
taskReportImportSlot,
|
||||
businessCardSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
@@ -176,6 +177,14 @@ export function MessageList({
|
||||
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
|
||||
*/
|
||||
taskReportImportSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
|
||||
* `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
|
||||
* 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
|
||||
* 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
|
||||
* 层定义,MessageList 不感知具体业务字段。
|
||||
*/
|
||||
businessCardSlot?: (message: Message) => React.ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
@@ -300,17 +309,17 @@ export function MessageList({
|
||||
}
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() =>
|
||||
messages.some(
|
||||
@@ -692,6 +701,14 @@ export function MessageList({
|
||||
{taskReportImportSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:business-card") {
|
||||
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
|
||||
const message = group.messages[0];
|
||||
return businessCardSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{businessCardSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -786,13 +803,13 @@ export function MessageList({
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
@@ -840,12 +857,12 @@ export function MessageList({
|
||||
.find((message) => message.type === "ai");
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -100,6 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/deep-research",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||
},
|
||||
{
|
||||
id: "workspace-report-collaboration",
|
||||
label: "报告协作",
|
||||
icon: FileText,
|
||||
path: "/page/workspace/report-collaboration",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
|
||||
},
|
||||
// {
|
||||
// id: "workspace-enterprise-research",
|
||||
// label: "企业深度研究",
|
||||
|
||||
@@ -28,6 +28,8 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:
|
||||
|
||||
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
|
||||
|
||||
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -41,6 +43,7 @@ type MessageGroup =
|
||||
| AssistantSetupReportStructureApprovalGroup
|
||||
| AssistantResearchApprovalGroup
|
||||
| AssistantTaskReportImportGroup
|
||||
| AssistantBusinessCardGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -65,7 +68,8 @@ export function groupMessages<T>(
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval" &&
|
||||
last.type !== "assistant:setup-report-structure-approval" &&
|
||||
last.type !== "assistant:research-approval"
|
||||
last.type !== "assistant:research-approval" &&
|
||||
last.type !== "assistant:business-card"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -159,6 +163,17 @@ export function groupMessages<T>(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
|
||||
// (additional_kwargs.report_collaboration_card),由宿主注入并经
|
||||
// businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
|
||||
if (hasBusinessCard(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:business-card",
|
||||
messages: [message],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hasPresentFiles(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@@ -487,6 +502,19 @@ export function hasTaskReportImportApproval(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息
|
||||
* (additional_kwargs.report_collaboration_card),经 MessageList 的
|
||||
* businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
|
||||
*/
|
||||
export function hasBusinessCard(message: Message) {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof message.additional_kwargs?.report_collaboration_card === "object" &&
|
||||
message.additional_kwargs.report_collaboration_card !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { replaceMermaidFencesForWordExport } from "@/core/utils/mermaid";
|
||||
|
||||
const DOCX_MEDIA_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
@@ -38,13 +39,15 @@ export async function markdownToDocxBlob(
|
||||
const source = markdown?.trim();
|
||||
if (!source) throw new Error("文档内容为空");
|
||||
|
||||
const prepared = await replaceMermaidFencesForWordExport(source);
|
||||
const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title?.trim() || undefined,
|
||||
markdown: source,
|
||||
markdown: prepared.markdown,
|
||||
embedded_images: prepared.embeddedImages,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw await responseError(response);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./plugins";
|
||||
export * from "./mermaid-png-download";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { downloadSvgElementAsPng } from "@/core/utils/mermaid";
|
||||
|
||||
function findStreamdownMermaidDownloadBlock(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const button = target.closest("button");
|
||||
if (!(button instanceof HTMLButtonElement)) return null;
|
||||
if (button.getAttribute("title") !== "Download file") return null;
|
||||
const block = button.closest('[data-streamdown="mermaid-block"]');
|
||||
return block instanceof HTMLElement ? block : null;
|
||||
}
|
||||
|
||||
function findRenderedMermaidChartSvg(block: HTMLElement): SVGSVGElement | null {
|
||||
const fromChart = block.querySelector('[aria-label="Mermaid chart"] svg, [role="img"] svg');
|
||||
if (fromChart instanceof SVGSVGElement && !fromChart.closest("button")) {
|
||||
return fromChart;
|
||||
}
|
||||
for (const svg of block.querySelectorAll("svg")) {
|
||||
if (svg instanceof SVGSVGElement && !svg.closest("button")) {
|
||||
return svg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Intercept Streamdown mermaid download buttons and save a PNG of the chart. */
|
||||
export function useStreamdownMermaidPngDownload(rootRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const onClickCapture = (event: MouseEvent) => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !root.contains(event.target as Node)) return;
|
||||
const block = findStreamdownMermaidDownloadBlock(event.target);
|
||||
if (!block) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const svg = findRenderedMermaidChartSvg(block);
|
||||
if (!svg) {
|
||||
toast.error("流程图尚未渲染完成,请稍后再下载");
|
||||
return;
|
||||
}
|
||||
|
||||
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
|
||||
console.error("Failed to download mermaid image:", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载流程图失败");
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", onClickCapture, true);
|
||||
return () => document.removeEventListener("click", onClickCapture, true);
|
||||
}, [rootRef]);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { sanitizeMermaidSource } from "./mermaid";
|
||||
import { findMermaidFenceMatches, getMermaidImageFileName, sanitizeMermaidSource } from "./mermaid";
|
||||
|
||||
test("inserts a missing arrow before a bare edge label", () => {
|
||||
const input = "E --> G|价值输出 V_s| H[PPO 损失]";
|
||||
@@ -69,3 +69,17 @@ test("repairs the DAC-PPO architecture diagram class block", () => {
|
||||
assert.doesNotMatch(output, /class,/);
|
||||
assert.doesNotMatch(output, /input,module/);
|
||||
});
|
||||
|
||||
test("turns mermaid source filenames into png downloads", () => {
|
||||
assert.equal(getMermaidImageFileName("outputs/图2.mmd"), "图2.png");
|
||||
assert.equal(getMermaidImageFileName("chart.mermaid", "png"), "chart.png");
|
||||
assert.equal(getMermaidImageFileName("flowchart.png"), "flowchart.png");
|
||||
});
|
||||
|
||||
test("finds mermaid fences for Word image replacement", () => {
|
||||
const markdown = "前文\n```mermaid\nflowchart TB\nA-->B\n```\n后文\n```mmd\ngraph LR\nC-->D\n```";
|
||||
const fences = findMermaidFenceMatches(markdown);
|
||||
assert.equal(fences.length, 2);
|
||||
assert.equal(fences[0].source, "flowchart TB\nA-->B");
|
||||
assert.equal(fences[1].source, "graph LR\nC-->D");
|
||||
});
|
||||
@@ -96,12 +96,209 @@ function sanitizeMermaidClassStatements(source: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
const MERMAID_FILE_EXT = /\.(mmd|mermaid)$/i;
|
||||
|
||||
export function isMermaidFile(filepath: string): boolean {
|
||||
return /\.mmd$/i.test(filepath);
|
||||
return MERMAID_FILE_EXT.test(filepath);
|
||||
}
|
||||
|
||||
export function getMermaidImageFileName(filepath: string, extension = "svg"): string {
|
||||
return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`);
|
||||
export function getMermaidImageFileName(filepath: string, extension = "png"): string {
|
||||
const name = getFileName(filepath) || "mermaid-diagram";
|
||||
if (MERMAID_FILE_EXT.test(name)) {
|
||||
return name.replace(MERMAID_FILE_EXT, `.${extension}`);
|
||||
}
|
||||
if (/\.(svg|png|jpe?g|webp)$/i.test(name)) {
|
||||
return name.replace(/\.(svg|png|jpe?g|webp)$/i, `.${extension}`);
|
||||
}
|
||||
return `${name}.${extension}`;
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
function readPositiveNumber(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function getSvgExportSize(svg: SVGSVGElement): { width: number; height: number } {
|
||||
const viewBox = svg.viewBox?.baseVal;
|
||||
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
|
||||
return { width: viewBox.width, height: viewBox.height };
|
||||
}
|
||||
const attrWidth = readPositiveNumber(svg.getAttribute("width"));
|
||||
const attrHeight = readPositiveNumber(svg.getAttribute("height"));
|
||||
if (attrWidth && attrHeight) {
|
||||
return { width: attrWidth, height: attrHeight };
|
||||
}
|
||||
try {
|
||||
const box = svg.getBBox();
|
||||
if (box.width > 0 && box.height > 0) {
|
||||
return { width: box.width, height: box.height };
|
||||
}
|
||||
} catch {
|
||||
// Detached SVGs may throw on getBBox().
|
||||
}
|
||||
return {
|
||||
width: Math.max(1, svg.clientWidth),
|
||||
height: Math.max(1, svg.clientHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSvg(svg: SVGSVGElement): string {
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
if (!clone.getAttribute("xmlns")) {
|
||||
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
}
|
||||
if (!clone.getAttribute("xmlns:xlink")) {
|
||||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||||
}
|
||||
return new XMLSerializer().serializeToString(clone);
|
||||
}
|
||||
|
||||
export async function renderSvgElementToPngBlob(svg: SVGSVGElement): Promise<Blob> {
|
||||
const { width, height } = getSvgExportSize(svg);
|
||||
const scale = 2;
|
||||
const markup = serializeSvg(svg);
|
||||
const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("流程图转图片失败"));
|
||||
image.src = objectUrl;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.ceil(width * scale));
|
||||
canvas.height = Math.max(1, Math.ceil(height * scale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("无法创建画布");
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const png = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error("生成 PNG 失败"))),
|
||||
"image/png",
|
||||
);
|
||||
});
|
||||
return png;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSvgElementAsPng(
|
||||
svg: SVGSVGElement,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const png = await renderSvgElementToPngBlob(svg);
|
||||
triggerBlobDownload(png, getMermaidImageFileName(filename, "png"));
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || "");
|
||||
const comma = result.indexOf(",");
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("图片编码失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
const MERMAID_FENCE_RE = /```(?:mermaid|mmd)[^\n]*\r?\n([\s\S]*?)```/gi;
|
||||
|
||||
export function findMermaidFenceMatches(markdown: string): Array<{
|
||||
index: number;
|
||||
length: number;
|
||||
source: string;
|
||||
}> {
|
||||
const matches: Array<{ index: number; length: number; source: string }> = [];
|
||||
const matcher = new RegExp(MERMAID_FENCE_RE.source, MERMAID_FENCE_RE.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(markdown)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
source: (match[1] ?? "").trim(),
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export async function renderMermaidToPngBlob(source: string): Promise<Blob> {
|
||||
const markup = await renderMermaidToSvg(source);
|
||||
const host = document.createElement("div");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-10000px";
|
||||
host.style.top = "-10000px";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.opacity = "0";
|
||||
host.innerHTML = markup;
|
||||
document.body.appendChild(host);
|
||||
try {
|
||||
const svg = host.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) {
|
||||
throw new Error("无法生成流程图图片");
|
||||
}
|
||||
return await renderSvgElementToPngBlob(svg);
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceMermaidFencesForWordExport(markdown: string): Promise<{
|
||||
markdown: string;
|
||||
embeddedImages: Record<string, string>;
|
||||
}> {
|
||||
const fences = findMermaidFenceMatches(markdown);
|
||||
if (fences.length === 0) {
|
||||
return { markdown, embeddedImages: {} };
|
||||
}
|
||||
|
||||
const embeddedImages: Record<string, string> = {};
|
||||
const replacements: Array<{ index: number; length: number; text: string }> = [];
|
||||
|
||||
for (const [offset, fence] of fences.entries()) {
|
||||
if (!fence.source) continue;
|
||||
try {
|
||||
const png = await renderMermaidToPngBlob(fence.source);
|
||||
const id = `mermaid-${offset + 1}`;
|
||||
embeddedImages[id] = await blobToBase64(png);
|
||||
replacements.push({
|
||||
index: fence.index,
|
||||
length: fence.length,
|
||||
text: `\n\n`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to render mermaid for Word export:", error);
|
||||
}
|
||||
}
|
||||
|
||||
let next = markdown;
|
||||
for (const piece of replacements.sort((left, right) => right.index - left.index)) {
|
||||
next = `${next.slice(0, piece.index)}${piece.text}${next.slice(piece.index + piece.length)}`;
|
||||
}
|
||||
return { markdown: next, embeddedImages };
|
||||
}
|
||||
|
||||
async function getMermaidModule() {
|
||||
@@ -152,15 +349,6 @@ export async function openMermaidSvgInNewTab(source: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function downloadMermaidSvgFile(source: string, filepath: string): Promise<void> {
|
||||
const svg = await renderMermaidToSvg(source);
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = getMermaidImageFileName(filepath);
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
const png = await renderMermaidToPngBlob(source);
|
||||
triggerBlobDownload(png, getMermaidImageFileName(filepath, "png"));
|
||||
}
|
||||
@@ -77,6 +77,10 @@ import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEd
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import ReportCollaborationPage, {
|
||||
ReportCollaborationSessionPage,
|
||||
} from '@/report-collaboration/pages/ReportCollaborationPage';
|
||||
import '@/report-collaboration/styles/report-collaboration.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NotebookDetailPageWithKey() {
|
||||
@@ -90,6 +94,29 @@ function NewAgentPageWithKey() {
|
||||
return <NewAgentPage key={thread_id} />;
|
||||
}
|
||||
|
||||
// 报告协作会话页:按 sessionId 重挂(旧 SSE/状态不串台)。
|
||||
function ReportCollaborationSessionRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ReportCollaborationSessionPage key={sessionId} sessionId={sessionId ?? ""} />
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
@@ -728,6 +755,20 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* AgentScope 报告协作工作台:左对话(复用 MessageList)/右画布的独立新页面,
|
||||
不与旧多智能体会商共享状态。key 绑定 sessionId——切换会话重挂干净的页面。 */}
|
||||
<Route
|
||||
path="report-collaboration"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ReportCollaborationPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="report-collaboration/:sessionId"
|
||||
element={<ReportCollaborationSessionRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|
||||
<Route
|
||||
path="roundtable/chain-preview"
|
||||
|
||||
@@ -273,6 +273,12 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
|
||||
|
||||
function isRefusalReportText(value: string): boolean {
|
||||
return REPORT_REFUSAL_PATTERN.test(value.replace(/\s+/g, ""));
|
||||
}
|
||||
|
||||
function researchExportBaseName(session: DeepResearchSession): string {
|
||||
const title = (session.title || session.query || "研究报告")
|
||||
.replace(/[\\/:*?"<>|]/g, "-")
|
||||
@@ -416,6 +422,71 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
|
||||
return unique.size;
|
||||
}
|
||||
|
||||
/** Freeze a compact evidence pool for a worker-independent report job. */
|
||||
function snapshotCollectorMaterials(messages: readonly Message[] | undefined): Record<string, unknown>[] {
|
||||
if (!messages?.length) return [];
|
||||
const frozen: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||
const addCandidate = (candidate: unknown, toolName: string, index: number) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const url = text(item.url) || text(item.source_url) || text(item.link);
|
||||
const id = text(item.id) || text(item.recUuid) || text(item.rec_uuid) || text(item.uuid);
|
||||
const title = text(item.title) || text(item.name) || `检索结果 ${index + 1}`;
|
||||
const content = text(item.content) || text(item.raw_content) || text(item.snippet)
|
||||
|| text(item.content_preview) || text(item.text);
|
||||
if (!url && !id && !content) return;
|
||||
const key = id || url || `${title}\n${content.slice(0, 240)}`;
|
||||
if (seen.has(key) || frozen.length >= 80) return;
|
||||
seen.add(key);
|
||||
frozen.push({
|
||||
id: id || undefined,
|
||||
title: title.slice(0, 500),
|
||||
url: url.slice(0, 2_000) || undefined,
|
||||
raw_content: content.slice(0, 6_000),
|
||||
snippet: content.slice(0, 500) || undefined,
|
||||
source: text(item.source) || toolName || "collector",
|
||||
source_type: toolName === "web_search" ? "web" : "other",
|
||||
published_at: text(item.publish_time) || text(item.published_at) || text(item.time) || undefined,
|
||||
rec_uuid: text(item.recUuid) || text(item.rec_uuid) || undefined,
|
||||
});
|
||||
};
|
||||
const visitPayload = (payload: unknown, toolName: string) => {
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
visitPayload(JSON.parse(payload), toolName);
|
||||
} catch {
|
||||
// A plain-text tool result is represented by the collector summary.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
payload.forEach((item, index) => addCandidate(item, toolName, index));
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const key of ["results", "items", "records", "documents", "hits", "data", "sources"]) {
|
||||
if (Array.isArray(record[key])) {
|
||||
record[key].forEach((item, index) => addCandidate(item, toolName, index));
|
||||
return;
|
||||
}
|
||||
}
|
||||
addCandidate(record, toolName, 0);
|
||||
};
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool") {
|
||||
visitPayload(extractToolResultPayload(message), message.name || "collector_tool");
|
||||
}
|
||||
}
|
||||
// Do not disguise the collector's narrative conclusion as a source. A
|
||||
// summary-only snapshot used to look like one successful material, which
|
||||
// prevented the backend from trying the checkpoint/durable fallback that
|
||||
// actually contains the source rows.
|
||||
return frozen;
|
||||
}
|
||||
|
||||
function countEventMaterials(events: ProgressEvent[]): number {
|
||||
const unique = new Set<string>();
|
||||
for (const event of events) {
|
||||
@@ -585,7 +656,7 @@ function DeepResearchWorkbenchInner() {
|
||||
: "";
|
||||
// 全文改写启动后,右侧只渲染临时草稿:首个 token 到达前保持空白,
|
||||
// 不把旧报告混在新稿前面。真实 report.md 仍只在后台校验 + CAS 成功后替换。
|
||||
const displayedReport = reportDocumentRewrite.isActive
|
||||
const rawDisplayedReport = reportDocumentRewrite.isActive
|
||||
? reportDocumentRewrite.run.draft
|
||||
: reportRewritePreview
|
||||
|| (viewingLiveGeneration
|
||||
@@ -593,6 +664,52 @@ function DeepResearchWorkbenchInner() {
|
||||
: viewingParentReport
|
||||
? (session?.reportMarkdown || "")
|
||||
: sandboxVariantSession?.reportMarkdown || "");
|
||||
// Never hide a backend response. Hiding refusal prose also removed the
|
||||
// virtual report file and its sandbox-open trigger, leaving operators with
|
||||
// neither a report nor an error. The backend now replaces refusal output at
|
||||
// its final persistence gate; this client-side signal remains visible when
|
||||
// a worker is stale or a new provider wording escapes that guard.
|
||||
const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
|
||||
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
|
||||
// A completed session may retain an old diagnostic in `session.error` even
|
||||
// after the backend has produced a usable fallback report. That historical
|
||||
// diagnostic must not be appended to an otherwise normal report.
|
||||
const showRunDiagnostic = Boolean(
|
||||
refusalReportDetected
|
||||
|| (!hasUsableDisplayedReport && (session?.error || session?.status === "failed")),
|
||||
);
|
||||
const runtimeDiagnosticText = JSON.stringify(
|
||||
session?.runtimeDiagnostics ?? {
|
||||
revision: "frontend-fallback-diagnostic",
|
||||
session: {
|
||||
id: session?.id,
|
||||
status: session?.status,
|
||||
sourceCount: session?.sourceCount ?? 0,
|
||||
activeJobId: session?.activeJobId,
|
||||
},
|
||||
error: session?.error ?? "报告正文被识别为拒绝型输出",
|
||||
invalidOutputPreview: refusalReportDetected ? rawDisplayedReport.slice(0, 1_200) : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const diagnosticMarkdown = showRunDiagnostic
|
||||
? [
|
||||
"## 系统运行说明",
|
||||
"",
|
||||
"> 本次报告生成链路出现异常。系统不能据此判断为‘没有收集到素材’,已有素材不会被删除。请将下面的诊断信息复制给管理员排查。",
|
||||
"",
|
||||
"```json",
|
||||
runtimeDiagnosticText,
|
||||
"```",
|
||||
].join("\n")
|
||||
: "";
|
||||
// Invalid refusal prose (or a failed run with no report) is replaced instead
|
||||
// of repeated to the user. Any usable normal/fallback/partial report is shown
|
||||
// as-is and never receives diagnostic text.
|
||||
const displayedReport = showRunDiagnostic
|
||||
? `# 报告生成异常\n\n${diagnosticMarkdown}`
|
||||
: rawDisplayedReport;
|
||||
|
||||
const updateFullReportRewritePresentation = useCallback(
|
||||
(presentationId: string | null, event: DocumentRewriteStreamEvent) => {
|
||||
@@ -1329,6 +1446,7 @@ function DeepResearchWorkbenchInner() {
|
||||
continue;
|
||||
}
|
||||
const version = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion;
|
||||
if (version < reportVersion) continue;
|
||||
const target = typeof event.payload.target === "string" ? event.payload.target : "report";
|
||||
const currentTarget = reportTargets[target];
|
||||
if (!currentTarget || version >= currentTarget.version) {
|
||||
@@ -1858,15 +1976,20 @@ function DeepResearchWorkbenchInner() {
|
||||
try {
|
||||
await actions.startJob.mutateAsync({
|
||||
sessionId: session.id,
|
||||
// First writing must harvest the collector thread before entering the
|
||||
// shared static-material report engine. Structural regeneration can
|
||||
// read already-persisted sources, but a first report cannot assume
|
||||
// that selected source rows exist yet.
|
||||
entry: "chat",
|
||||
config: { ...config, ...modelConfig },
|
||||
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages),
|
||||
});
|
||||
setSandboxOpen(false);
|
||||
toast.success("开始撰写研究报告");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
|
||||
}
|
||||
}, [actions.startJob, selectedModel, session]);
|
||||
}, [actions.startJob, collectorThread.messages, selectedModel, session]);
|
||||
|
||||
const handleContinueCollecting = useCallback(() => {
|
||||
document.getElementById("deep-research-composer")?.focus();
|
||||
@@ -2739,7 +2862,7 @@ function ResearchConversation({
|
||||
}) {
|
||||
const active = session.status === "running" || session.status === "awaiting_input";
|
||||
const hasTrace = active || events.length > 0;
|
||||
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
||||
const streamedSourceCount = countEventMaterials(events);
|
||||
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
||||
const requestedImageCount = requestedReportImageCount(session.config);
|
||||
return (
|
||||
@@ -2847,6 +2970,7 @@ type TraceSnapshot = {
|
||||
images: TraceImage[];
|
||||
imagePhaseFinished: boolean;
|
||||
warnings: string[];
|
||||
diagnostics: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
function traceRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -2884,6 +3008,8 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
const selectedIds = new Set<string>();
|
||||
const removedIds = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
const diagnostics: Record<string, unknown>[] = [];
|
||||
const diagnosticKeys = new Set<string>();
|
||||
let curationReason: string | undefined;
|
||||
let compressedSourceCount: number | undefined;
|
||||
let reportCompleted = false;
|
||||
@@ -2995,6 +3121,19 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
if (event.type === "warning" || event.type === "error") {
|
||||
const message = traceString(payload.message) ?? traceString(payload.detail);
|
||||
if (message && !warnings.includes(message)) warnings.push(message);
|
||||
const diagnostic = traceRecord(payload.diagnostic);
|
||||
if (Object.keys(diagnostic).length > 0) {
|
||||
const item = {
|
||||
code: traceString(payload.code) ?? event.type,
|
||||
...(message ? { message } : {}),
|
||||
...diagnostic,
|
||||
};
|
||||
const key = JSON.stringify(item);
|
||||
if (!diagnosticKeys.has(key)) {
|
||||
diagnosticKeys.add(key);
|
||||
diagnostics.push(item);
|
||||
}
|
||||
}
|
||||
if (traceString(payload.code) === "image_generation_failed") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
@@ -3027,6 +3166,7 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
|
||||
imagePhaseFinished,
|
||||
warnings,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3123,6 +3263,7 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
</TraceStage>}
|
||||
|
||||
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
|
||||
{snapshot.diagnostics.length > 0 && <details className="rounded-lg border border-amber-500/20 bg-amber-500/[0.05] px-3 py-2 text-xs"><summary className="cursor-pointer font-medium text-amber-800 dark:text-amber-200">运行诊断(用于排查,可复制)</summary><pre className="mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-background/70 p-2 text-[11px] leading-5 text-muted-foreground">{snapshot.diagnostics.map((item) => JSON.stringify(item, null, 2)).join("\n\n")}</pre></details>}
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,14 @@ export type ReportStubAiMessage = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ReportStubToolMessage = {
|
||||
id: string;
|
||||
type: "tool";
|
||||
name: "write_file";
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
interface UseReportSandboxOptions {
|
||||
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
|
||||
threadKey: string;
|
||||
@@ -87,9 +95,14 @@ export function useReportSandbox({
|
||||
wasWritingRef.current = writing;
|
||||
}, [writing]);
|
||||
|
||||
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
|
||||
// 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
|
||||
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
|
||||
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
if (!report) {
|
||||
if (writing) setStubMessage(null);
|
||||
return;
|
||||
}
|
||||
setStubMessage((prev) => {
|
||||
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
||||
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
|
||||
@@ -106,7 +119,7 @@ export function useReportSandbox({
|
||||
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
|
||||
};
|
||||
});
|
||||
}, [messageId, report, toolCallId]);
|
||||
}, [messageId, report, toolCallId, writing]);
|
||||
|
||||
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
|
||||
@@ -132,8 +145,23 @@ export function useReportSandbox({
|
||||
return {
|
||||
virtualUrl,
|
||||
stubMessages: useMemo(
|
||||
() => (currentStubMessage ? [currentStubMessage] : []),
|
||||
[currentStubMessage],
|
||||
() => {
|
||||
if (!currentStubMessage) return [];
|
||||
if (writing) return [currentStubMessage];
|
||||
// ArtifactFileDetail determines whether a write is still streaming by
|
||||
// matching the write_file call with its tool result. `isLoading=false`
|
||||
// alone is intentionally ignored there, so completed and historical
|
||||
// virtual reports must include this synthetic completion receipt.
|
||||
const completedWrite: ReportStubToolMessage = {
|
||||
id: `${toolCallId}-result`,
|
||||
type: "tool",
|
||||
name: "write_file",
|
||||
tool_call_id: toolCallId,
|
||||
content: "ok",
|
||||
};
|
||||
return [currentStubMessage, completedWrite];
|
||||
},
|
||||
[currentStubMessage, toolCallId, writing],
|
||||
),
|
||||
openReport,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 报告协作工作台(AgentScope 多智能体)—— 前端模块
|
||||
|
||||
实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
|
||||
(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
|
||||
|
||||
## 结构
|
||||
|
||||
```text
|
||||
src/report-collaboration/
|
||||
api/ # 契约类型 + REST/SSE 客户端 + Mock 驱动
|
||||
types.ts # 前后端共享领域类型(冻结契约,字段与后端 Pydantic 一致)
|
||||
client.ts # rcFetch:鉴权、X-Idempotency-Key、X-Expected-Revision、409/422/429/5xx 错误映射
|
||||
sessions.ts / plans.ts / runs.ts / reports.ts # 端点函数
|
||||
driver.ts # 真实 / Mock 统一门面(getReportCollaborationDriver)
|
||||
mock.ts # 本地演示驱动:澄清→3 方案→并行执行→报告版本,全走同一事件协议
|
||||
state/
|
||||
event-normalizer.ts # SSE/REST 事件防御性归一化
|
||||
event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
|
||||
selectors.ts # 页面状态投影(并行聚焦过滤、画布方案、阶段推导)
|
||||
message-adapter.ts # CollaborationMessage → LangGraph Message + BaseStream 外观 + 虚拟业务卡
|
||||
hooks/
|
||||
useReportCollaborationSession.ts # 快照→SSE→合帧 reducer→动作(send/select/startRun/commands)
|
||||
useReportCollaborationStream.ts # SSE 连接/指数退避重连/after_seq 续接
|
||||
useReportCollaborationMessages.ts # 消息组装(聚焦过滤 + 业务卡 + thread 外观)
|
||||
useReportPanelLayout.ts # 两栏宽度(持久化)、窄屏切换、键盘分隔条
|
||||
useReportPlanSelection.ts # 预览(纯前端)与选定(服务器事实)分离
|
||||
components/ # Header / 两栏 Layout / 会话面板(复用 MessageList)/ 方案卡 /
|
||||
# 画布(@xyflow/react)/ 节点抽屉 / Composer / 干预卡 / 报告预览 / 历史 / 状态栏
|
||||
pages/ReportCollaborationPage.tsx
|
||||
styles/report-collaboration.css # --rc-* 语义 token(Chrome 109 兼容,无 color-mix)
|
||||
tests/ # node:test 单测(reducer / adapter),运行:pnpm test:report-collaboration
|
||||
```
|
||||
|
||||
## 路由与入口
|
||||
|
||||
- `/page/workspace/report-collaboration` 新会话;`/page/workspace/report-collaboration/:sessionId` 会话页(按 sessionId 重挂)。
|
||||
- 侧边栏导航项「报告协作」(`workspace-nav-chat-list.tsx`)。
|
||||
- `WorkspaceRoutes.tsx` 引入页面与 CSS。
|
||||
|
||||
## 关键约束(与计划一致)
|
||||
|
||||
- 消息区**复用** `MessageList`:AgentScope 文本 / TeamSay / 工具调用与返回经
|
||||
`message-adapter.ts` 一对一映射,经 `ThreadContext.Provider` + 合成 `BaseStream` 渲染;
|
||||
不另做摘要卡或第二套渲染器。
|
||||
- MessageList 新增的**通用业务卡插槽** `businessCardSlot`(分组 `assistant:business-card`,
|
||||
判据 `additional_kwargs.report_collaboration_card`,见 `core/messages/utils.ts`)只在本页
|
||||
传入;其他页面零行为变化。
|
||||
- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
|
||||
- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
|
||||
|
||||
## Mock 演示(后端未就绪时)
|
||||
|
||||
开启方式任一:
|
||||
|
||||
- `localStorage.setItem('deerflow.rc-mock','1')` 后刷新;
|
||||
- 环境变量 `VITE_RC_MOCK=1`。
|
||||
|
||||
Mock 走与真实后端相同的驱动接口与事件协议(seq 单调、可重放),可完整演示:
|
||||
输入需求 → 澄清 → 回答 → 3 个候选方案 → 预览/采用 → 开始协作 → 并行成员流式输出 +
|
||||
工具调用 → 命令确认卡(输入「重新检索…」)→ 报告版本 → 预览/下载/恢复。
|
||||
|
||||
## 后端契约冻结说明
|
||||
|
||||
`api/types.ts` 按后端计划 §5/§7 编写;快照端点约定 `GET /sessions/{id}` 返回
|
||||
`SessionSnapshot`(session/messages/plans/run/nodes/agent_runs/commands/report_versions/last_seq)。
|
||||
后端实现时如有出入,以 OpenAPI 为准同步本文件与两份计划文档。
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
|
||||
*
|
||||
* - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
|
||||
* - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
|
||||
* 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
|
||||
* (保留输入,允许同一幂等键重试)。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ApiErrorDetail } from "./types";
|
||||
|
||||
export const REPORT_COLLABORATION_BASE = () =>
|
||||
`${getBackendBaseURL()}/api/report-collaboration`;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
readonly detail?: unknown;
|
||||
|
||||
constructor(status: number, message: string, code?: string, detail?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
|
||||
export class ConflictError extends ApiError {}
|
||||
/** 422:请求参数/语义错误,需在对应操作附近展示。 */
|
||||
export class ValidationError extends ApiError {}
|
||||
/** 429:预算、并发或速率限制,不自动重试。 */
|
||||
export class RateLimitError extends ApiError {}
|
||||
/** 5xx:服务端错误,可带同一幂等键重试。 */
|
||||
export class ServerError extends ApiError {}
|
||||
/** 网络层错误(fetch reject)。 */
|
||||
export class NetworkError extends ApiError {
|
||||
constructor(message: string) {
|
||||
super(0, message, "network");
|
||||
}
|
||||
}
|
||||
|
||||
export function newIdempotencyKey(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return `rc-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
async function parseErrorDetail(res: Response): Promise<ApiErrorDetail | null> {
|
||||
try {
|
||||
const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
|
||||
const source = "message" in data ? data : null;
|
||||
if (source) return source;
|
||||
if (typeof data.detail === "string") {
|
||||
return { message: data.detail };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
|
||||
if (res.status === 409) return new ConflictError(409, message, code, detail);
|
||||
if (res.status === 422) return new ValidationError(422, message, code, detail);
|
||||
if (res.status === 429) return new RateLimitError(429, message, code, detail);
|
||||
if (res.status >= 500) return new ServerError(res.status, message, code, detail);
|
||||
return new ApiError(res.status, message, code, detail);
|
||||
}
|
||||
|
||||
export interface RcRequestInit extends Omit<RequestInit, "body"> {
|
||||
body?: unknown;
|
||||
idempotencyKey?: string;
|
||||
expectedRevision?: number | null;
|
||||
/** 404 时返回 null 而不是抛错。 */
|
||||
nullOn404?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function rcRequest<T>(path: string, init: RcRequestInit = {}): Promise<T | null> {
|
||||
const headers = new Headers();
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (init.idempotencyKey) {
|
||||
headers.set("X-Idempotency-Key", init.idempotencyKey);
|
||||
}
|
||||
if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
|
||||
headers.set("X-Expected-Revision", String(init.expectedRevision));
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
|
||||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||||
headers,
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
credentials: "include",
|
||||
signal: init.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new NetworkError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
if (res.status === 404 && init.nullOn404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await parseErrorDetail(res);
|
||||
throw errorForStatus(
|
||||
res,
|
||||
detail?.message ?? `request failed (${res.status})`,
|
||||
detail?.code,
|
||||
detail?.detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return null;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
/** GET 便捷封装(非空断言版)。 */
|
||||
export async function rcGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const data = await rcRequest<T>(path, { method: "GET", signal });
|
||||
if (data === null) {
|
||||
throw new ApiError(404, `resource not found: ${path}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 驱动接口:真实后端 / 本地 Mock 的统一门面。
|
||||
*
|
||||
* Mock 开关(任一为真即 Mock,便于后端未就绪时演示与联调自测):
|
||||
* - 环境变量 `VITE_RC_MOCK=1`;
|
||||
* - localStorage `deerflow.rc-mock` = `"1"`(浏览器里临时打开:
|
||||
* `localStorage.setItem('deerflow.rc-mock','1')` 后刷新)。
|
||||
*/
|
||||
|
||||
import * as plansApi from "@/report-collaboration/api/plans";
|
||||
import * as reportsApi from "@/report-collaboration/api/reports";
|
||||
import * as runsApi from "@/report-collaboration/api/runs";
|
||||
import * as sessionsApi from "@/report-collaboration/api/sessions";
|
||||
import { mockDriver } from "@/report-collaboration/api/mock";
|
||||
import { ApiError } from "@/report-collaboration/api/client";
|
||||
import type {
|
||||
CollaborationCommand,
|
||||
CollaborationEvent,
|
||||
CollaborationMessage,
|
||||
CreateCommandPayload,
|
||||
CreateRewritePayload,
|
||||
NodeContractDetail,
|
||||
ReportRewriteProposal,
|
||||
ReportRun,
|
||||
ReportSession,
|
||||
ReportSessionSummary,
|
||||
ReportVersion,
|
||||
SendMessagePayload,
|
||||
SessionSnapshot,
|
||||
StartRunPayload,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
export interface OpenStreamHandlers {
|
||||
onEvent: (event: CollaborationEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ReportCollaborationDriver {
|
||||
listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]>;
|
||||
getSnapshot(sessionId: string, signal?: AbortSignal): Promise<SessionSnapshot | null>;
|
||||
|
||||
createSession(title?: string): Promise<ReportSession>;
|
||||
updateSession(
|
||||
sessionId: string,
|
||||
patch: { title?: string },
|
||||
expectedRevision?: number | null,
|
||||
): Promise<ReportSession | null>;
|
||||
deleteSession(sessionId: string): Promise<void>;
|
||||
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
payload: SendMessagePayload,
|
||||
idempotencyKey: string,
|
||||
): Promise<CollaborationMessage>;
|
||||
|
||||
requestPlans(sessionId: string, expectedRevision?: number | null): Promise<void>;
|
||||
selectPlan(
|
||||
sessionId: string,
|
||||
planId: string,
|
||||
expectedRevision?: number | null,
|
||||
): Promise<void>;
|
||||
startRun(
|
||||
sessionId: string,
|
||||
payload: StartRunPayload,
|
||||
expectedRevision?: number | null,
|
||||
): Promise<ReportRun>;
|
||||
|
||||
getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null>;
|
||||
listRunEvents(runId: string, afterSeq: number, signal?: AbortSignal): Promise<CollaborationEvent[]>;
|
||||
openRunStream(
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
handlers: OpenStreamHandlers,
|
||||
): () => void;
|
||||
cancelRun(runId: string): Promise<ReportRun>;
|
||||
|
||||
createCommand(
|
||||
runId: string,
|
||||
payload: CreateCommandPayload,
|
||||
idempotencyKey: string,
|
||||
): Promise<CollaborationCommand>;
|
||||
confirmCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
|
||||
cancelCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
|
||||
|
||||
getNodeContract(runId: string, nodeId: string, signal?: AbortSignal): Promise<NodeContractDetail>;
|
||||
|
||||
listReportVersions(sessionId: string, signal?: AbortSignal): Promise<ReportVersion[]>;
|
||||
createRewrite(sessionId: string, payload: CreateRewritePayload): Promise<ReportRewriteProposal>;
|
||||
applyRewrite(sessionId: string, rewriteId: string): Promise<ReportVersion>;
|
||||
restoreReportVersion(sessionId: string, versionId: string): Promise<ReportVersion>;
|
||||
}
|
||||
|
||||
const realDriver: ReportCollaborationDriver = {
|
||||
listSessions: sessionsApi.listSessions,
|
||||
getSnapshot: sessionsApi.getSessionSnapshot,
|
||||
createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
|
||||
updateSession: sessionsApi.updateSession,
|
||||
deleteSession: sessionsApi.deleteSession,
|
||||
sendMessage: sessionsApi.sendMessage,
|
||||
requestPlans: (sessionId, expectedRevision) =>
|
||||
plansApi.requestPlans(sessionId, undefined, expectedRevision),
|
||||
selectPlan: async (sessionId, planId, expectedRevision) => {
|
||||
await plansApi.selectPlan(sessionId, planId, expectedRevision);
|
||||
},
|
||||
startRun: runsApi.startRun,
|
||||
getRun: runsApi.getRun,
|
||||
listRunEvents: runsApi.listRunEvents,
|
||||
openRunStream: runsApi.openRunStream,
|
||||
cancelRun: runsApi.cancelRun,
|
||||
createCommand: runsApi.createCommand,
|
||||
confirmCommand: runsApi.confirmCommand,
|
||||
cancelCommand: runsApi.cancelCommand,
|
||||
getNodeContract: runsApi.getNodeContract,
|
||||
listReportVersions: reportsApi.listReportVersions,
|
||||
createRewrite: reportsApi.createRewrite,
|
||||
applyRewrite: reportsApi.applyRewrite,
|
||||
restoreReportVersion: reportsApi.restoreReportVersion,
|
||||
};
|
||||
|
||||
let cachedDriver: ReportCollaborationDriver | null = null;
|
||||
/** DEV 下探测到后端无 /api/report-collaboration(404/网络失败/非 JSON)后自动切 Mock。 */
|
||||
let devFallbackToMock = false;
|
||||
|
||||
export function isMockEnabled(): boolean {
|
||||
try {
|
||||
if (import.meta.env.VITE_RC_MOCK === "1") return true;
|
||||
if (typeof window !== "undefined") {
|
||||
return window.localStorage.getItem("deerflow.rc-mock") === "1";
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用时按真实后端处理。
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 显式关闭(localStorage "0"),优先级高于 DEV 自动回退。 */
|
||||
function isMockExplicitlyDisabled(): boolean {
|
||||
try {
|
||||
return typeof window !== "undefined" && window.localStorage.getItem("deerflow.rc-mock") === "0";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端尚未实现该路由的特征:404/405/501、网络失败、或 SPA fallback 返回的非 JSON。 */
|
||||
function isFeatureMissingError(err: unknown): boolean {
|
||||
if (err instanceof ApiError) {
|
||||
// 405:网关对未知 POST 路由的响应形态;404/501:路由未注册。
|
||||
return err.status === 404 || err.status === 405 || err.status === 501 || err.status === 0;
|
||||
}
|
||||
// rcRequest 对 200+HTML(dev server SPA fallback)会抛 JSON.parse 的 SyntaxError。
|
||||
return err instanceof SyntaxError;
|
||||
}
|
||||
|
||||
async function withDevFallback<T>(op: () => Promise<T>, retry: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await op();
|
||||
} catch (err) {
|
||||
if (!canDevFallback(err)) throw err;
|
||||
enableMockFallback();
|
||||
return retry();
|
||||
}
|
||||
}
|
||||
|
||||
function canDevFallback(err: unknown): boolean {
|
||||
return (
|
||||
import.meta.env.DEV &&
|
||||
!devFallbackToMock &&
|
||||
!isMockExplicitlyDisabled() &&
|
||||
isFeatureMissingError(err)
|
||||
);
|
||||
}
|
||||
|
||||
function enableMockFallback(): void {
|
||||
devFallbackToMock = true;
|
||||
console.info(
|
||||
"[report-collaboration] 后端 /api/report-collaboration 尚不可用,开发模式自动切换到本地 Mock 演示驱动(localStorage deerflow.rc-mock=0 可强制关闭)。",
|
||||
);
|
||||
}
|
||||
|
||||
/** DEV 自动回退驱动:命中回退标志后所有方法直达 Mock;否则先走真实后端。 */
|
||||
const devFallbackEntries: Partial<ReportCollaborationDriver> = {
|
||||
async listSessions(signal) {
|
||||
if (devFallbackToMock) return mockDriver.listSessions(signal);
|
||||
return withDevFallback(
|
||||
() => realDriver.listSessions(signal),
|
||||
() => mockDriver.listSessions(signal),
|
||||
);
|
||||
},
|
||||
async getSnapshot(sessionId, signal) {
|
||||
if (devFallbackToMock) return mockDriver.getSnapshot(sessionId, signal);
|
||||
const snapshot = await realDriver.getSnapshot(sessionId, signal);
|
||||
// 真实后端 404 且 DEV 未定态时,可能是功能未部署:探测一次并转 Mock。
|
||||
if (
|
||||
snapshot === null &&
|
||||
import.meta.env.DEV &&
|
||||
!isMockExplicitlyDisabled() &&
|
||||
!devFallbackToMock
|
||||
) {
|
||||
enableMockFallback();
|
||||
return mockDriver.getSnapshot(sessionId, signal);
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
async createSession(title) {
|
||||
if (devFallbackToMock) return mockDriver.createSession(title);
|
||||
return withDevFallback(
|
||||
() => realDriver.createSession(title),
|
||||
() => mockDriver.createSession(title),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const devFallbackDriver: ReportCollaborationDriver = new Proxy(devFallbackEntries, {
|
||||
get(target, prop, receiver) {
|
||||
if (Reflect.has(target, prop)) {
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
const active = devFallbackToMock ? mockDriver : realDriver;
|
||||
const value = Reflect.get(active, prop);
|
||||
return typeof value === "function" ? value.bind(active) : value;
|
||||
},
|
||||
}) as ReportCollaborationDriver;
|
||||
|
||||
export function getReportCollaborationDriver(): ReportCollaborationDriver {
|
||||
if (cachedDriver) return cachedDriver;
|
||||
cachedDriver = isMockEnabled() ? mockDriver : devFallbackDriver;
|
||||
return cachedDriver;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 候选计划:plan request / 查询 / 选定(`/sessions/{id}/plans*`)。
|
||||
*
|
||||
* 选择方案与开始执行是两个独立命令:select 只写 `selected_plan_id`,
|
||||
* 不创建 run。
|
||||
*/
|
||||
|
||||
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type { ReportPlanCandidate } from "./types";
|
||||
|
||||
export async function requestPlans(
|
||||
sessionId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
expectedRevision?: number | null,
|
||||
): Promise<void> {
|
||||
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}/plan-requests`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
idempotencyKey,
|
||||
expectedRevision,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPlans(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReportPlanCandidate[]> {
|
||||
const data = await rcGet<{ plans: ReportPlanCandidate[] }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/plans`,
|
||||
signal,
|
||||
);
|
||||
return data.plans ?? [];
|
||||
}
|
||||
|
||||
/** 采用方案:409 表示方案 revision 过期或会话已选定,需刷新后重新确认。 */
|
||||
export async function selectPlan(
|
||||
sessionId: string,
|
||||
planId: string,
|
||||
expectedRevision?: number | null,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<{ selected_plan_id: string }> {
|
||||
return rcRequest<{ selected_plan_id: string }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/plans/${encodeURIComponent(planId)}/select`,
|
||||
{ method: "POST", body: {}, idempotencyKey, expectedRevision },
|
||||
).then((res) => res as { selected_plan_id: string });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 报告版本、章节改写候选与版本恢复(`/sessions/{id}/reports*`)。
|
||||
*/
|
||||
|
||||
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type { CreateRewritePayload, ReportRewriteProposal, ReportVersion } from "./types";
|
||||
|
||||
export async function listReportVersions(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReportVersion[]> {
|
||||
const data = await rcGet<{ versions: ReportVersion[] }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/reports`,
|
||||
signal,
|
||||
);
|
||||
return data.versions ?? [];
|
||||
}
|
||||
|
||||
/** 发起章节重写 / 润色:只生成 replacement candidate,不直接覆盖正式版本。 */
|
||||
export async function createRewrite(
|
||||
sessionId: string,
|
||||
payload: CreateRewritePayload,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportRewriteProposal> {
|
||||
return rcRequest<ReportRewriteProposal>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites`,
|
||||
{ method: "POST", body: payload, idempotencyKey },
|
||||
).then((res) => res as ReportRewriteProposal);
|
||||
}
|
||||
|
||||
export async function applyRewrite(
|
||||
sessionId: string,
|
||||
rewriteId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportVersion> {
|
||||
return rcRequest<ReportVersion>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites/${encodeURIComponent(rewriteId)}/apply`,
|
||||
{ method: "POST", body: {}, idempotencyKey },
|
||||
).then((res) => res as ReportVersion);
|
||||
}
|
||||
|
||||
/** 版本恢复:创建新的头版本,不删除历史。 */
|
||||
export async function restoreReportVersion(
|
||||
sessionId: string,
|
||||
versionId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportVersion> {
|
||||
return rcRequest<ReportVersion>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/report-versions/${encodeURIComponent(versionId)}/restore`,
|
||||
{ method: "POST", body: {}, idempotencyKey },
|
||||
).then((res) => res as ReportVersion);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Run 执行与干预:查询、事件回放、SSE 流、命令、取消、节点合同。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { REPORT_COLLABORATION_BASE, newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type {
|
||||
CollaborationCommand,
|
||||
CollaborationEvent,
|
||||
CreateCommandPayload,
|
||||
NodeContractDetail,
|
||||
ReportRun,
|
||||
StartRunPayload,
|
||||
} from "./types";
|
||||
|
||||
export async function startRun(
|
||||
sessionId: string,
|
||||
payload: StartRunPayload,
|
||||
expectedRevision?: number | null,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportRun> {
|
||||
return rcRequest<ReportRun>(`/sessions/${encodeURIComponent(sessionId)}/runs`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
idempotencyKey,
|
||||
expectedRevision,
|
||||
}).then((res) => res as ReportRun);
|
||||
}
|
||||
|
||||
export async function getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null> {
|
||||
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
nullOn404: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** 历史事件回放(断线补拉 / 序号缺口补齐)。 */
|
||||
export async function listRunEvents(
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollaborationEvent[]> {
|
||||
const data = await rcGet<{ events: CollaborationEvent[] }>(
|
||||
`/runs/${encodeURIComponent(runId)}/events?after_seq=${Math.max(0, afterSeq)}`,
|
||||
signal,
|
||||
);
|
||||
return data.events ?? [];
|
||||
}
|
||||
|
||||
export async function cancelRun(
|
||||
runId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportRun> {
|
||||
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
idempotencyKey,
|
||||
}).then((res) => res as ReportRun);
|
||||
}
|
||||
|
||||
export async function getNodeContract(
|
||||
runId: string,
|
||||
nodeId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NodeContractDetail> {
|
||||
return rcGet<NodeContractDetail>(
|
||||
`/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/contract`,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
|
||||
|
||||
export async function createCommand(
|
||||
runId: string,
|
||||
payload: CreateCommandPayload,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationCommand> {
|
||||
return rcRequest<CollaborationCommand>(`/runs/${encodeURIComponent(runId)}/commands`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
idempotencyKey,
|
||||
}).then((res) => res as CollaborationCommand);
|
||||
}
|
||||
|
||||
export async function confirmCommand(
|
||||
runId: string,
|
||||
commandId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationCommand> {
|
||||
return rcRequest<CollaborationCommand>(
|
||||
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/confirm`,
|
||||
{ method: "POST", body: {}, idempotencyKey },
|
||||
).then((res) => res as CollaborationCommand);
|
||||
}
|
||||
|
||||
export async function cancelCommand(
|
||||
runId: string,
|
||||
commandId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationCommand> {
|
||||
return rcRequest<CollaborationCommand>(
|
||||
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/cancel`,
|
||||
{ method: "POST",
|
||||
body: {},
|
||||
idempotencyKey },
|
||||
).then((res) => res as CollaborationCommand);
|
||||
}
|
||||
|
||||
// ── SSE 订阅 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RunStreamHandlers {
|
||||
onEvent: (event: CollaborationEvent) => void;
|
||||
onOpen?: () => void;
|
||||
/** 服务端正常关流(终态)。 */
|
||||
onClose?: () => void;
|
||||
/** 传输层异常(触发重连)。 */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 run 的 durable SSE 流。支持 `after_seq` 与 `Last-Event-ID`。
|
||||
*
|
||||
* 返回 close 函数:幂等,调用后中断连接且不再触发回调。
|
||||
* 注意:fetch 流式读取无法携带 EventSource 的自动重连,重连由
|
||||
* useReportCollaborationStream 以指数退避驱动。
|
||||
*/
|
||||
export function openRunStream(
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
handlers: RunStreamHandlers,
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
let closed = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`${REPORT_COLLABORATION_BASE()}/runs/${encodeURIComponent(runId)}/stream?after_seq=${Math.max(0, afterSeq)}`,
|
||||
{ method: "GET", signal: controller.signal, headers: { Accept: "text/event-stream" } },
|
||||
);
|
||||
if (!res.ok || !res.body) {
|
||||
if (!closed) handlers.onError?.(`stream failed (${res.status})`);
|
||||
return;
|
||||
}
|
||||
handlers.onOpen?.();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let dataLines: string[] = [];
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const flushData = () => {
|
||||
if (dataLines.length === 0) return;
|
||||
const json = dataLines.join("\n").trim();
|
||||
dataLines = [];
|
||||
if (!json) return;
|
||||
try {
|
||||
const event = JSON.parse(json) as CollaborationEvent;
|
||||
if (lastEventId) event.eventId = event.eventId || lastEventId;
|
||||
handlers.onEvent(event);
|
||||
} catch {
|
||||
// 非 JSON 帧(如注释/心跳文本)忽略,不中断连接。
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\r$/, "");
|
||||
if (line === "") {
|
||||
flushData();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("id:")) {
|
||||
lastEventId = line.slice(3).trim() || undefined;
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
// `event:` / `:` 注释行不影响 data 解析。
|
||||
}
|
||||
}
|
||||
flushData();
|
||||
if (!closed) handlers.onClose?.();
|
||||
} catch (err) {
|
||||
if (!closed && !controller.signal.aborted) {
|
||||
handlers.onError?.(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 会话 CRUD 与消息读写(`/api/report-collaboration/sessions`)。
|
||||
*/
|
||||
|
||||
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type {
|
||||
CollaborationMessage,
|
||||
CreateSessionPayload,
|
||||
ReportSession,
|
||||
ReportSessionSummary,
|
||||
SendMessagePayload,
|
||||
SessionSnapshot,
|
||||
UpdateSessionPayload,
|
||||
} from "./types";
|
||||
|
||||
export async function listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]> {
|
||||
const data = await rcGet<{ sessions: ReportSessionSummary[] }>("/sessions", signal);
|
||||
return data.sessions ?? [];
|
||||
}
|
||||
|
||||
export async function getSessionSnapshot(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionSnapshot | null> {
|
||||
return rcRequest<SessionSnapshot>(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
nullOn404: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
payload: CreateSessionPayload = {},
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportSession> {
|
||||
return rcRequest<ReportSession>("/sessions", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
idempotencyKey,
|
||||
}).then((res) => res as ReportSession);
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
sessionId: string,
|
||||
patch: UpdateSessionPayload,
|
||||
expectedRevision?: number | null,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportSession | null> {
|
||||
return rcRequest<ReportSession>(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "PATCH",
|
||||
body: patch,
|
||||
idempotencyKey,
|
||||
expectedRevision,
|
||||
nullOn404: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMessages(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollaborationMessage[]> {
|
||||
const data = await rcGet<{ messages: CollaborationMessage[] }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
signal,
|
||||
);
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
sessionId: string,
|
||||
payload: SendMessagePayload,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationMessage> {
|
||||
return rcRequest<CollaborationMessage>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
{ method: "POST", body: payload, idempotencyKey },
|
||||
).then((res) => res as CollaborationMessage);
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* 报告协作工作台(AgentScope 多智能体)—— 前后端共享领域类型。
|
||||
*
|
||||
* 契约来源:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` §5/§7。
|
||||
* 字段与后端 Pydantic 合同保持 snake_case 一一对应;前端只在 selector/adapter
|
||||
* 层做投影,不再二次推断状态。
|
||||
*
|
||||
* 后端尚未实现时本层即为冻结契约(RC-FE-004 前置条件),修改字段需同步两份计划文档。
|
||||
*/
|
||||
|
||||
// ── 会话 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 会话状态机(后端 §6 sessions.status)。 */
|
||||
export type ReportSessionStatus =
|
||||
| "empty"
|
||||
| "clarifying"
|
||||
| "planning"
|
||||
| "proposal_ready"
|
||||
| "running"
|
||||
| "awaiting_input"
|
||||
| "reviewing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface ReportSession {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ReportSessionStatus;
|
||||
requirement_revision: number;
|
||||
selected_plan_id: string | null;
|
||||
active_run_id: string | null;
|
||||
current_report_version_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 会话列表条目(轻量,无 messages)。 */
|
||||
export type ReportSessionSummary = ReportSession;
|
||||
|
||||
// ── 需求快照 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportRequirementSnapshot {
|
||||
topic: string;
|
||||
objective?: string | null;
|
||||
audience?: string | null;
|
||||
language?: string | null;
|
||||
time_range?: string | null;
|
||||
geography?: string | null;
|
||||
required_angles: string[];
|
||||
excluded_angles: string[];
|
||||
source_constraints?: string | null;
|
||||
report_structure_id?: string | null;
|
||||
length_target?: string | null;
|
||||
tone?: string | null;
|
||||
deadline_hint?: string | null;
|
||||
quality_priority?: string | null;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
// ── 消息镜像(ConversationReplica)──────────────────────────────────────────
|
||||
|
||||
export type CollaborationMessageRole = "human" | "ai" | "tool";
|
||||
|
||||
/** ai 消息上的工具调用(args 流式期间为部分 JSON 字符串,完成后为对象)。 */
|
||||
export interface CollaborationToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 `report_collaboration_messages` 行的镜像:用户可见的 assistant 文本、
|
||||
* TeamSay、`tool_calls` 与 `tool` 返回都落在这同一张时间线上。
|
||||
*/
|
||||
export interface CollaborationMessage {
|
||||
message_id: string;
|
||||
role: CollaborationMessageRole;
|
||||
content: string;
|
||||
/** 成员显示名(TeamSay / 成员回复 / tool 名)。 */
|
||||
name?: string | null;
|
||||
tool_calls?: CollaborationToolCall[];
|
||||
tool_call_id?: string | null;
|
||||
/** 并行阶段稳定归属;协调者广播可为 null(共享上下文)。 */
|
||||
agent_run_id?: string | null;
|
||||
node_run_id?: string | null;
|
||||
phase_id?: string | null;
|
||||
visibility: "visible" | "internal";
|
||||
metadata?: Record<string, unknown> | null;
|
||||
command_id?: string | null;
|
||||
created_at: string;
|
||||
/** null = 仍在流式输出。 */
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
// ── 候选计划 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReportPlanStatus =
|
||||
| "proposed"
|
||||
| "selected"
|
||||
| "superseded"
|
||||
| "rejected"
|
||||
| "executing"
|
||||
| "completed";
|
||||
|
||||
export type ReportCostLevel = "low" | "medium" | "high";
|
||||
|
||||
export interface PlanRole {
|
||||
key: string;
|
||||
display_name: string;
|
||||
responsibility: string;
|
||||
angle?: string | null;
|
||||
}
|
||||
|
||||
export interface QualityGate {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanValidation {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** 计划图节点:节点合同的用户可见摘要(不含执行结果)。 */
|
||||
export interface PlanNode {
|
||||
id: string;
|
||||
label: string;
|
||||
role_key?: string | null;
|
||||
role_display_name?: string | null;
|
||||
angle?: string | null;
|
||||
depends_on: string[];
|
||||
/** 合同摘要;完整合同走 `GET /runs/{run_id}/nodes/{node_id}/contract`。 */
|
||||
mission?: string | null;
|
||||
output_artifact_type?: string | null;
|
||||
input_artifact_types?: string[];
|
||||
allowed_tools?: string[];
|
||||
acceptance_criteria?: string[];
|
||||
}
|
||||
|
||||
export interface PlanEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface ReportPlanCandidate {
|
||||
id: string;
|
||||
proposal_group_id: string;
|
||||
title: string;
|
||||
strategy: string;
|
||||
summary: string;
|
||||
rationale: string;
|
||||
recommended?: boolean;
|
||||
estimated_duration_seconds: number;
|
||||
estimated_cost_level: ReportCostLevel;
|
||||
requirement_revision: number;
|
||||
roles: PlanRole[];
|
||||
nodes: PlanNode[];
|
||||
edges: PlanEdge[];
|
||||
quality_gates: QualityGate[];
|
||||
report_structure_id: string | null;
|
||||
validation: PlanValidation;
|
||||
revision: number;
|
||||
status: ReportPlanStatus;
|
||||
}
|
||||
|
||||
// ── 节点运行状态 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 后端 §5.5 状态机:completed 只能经 reopened 重新入队。 */
|
||||
export type CollaborationNodeStatus =
|
||||
| "planned"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "validating"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "awaiting_input"
|
||||
| "cancel_requested"
|
||||
| "cancelled"
|
||||
| "superseded"
|
||||
| "reopened"
|
||||
| "interrupted";
|
||||
|
||||
export interface NodeAwaitingInput {
|
||||
reason: string;
|
||||
question: string;
|
||||
impact?: string[];
|
||||
}
|
||||
|
||||
export interface NodeAttemptState {
|
||||
attempt: number;
|
||||
status: CollaborationNodeStatus;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface CollaborationNodeState {
|
||||
node_id: string;
|
||||
label: string;
|
||||
role_display_name?: string | null;
|
||||
angle?: string | null;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
progress?: string | null;
|
||||
awaiting_input?: NodeAwaitingInput | null;
|
||||
error?: string | null;
|
||||
attempts: NodeAttemptState[];
|
||||
}
|
||||
|
||||
// ── 智能体成员(并行阶段稳定身份)────────────────────────────────────────────
|
||||
|
||||
export type AgentRunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting_input"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface AgentRun {
|
||||
agent_run_id: string;
|
||||
node_run_id?: string | null;
|
||||
phase_id?: string | null;
|
||||
role: string;
|
||||
display_name: string;
|
||||
avatar_key?: string | null;
|
||||
status: AgentRunStatus;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
}
|
||||
|
||||
// ── Run ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReportRunStatus =
|
||||
| "queued"
|
||||
| "planning"
|
||||
| "running"
|
||||
| "awaiting_input"
|
||||
| "reviewing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "recovering";
|
||||
|
||||
export interface ReportRun {
|
||||
id: string;
|
||||
session_id: string;
|
||||
plan_id: string;
|
||||
status: ReportRunStatus;
|
||||
cancel_requested: boolean;
|
||||
next_event_seq: number;
|
||||
current_phase?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
|
||||
|
||||
export type CommandIntent =
|
||||
| "answer_question"
|
||||
| "update_requirements"
|
||||
| "add_angle"
|
||||
| "research_again"
|
||||
| "reanalyze"
|
||||
| "rerun_node"
|
||||
| "rewrite_section"
|
||||
| "polish_report"
|
||||
| "ask_report"
|
||||
| "replan"
|
||||
| "cancel";
|
||||
|
||||
export type CommandStatus =
|
||||
| "pending"
|
||||
| "classified"
|
||||
| "confirmation_required"
|
||||
| "accepted"
|
||||
| "accepted_waiting_boundary"
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "rejected";
|
||||
|
||||
export interface CollaborationCommand {
|
||||
command_id: string;
|
||||
idempotency_key: string;
|
||||
text: string;
|
||||
intent?: CommandIntent | null;
|
||||
confidence?: number | null;
|
||||
target_type?: string | null;
|
||||
target_ids: string[];
|
||||
normalized_instruction?: string | null;
|
||||
requires_confirmation: boolean;
|
||||
cost_level?: ReportCostLevel | null;
|
||||
reason?: string | null;
|
||||
impact_nodes?: string[];
|
||||
status: CommandStatus;
|
||||
expected_revision?: number | null;
|
||||
error?: string | null;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
// ── 成果物 / 来源 / 报告版本 ────────────────────────────────────────────────
|
||||
|
||||
export type ArtifactValidationStatus =
|
||||
| "pending"
|
||||
| "validated"
|
||||
| "rejected"
|
||||
| "superseded";
|
||||
|
||||
export interface CollaborationArtifact {
|
||||
id: string;
|
||||
run_id: string;
|
||||
node_id: string;
|
||||
attempt: number;
|
||||
artifact_type: string;
|
||||
schema_version: number;
|
||||
validation_status: ArtifactValidationStatus;
|
||||
/** 用户可见摘要(如「12 条来源,8 条近 90 天」);不含正文。 */
|
||||
summary?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReportSource {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
title: string;
|
||||
published_at?: string | null;
|
||||
added_at: string;
|
||||
agent_run_id?: string | null;
|
||||
}
|
||||
|
||||
export type ReportChangeType =
|
||||
| "initial"
|
||||
| "section_rewrite"
|
||||
| "polish"
|
||||
| "restore"
|
||||
| "revision";
|
||||
|
||||
export interface ReportVersion {
|
||||
id: string;
|
||||
session_id: string;
|
||||
version: number;
|
||||
parent_version_id: string | null;
|
||||
change_type: ReportChangeType;
|
||||
change_summary?: string | null;
|
||||
markdown: string;
|
||||
source_index?: ReportSource[];
|
||||
created_at: string;
|
||||
is_head?: boolean;
|
||||
}
|
||||
|
||||
export interface ReportRewriteProposal {
|
||||
id: string;
|
||||
session_id: string;
|
||||
section?: string | null;
|
||||
instruction: string;
|
||||
status: "proposed" | "applied" | "dismissed";
|
||||
markdown?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── 澄清 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClarificationRecord {
|
||||
id: string;
|
||||
questions: string[];
|
||||
impact?: string[];
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── SSE 事件协议 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type CollaborationEventType =
|
||||
// 会话与消息镜像
|
||||
| "session.snapshot"
|
||||
| "message.created"
|
||||
| "message.delta"
|
||||
| "message.completed"
|
||||
| "tool_call.created"
|
||||
| "tool_call.delta"
|
||||
| "tool_call.completed"
|
||||
| "tool_result.created"
|
||||
| "tool_result.delta"
|
||||
| "tool_result.completed"
|
||||
| "team_message.created"
|
||||
| "team_message.delta"
|
||||
| "team_message.completed"
|
||||
// 需求与澄清
|
||||
| "requirement.updated"
|
||||
| "clarification.requested"
|
||||
| "clarification.resolved"
|
||||
// 计划
|
||||
| "plan.progress"
|
||||
| "plan.proposed"
|
||||
| "plan.selected"
|
||||
// Run / 节点
|
||||
| "run.status.changed"
|
||||
| "node.created"
|
||||
| "node.status.changed"
|
||||
| "node.progress"
|
||||
| "node.attempt.created"
|
||||
// 成果与来源
|
||||
| "artifact.created"
|
||||
| "artifact.validated"
|
||||
| "artifact.rejected"
|
||||
| "source.added"
|
||||
// 命令
|
||||
| "command.classified"
|
||||
| "command.confirmation.required"
|
||||
| "command.accepted"
|
||||
| "command.completed"
|
||||
| "command.failed"
|
||||
// 报告与评审
|
||||
| "report.delta"
|
||||
| "report.version.created"
|
||||
| "review.created"
|
||||
// 其他
|
||||
| "error"
|
||||
| "heartbeat";
|
||||
|
||||
export interface CollaborationEvent<T = unknown> {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string | null;
|
||||
seq: number;
|
||||
type: CollaborationEventType;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ── 事件 payload ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MessageEventPayload {
|
||||
message: CollaborationMessage;
|
||||
}
|
||||
|
||||
export interface MessageDeltaPayload {
|
||||
message_id: string;
|
||||
agent_run_id?: string | null;
|
||||
node_run_id?: string | null;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface MessageCompletedPayload {
|
||||
message_id: string;
|
||||
agent_run_id?: string | null;
|
||||
content?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallCreatedPayload {
|
||||
message_id: string;
|
||||
agent_run_id?: string | null;
|
||||
tool_call: CollaborationToolCall;
|
||||
}
|
||||
|
||||
export interface ToolCallDeltaPayload {
|
||||
message_id: string;
|
||||
tool_call_id: string;
|
||||
args_delta: string;
|
||||
}
|
||||
|
||||
export interface ToolCallCompletedPayload {
|
||||
message_id: string;
|
||||
tool_call: CollaborationToolCall;
|
||||
}
|
||||
|
||||
export interface ToolResultCreatedPayload {
|
||||
/** 工具返回作为独立 tool 消息的稳定 id(缺省时前端以 `tool:{tool_call_id}` 派生)。 */
|
||||
message_id?: string;
|
||||
tool_call_id: string;
|
||||
name: string;
|
||||
agent_run_id?: string | null;
|
||||
node_run_id?: string | null;
|
||||
phase_id?: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ToolResultDeltaPayload {
|
||||
message_id?: string;
|
||||
tool_call_id: string;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface ToolResultCompletedPayload {
|
||||
message_id?: string;
|
||||
tool_call_id: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface RequirementUpdatedPayload {
|
||||
requirement: ReportRequirementSnapshot;
|
||||
}
|
||||
|
||||
export interface ClarificationRequestedPayload {
|
||||
clarification: ClarificationRecord;
|
||||
/** 同时镜像为 ask_clarification tool 消息(由后端 ConversationReplica 发出)。 */
|
||||
message?: CollaborationMessage;
|
||||
}
|
||||
|
||||
export interface ClarificationResolvedPayload {
|
||||
clarification_id: string;
|
||||
}
|
||||
|
||||
export interface PlanProgressPayload {
|
||||
stage: string;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanProposedPayload {
|
||||
plans: ReportPlanCandidate[];
|
||||
}
|
||||
|
||||
export interface PlanSelectedPayload {
|
||||
plan_id: string;
|
||||
}
|
||||
|
||||
export interface RunStatusChangedPayload {
|
||||
status: ReportRunStatus;
|
||||
phase?: string | null;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeCreatedPayload {
|
||||
node: CollaborationNodeState;
|
||||
}
|
||||
|
||||
export interface NodeStatusChangedPayload {
|
||||
node_id: string;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
awaiting_input?: NodeAwaitingInput | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeProgressPayload {
|
||||
node_id: string;
|
||||
progress: string;
|
||||
}
|
||||
|
||||
export interface NodeAttemptCreatedPayload {
|
||||
node_id: string;
|
||||
attempt: number;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactCreatedPayload {
|
||||
artifact: CollaborationArtifact;
|
||||
}
|
||||
|
||||
export interface ArtifactValidatedPayload {
|
||||
artifact_id: string;
|
||||
}
|
||||
|
||||
export interface ArtifactRejectedPayload {
|
||||
artifact_id: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface SourceAddedPayload {
|
||||
source: ReportSource;
|
||||
}
|
||||
|
||||
export interface CommandClassifiedPayload {
|
||||
command: CollaborationCommand;
|
||||
}
|
||||
|
||||
export interface CommandConfirmationRequiredPayload {
|
||||
command: CollaborationCommand;
|
||||
}
|
||||
|
||||
export interface CommandAcceptedPayload {
|
||||
command_id: string;
|
||||
waiting_boundary: boolean;
|
||||
impact_nodes?: string[];
|
||||
}
|
||||
|
||||
export interface CommandCompletedPayload {
|
||||
command_id: string;
|
||||
result_summary?: string | null;
|
||||
report_version_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CommandFailedPayload {
|
||||
command_id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ReportDeltaPayload {
|
||||
draft_id?: string | null;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface ReportVersionCreatedPayload {
|
||||
version: ReportVersion;
|
||||
}
|
||||
|
||||
export interface ReviewCreatedPayload {
|
||||
review: {
|
||||
reviewer: string;
|
||||
verdict: "pass" | "revise";
|
||||
issues: { severity: string; target: string; requirement: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorEventPayload {
|
||||
message: string;
|
||||
recover_hint?: string | null;
|
||||
node_id?: string | null;
|
||||
}
|
||||
|
||||
export interface HeartbeatPayload {
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
// ── 快照 / 请求体 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** `GET /sessions/{id}` 返回的完整快照:前端唯一事实源投影。 */
|
||||
export interface SessionSnapshot {
|
||||
session: ReportSession;
|
||||
messages: CollaborationMessage[];
|
||||
plans: ReportPlanCandidate[];
|
||||
run: ReportRun | null;
|
||||
nodes: CollaborationNodeState[];
|
||||
agent_runs: AgentRun[];
|
||||
commands: CollaborationCommand[];
|
||||
report_versions: ReportVersion[];
|
||||
/** 已持久化事件的最大 seq;SSE 以 after_seq=last_seq 续接。 */
|
||||
last_seq: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionPayload {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSessionPayload {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SendMessagePayload {
|
||||
text: string;
|
||||
/** 从节点抽屉 / 并行选择器带出的目标上下文。 */
|
||||
target_node_id?: string | null;
|
||||
agent_run_id?: string | null;
|
||||
}
|
||||
|
||||
export interface StartRunPayload {
|
||||
plan_id: string;
|
||||
}
|
||||
|
||||
export interface CreateCommandPayload {
|
||||
text: string;
|
||||
target_node_id?: string | null;
|
||||
agent_run_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRewritePayload {
|
||||
instruction: string;
|
||||
section?: string | null;
|
||||
}
|
||||
|
||||
/** 统一错误模型(client 层抛出的 ApiError 携带这些字段)。 */
|
||||
export interface ApiErrorDetail {
|
||||
code?: string;
|
||||
message: string;
|
||||
detail?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /runs/{run_id}/nodes/{node_id}/contract` 返回的节点合同详情。
|
||||
* 只含职责与输入输出合同,绝不含执行结果正文(后端 §7.1)。
|
||||
*/
|
||||
export interface NodeContractDetail {
|
||||
node_id: string;
|
||||
label: string;
|
||||
role: string | null;
|
||||
angle: string | null;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
mission: string;
|
||||
input_artifact_types: string[];
|
||||
source_policy?: string | null;
|
||||
output_artifact_type: string;
|
||||
output_structure?: string | null;
|
||||
acceptance_criteria: string[];
|
||||
allowed_tools: string[];
|
||||
forbidden?: string[];
|
||||
time_budget_seconds?: number | null;
|
||||
token_budget?: number | null;
|
||||
max_attempts: number;
|
||||
upstream: string[];
|
||||
downstream: string[];
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Circle,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { AgentRun } from "@/report-collaboration/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentStageSelectorProps {
|
||||
/** 并行阶段标签(协调者首位);空数组时不渲染。 */
|
||||
tabs: AgentRun[];
|
||||
phaseLabel?: string | null;
|
||||
focusedAgentRunId: string | null;
|
||||
onFocus: (agentRunId: string | null) => void;
|
||||
/** 正在流式输出的成员。 */
|
||||
streamingAgentRunIds: Set<string>;
|
||||
/** 请求用户协助(待回复)的成员。 */
|
||||
awaitingAgentRunIds: Set<string>;
|
||||
}
|
||||
|
||||
function statusIcon(agentRun: AgentRun, streaming: boolean, awaiting: boolean) {
|
||||
if (awaiting) {
|
||||
return <AlertTriangle className="size-3.5 shrink-0" style={{ color: "var(--rc-waiting)" }} aria-label="待回复" />;
|
||||
}
|
||||
if (agentRun.status === "completed") {
|
||||
return <Check className="size-3.5 shrink-0" style={{ color: "var(--rc-success)" }} aria-label="已完成" />;
|
||||
}
|
||||
if (agentRun.status === "failed" || agentRun.status === "cancelled") {
|
||||
return <X className="size-3.5 shrink-0" style={{ color: "var(--rc-danger)" }} aria-label={agentRun.status === "failed" ? "失败" : "已取消"} />;
|
||||
}
|
||||
if (agentRun.status === "running" || streaming) {
|
||||
return <Loader2 className="rc-spin size-3.5 shrink-0" style={{ color: "var(--rc-running)" }} aria-label="运行中" />;
|
||||
}
|
||||
return <Circle className="size-3 shrink-0 opacity-50" aria-label="等待" />;
|
||||
}
|
||||
|
||||
function statusText(agentRun: AgentRun, streaming: boolean, awaiting: boolean): string {
|
||||
if (awaiting) return "待回复";
|
||||
if (agentRun.status === "completed") return "已完成";
|
||||
if (agentRun.status === "failed") return "失败";
|
||||
if (agentRun.status === "cancelled") return "已取消";
|
||||
if (agentRun.status === "running" || streaming) return "进行中";
|
||||
return "排队中";
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行阶段成员选择器:只切换同一消息流的聚焦范围,不创建第二套执行视图。
|
||||
* 用户手动切换后保持选择;未聚焦成员的事件继续接收并持久更新。
|
||||
*/
|
||||
export const AgentStageSelector = memo(function AgentStageSelector({
|
||||
tabs,
|
||||
phaseLabel,
|
||||
focusedAgentRunId,
|
||||
onFocus,
|
||||
streamingAgentRunIds,
|
||||
awaitingAgentRunIds,
|
||||
}: AgentStageSelectorProps) {
|
||||
if (tabs.length === 0) return null;
|
||||
const awaitingCount = tabs.filter((tab) => awaitingAgentRunIds.has(tab.agent_run_id)).length;
|
||||
const doneCount = tabs.filter((tab) => tab.status === "completed").length;
|
||||
|
||||
return (
|
||||
<div className="rc-agent-tabs" role="tablist" aria-label="并行成员">
|
||||
<div className="flex shrink-0 items-center gap-1 pr-1 text-[11px] text-[var(--rc-text-secondary)]">
|
||||
<MessagesSquare className="size-3.5" aria-hidden />
|
||||
<span className="max-w-28 truncate">{phaseLabel ?? "并行协作"}</span>
|
||||
<span>
|
||||
{doneCount}/{tabs.length}
|
||||
</span>
|
||||
{awaitingCount > 0 && <span className="rc-badge-num">{awaitingCount}</span>}
|
||||
</div>
|
||||
{tabs.map((tab) => {
|
||||
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
|
||||
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
|
||||
const active = (focusedAgentRunId ?? tabs[0]?.agent_run_id) === tab.agent_run_id;
|
||||
return (
|
||||
<button
|
||||
key={tab.agent_run_id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-active={active}
|
||||
className="rc-agent-tab"
|
||||
onClick={() => onFocus(tab.agent_run_id)}
|
||||
>
|
||||
{statusIcon(tab, streaming, awaiting)}
|
||||
<span className="max-w-32 truncate">{tab.display_name}</span>
|
||||
<span className="text-[10px] text-[var(--rc-text-secondary)]">
|
||||
{statusText(tab, streaming, awaiting)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import type { RcConnectionStatus } from "@/report-collaboration/hooks/useReportCollaborationStream";
|
||||
|
||||
const STATUS_TEXT: Record<RcConnectionStatus, string> = {
|
||||
idle: "未连接",
|
||||
connecting: "连接中…",
|
||||
live: "实时",
|
||||
reconnecting: "重连中…",
|
||||
closed: "已关闭",
|
||||
error: "连接异常",
|
||||
};
|
||||
|
||||
/** 底部连接状态栏:连接态 + run + 事件游标(aria-live 播报)。 */
|
||||
export function ConnectionStatusBar({
|
||||
status,
|
||||
runId,
|
||||
lastSeq,
|
||||
message,
|
||||
}: {
|
||||
status: RcConnectionStatus;
|
||||
runId: string | null;
|
||||
lastSeq: number;
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
void t;
|
||||
return (
|
||||
<footer className="rc-status-bar" aria-live="polite">
|
||||
<span>
|
||||
<span className="rc-status-dot" data-status={status} />
|
||||
SSE:{STATUS_TEXT[status]}
|
||||
{status === "reconnecting" && message ? `(${message.slice(0, 80)})` : ""}
|
||||
</span>
|
||||
<span>run:{runId ? runId.slice(0, 18) : "—"}</span>
|
||||
<span>事件 seq:{lastSeq}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Check, Loader2, X } from "lucide-react";
|
||||
|
||||
import type { CollaborationCommand } from "@/report-collaboration/api/types";
|
||||
|
||||
const INTENT_LABELS: Record<string, string> = {
|
||||
answer_question: "只读问答",
|
||||
update_requirements: "更新需求",
|
||||
add_angle: "新增分析角度",
|
||||
research_again: "重新检索",
|
||||
reanalyze: "重新分析",
|
||||
rerun_node: "重跑节点",
|
||||
rewrite_section: "重写章节",
|
||||
polish_report: "全文润色",
|
||||
ask_report: "报告问答",
|
||||
replan: "重新规划",
|
||||
cancel: "取消",
|
||||
};
|
||||
|
||||
interface InterventionCardProps {
|
||||
command: CollaborationCommand;
|
||||
busy: boolean;
|
||||
onConfirm: (commandId: string) => void;
|
||||
onCancel: (commandId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令确认卡:高成本 / 低置信度命令需要用户显式确认。
|
||||
* 确认/取消都只是提交命令,不改变节点完成状态(状态只由后端状态机驱动)。
|
||||
*/
|
||||
export function InterventionCard({ command, busy, onConfirm, onCancel }: InterventionCardProps) {
|
||||
return (
|
||||
<div className="rc-intervention-card">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: "var(--rc-waiting)" }} aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold">需要确认:{INTENT_LABELS[command.intent ?? ""] ?? "修改操作"}</div>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-[var(--rc-text-secondary)]">
|
||||
「{command.text}」
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="list-disc pl-8 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
<li>
|
||||
影响范围:
|
||||
{command.impact_nodes && command.impact_nodes.length > 0
|
||||
? command.impact_nodes.join("、") + " 及其下游"
|
||||
: "由协调者判定"}
|
||||
</li>
|
||||
<li>
|
||||
成本:{command.cost_level === "high" ? "高(触发重新检索/重写)" : command.cost_level === "low" ? "低" : "中"}
|
||||
</li>
|
||||
{command.reason ? <li>理由:{command.reason}</li> : null}
|
||||
</ul>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="rc-btn" disabled={busy} onClick={() => onCancel(command.command_id)}>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn rc-btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() => onConfirm(command.command_id)}
|
||||
>
|
||||
{busy ? <Loader2 className="rc-spin size-3.5" /> : <Check className="size-3.5" aria-hidden />}
|
||||
确认执行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Ban, Hammer, Target, X } from "lucide-react";
|
||||
|
||||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
import type { NodeContractDetail } from "@/report-collaboration/api/types";
|
||||
|
||||
interface NodeInspectorDrawerProps {
|
||||
open: boolean;
|
||||
nodeId: string | null;
|
||||
runId: string | null;
|
||||
/** 节点基础信息(画布侧已有,用于头部展示,不发起结果请求)。 */
|
||||
fallback?: { label: string; status: string } | null;
|
||||
onClose: () => void;
|
||||
/** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
|
||||
onComment: (nodeId: string, nodeName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
|
||||
* 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
|
||||
* 执行过程统一在左侧 MessageList 查看。
|
||||
*/
|
||||
export function NodeInspectorDrawer({
|
||||
open,
|
||||
nodeId,
|
||||
runId,
|
||||
fallback,
|
||||
onClose,
|
||||
onComment,
|
||||
}: NodeInspectorDrawerProps) {
|
||||
const [contract, setContract] = useState<NodeContractDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !nodeId || !runId) {
|
||||
setContract(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getReportCollaborationDriver()
|
||||
.getNodeContract(runId, nodeId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) setContract(detail);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, nodeId, runId]);
|
||||
|
||||
if (!open || !nodeId) return null;
|
||||
|
||||
const label = contract?.label ?? fallback?.label ?? nodeId;
|
||||
const status = contract?.status ?? fallback?.status ?? "planned";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rc-drawer-mask" onClick={onClose} aria-hidden />
|
||||
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${label}`}>
|
||||
<header className="rc-drawer-section flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold">{label}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-[var(--rc-text-secondary)]">
|
||||
{contract?.role ? <span>{contract.role}</span> : null}
|
||||
{contract?.angle ? <span>· {contract.angle}</span> : null}
|
||||
<span>· 当前状态:{statusText(status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn shrink-0 !min-h-8 !px-2"
|
||||
onClick={onClose}
|
||||
aria-label="关闭节点说明"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
|
||||
) : error ? (
|
||||
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
|
||||
读取节点合同失败:{error}
|
||||
</div>
|
||||
) : contract ? (
|
||||
<>
|
||||
<section className="rc-drawer-section">
|
||||
<div className="rc-drawer-label">节点职责</div>
|
||||
<p className="text-[12px] leading-5">{contract.mission}</p>
|
||||
</section>
|
||||
<section className="rc-drawer-section">
|
||||
<div className="rc-drawer-label flex items-center gap-1">
|
||||
<ArrowDown className="size-3" aria-hidden /> 要根据什么
|
||||
</div>
|
||||
<ul className="list-disc pl-4 text-[12px] leading-5">
|
||||
{contract.input_artifact_types.length > 0 ? (
|
||||
contract.input_artifact_types.map((type) => <li key={type}>输入成果:{type}</li>)
|
||||
) : (
|
||||
<li>无上游成果要求</li>
|
||||
)}
|
||||
{contract.upstream.length > 0 ? (
|
||||
<li>上游节点:{contract.upstream.join("、")}</li>
|
||||
) : null}
|
||||
{contract.source_policy ? <li>资料策略:{contract.source_policy}</li> : null}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="rc-drawer-section">
|
||||
<div className="rc-drawer-label flex items-center gap-1">
|
||||
<ArrowUp className="size-3" aria-hidden /> 要产出什么
|
||||
</div>
|
||||
<ul className="list-disc pl-4 text-[12px] leading-5">
|
||||
<li>成果类型:{contract.output_artifact_type}</li>
|
||||
{contract.acceptance_criteria.map((criterion) => (
|
||||
<li key={criterion}>验收:{criterion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="rc-drawer-section">
|
||||
<div className="rc-drawer-label flex items-center gap-1">
|
||||
<Hammer className="size-3" aria-hidden /> 可用工具与边界
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{contract.allowed_tools.length > 0 ? (
|
||||
contract.allowed_tools.map((tool) => (
|
||||
<span key={tool} className="rc-chip">
|
||||
{tool}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[12px] text-[var(--rc-text-secondary)]">无外部工具</span>
|
||||
)}
|
||||
</div>
|
||||
{contract.forbidden && contract.forbidden.length > 0 ? (
|
||||
<ul className="mt-2 list-disc pl-4 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
{contract.forbidden.map((rule) => (
|
||||
<li key={rule}>
|
||||
<Ban className="mr-1 inline size-3" aria-hidden />
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="mt-2 text-[11px] text-[var(--rc-text-secondary)]">
|
||||
预算:至多 {contract.max_attempts} 次尝试
|
||||
{contract.time_budget_seconds ? ` · ${contract.time_budget_seconds}s` : ""}
|
||||
{contract.token_budget ? ` · ${contract.token_budget} tokens` : ""}
|
||||
</p>
|
||||
</section>
|
||||
<section className="rc-drawer-section">
|
||||
<div className="rc-drawer-label">上下游关系</div>
|
||||
<p className="text-[12px] leading-5">
|
||||
{contract.downstream.length > 0
|
||||
? `下游:${contract.downstream.join("、")}`
|
||||
: "终端节点(无下游)"}
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="rc-drawer-section !border-t border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn rc-btn-primary w-full justify-center"
|
||||
onClick={() => onComment(nodeId, label)}
|
||||
>
|
||||
<Target className="size-3.5" aria-hidden />
|
||||
在对话中对此节点提出意见
|
||||
</button>
|
||||
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
|
||||
</p>
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
planned: "规划中",
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
validating: "校验中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
awaiting_input: "等待用户",
|
||||
cancel_requested: "取消中",
|
||||
cancelled: "已取消",
|
||||
superseded: "已被替代",
|
||||
reopened: "已重开",
|
||||
interrupted: "已中断",
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||
|
||||
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
||||
|
||||
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
|
||||
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||
planning: { label: "生成方案", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
|
||||
proposal_ready: { label: "待确认方案", color: "var(--rc-accent)", bg: "rgb(var(--rc-accent-rgb) / 10%)" },
|
||||
running: { label: "协作中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
|
||||
awaiting_input: { label: "等待输入", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||
reviewing: { label: "审稿中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
|
||||
completed: { label: "已完成", color: "var(--rc-success)", bg: "rgba(22, 163, 74, 0.1)" },
|
||||
failed: { label: "失败", color: "var(--rc-danger)", bg: "rgba(220, 38, 38, 0.1)" },
|
||||
cancelled: { label: "已取消", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||
};
|
||||
|
||||
interface ReportCollaborationHeaderProps {
|
||||
title: string;
|
||||
phase: PagePhase;
|
||||
onBack: () => void;
|
||||
onNewSession: () => void;
|
||||
onOpenReport: () => void;
|
||||
hasReport: boolean;
|
||||
running: boolean;
|
||||
onCancelRun: () => void;
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 顶栏:返回 / 标题+状态 / 报告 / 停止 / 新建(右侧由布局注入窄屏切换等)。 */
|
||||
export function ReportCollaborationHeader({
|
||||
title,
|
||||
phase,
|
||||
onBack,
|
||||
onNewSession,
|
||||
onOpenReport,
|
||||
hasReport,
|
||||
running,
|
||||
onCancelRun,
|
||||
extra,
|
||||
}: ReportCollaborationHeaderProps) {
|
||||
const badge = PHASE_BADGES[phase] ?? PHASE_BADGES.empty;
|
||||
return (
|
||||
<header className="rc-header">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button type="button" className="rc-btn !min-h-8 !px-2" onClick={onBack} aria-label="返回工作台">
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<span className="rc-header-title" title={title}>
|
||||
报告协作 / {title}
|
||||
</span>
|
||||
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{extra}
|
||||
<button type="button" className="rc-btn" onClick={onOpenReport} disabled={!hasReport}>
|
||||
<FileText className="size-3.5" aria-hidden />
|
||||
报告
|
||||
</button>
|
||||
{running ? (
|
||||
<button type="button" className="rc-btn rc-btn-danger" onClick={onCancelRun}>
|
||||
<Square className="size-3.5" aria-hidden />
|
||||
停止
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="rc-btn" onClick={onNewSession}>
|
||||
<MessageSquarePlus className="size-3.5" aria-hidden />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { MessageSquare, Workflow } from "lucide-react";
|
||||
|
||||
import {
|
||||
CANVAS_MIN_WIDTH,
|
||||
PANEL_MIN_WIDTH,
|
||||
RESIZE_KEYBOARD_STEP,
|
||||
type FocusMode,
|
||||
type NarrowView,
|
||||
type UseReportPanelLayoutResult,
|
||||
} from "@/report-collaboration/hooks/useReportPanelLayout";
|
||||
|
||||
interface ReportCollaborationLayoutProps {
|
||||
layout: UseReportPanelLayoutResult;
|
||||
header: React.ReactNode;
|
||||
conversation: React.ReactNode;
|
||||
canvas: React.ReactNode;
|
||||
statusBar: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定两栏骨架:左对话 / 右画布 + 可拖拽分隔条(pointer + 方向键 + Home/End)
|
||||
* + 窄屏单面板切换 + 「专注」模式。节点抽屉与预览 Dialog 由画布侧覆盖渲染。
|
||||
*/
|
||||
export function ReportCollaborationLayout({
|
||||
layout,
|
||||
header,
|
||||
conversation,
|
||||
canvas,
|
||||
statusBar,
|
||||
}: ReportCollaborationLayoutProps) {
|
||||
const { conversationWidth, setConversationWidth, nudgeConversationWidth, isNarrow, narrowView, setNarrowView, focusMode, bodyRef } =
|
||||
layout;
|
||||
|
||||
const dragStart = useRef<{ x: number; width: number } | null>(null);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStart.current = { x: event.clientX, width: conversationWidth };
|
||||
},
|
||||
[conversationWidth],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragStart.current || event.buttons !== 1) return;
|
||||
setConversationWidth(dragStart.current.width + (event.clientX - dragStart.current.x));
|
||||
},
|
||||
[setConversationWidth],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const max = Math.max(
|
||||
PANEL_MIN_WIDTH,
|
||||
Math.min(720, (bodyRef.current?.clientWidth ?? 720) - CANVAS_MIN_WIDTH),
|
||||
);
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
nudgeConversationWidth(-RESIZE_KEYBOARD_STEP);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
nudgeConversationWidth(RESIZE_KEYBOARD_STEP);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setConversationWidth(PANEL_MIN_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setConversationWidth(max);
|
||||
}
|
||||
},
|
||||
[bodyRef, nudgeConversationWidth, setConversationWidth],
|
||||
);
|
||||
|
||||
const showConversation = isNarrow ? narrowView === "conversation" : focusMode !== "canvas";
|
||||
const showCanvas = isNarrow ? narrowView === "canvas" : focusMode !== "conversation";
|
||||
|
||||
return (
|
||||
<div className="rc-root">
|
||||
{header}
|
||||
<div className="rc-body" ref={bodyRef}>
|
||||
{showConversation ? (
|
||||
<section
|
||||
className="rc-conversation-panel"
|
||||
style={{ width: isNarrow ? "100%" : conversationWidth, flexShrink: isNarrow ? 1 : 0 }}
|
||||
aria-label="对话面板"
|
||||
>
|
||||
{conversation}
|
||||
</section>
|
||||
) : null}
|
||||
{!isNarrow && showConversation && showCanvas ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整对话与画布宽度"
|
||||
aria-valuenow={conversationWidth}
|
||||
aria-valuemin={PANEL_MIN_WIDTH}
|
||||
aria-valuemax={720}
|
||||
tabIndex={0}
|
||||
className="rc-resizer"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
) : null}
|
||||
{showCanvas ? (
|
||||
<section className="rc-canvas-panel" aria-label="协作流程画布">
|
||||
{isNarrow ? (
|
||||
<div className="absolute left-2 top-2 z-10">
|
||||
<NarrowSwitch narrowView={narrowView} setNarrowView={setNarrowView} />
|
||||
</div>
|
||||
) : null}
|
||||
{canvas}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
{statusBar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NarrowSwitch({
|
||||
narrowView,
|
||||
setNarrowView,
|
||||
}: {
|
||||
narrowView: NarrowView;
|
||||
setNarrowView: (view: NarrowView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rc-narrow-switch" role="tablist" aria-label="视图切换">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={narrowView === "conversation"}
|
||||
data-active={narrowView === "conversation"}
|
||||
onClick={() => setNarrowView("conversation")}
|
||||
>
|
||||
<MessageSquare className="mr-1 inline size-3" aria-hidden />
|
||||
对话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={narrowView === "canvas"}
|
||||
data-active={narrowView === "canvas"}
|
||||
onClick={() => setNarrowView("canvas")}
|
||||
>
|
||||
<Workflow className="mr-1 inline size-3" aria-hidden />
|
||||
流程
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { FocusMode };
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CornerDownLeft, Loader2, Send, Target, X } from "lucide-react";
|
||||
|
||||
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
||||
|
||||
export interface ComposerTarget {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
agentRunId?: string | null;
|
||||
}
|
||||
|
||||
interface ReportComposerProps {
|
||||
phase: PagePhase;
|
||||
busy: boolean;
|
||||
disabled?: boolean;
|
||||
target: ComposerTarget | null;
|
||||
onClearTarget: () => void;
|
||||
onSend: (text: string) => void;
|
||||
/** 递增以请求聚焦(节点抽屉「在对话中提出意见」)。 */
|
||||
focusSignal: number;
|
||||
}
|
||||
|
||||
const PHASE_HINTS: Record<PagePhase, { placeholder: string; hint: string; tone?: "warning" | "danger" }> = {
|
||||
empty: {
|
||||
placeholder: "描述你的报告需求,例如:撰写一份新能源汽车主要品牌市场趋势分析报告…",
|
||||
hint: "输入需求后回车发送",
|
||||
},
|
||||
clarifying: {
|
||||
placeholder: "回答澄清问题,或补充你的要求…",
|
||||
hint: "优先回答上方的澄清问题",
|
||||
tone: "warning",
|
||||
},
|
||||
planning: {
|
||||
placeholder: "补充要求(将在规划安全点生效)…",
|
||||
hint: "正在生成候选方案,补充要求会在安全点应用",
|
||||
},
|
||||
proposal_ready: {
|
||||
placeholder: "可以先预览方案;确认后点「开始协作」…",
|
||||
hint: "点击方案卡可在右侧预览对应协作流程",
|
||||
},
|
||||
running: {
|
||||
placeholder: "运行中可补充要求:增加角度 / 重新检索 / 重写章节…",
|
||||
hint: "协调者正在跟踪运行,你的输入会被分类并在安全点生效",
|
||||
},
|
||||
awaiting_input: {
|
||||
placeholder: "回答等待中的问题…",
|
||||
hint: "有节点正在等待你的输入,优先处理等待卡",
|
||||
tone: "warning",
|
||||
},
|
||||
reviewing: {
|
||||
placeholder: "审稿进行中,可补充要求…",
|
||||
hint: "审稿意见会出现在时间线中",
|
||||
},
|
||||
completed: {
|
||||
placeholder: "报告已完成:继续问答、重写章节、全文润色…",
|
||||
hint: "修改会生成新版本,不直接覆盖正式报告",
|
||||
},
|
||||
failed: {
|
||||
placeholder: "通过对话重试,或取消后从已有成果继续…",
|
||||
hint: "运行失败,可在对话中要求重试",
|
||||
tone: "danger",
|
||||
},
|
||||
cancelled: {
|
||||
placeholder: "可从已有成果继续,或生成新方案…",
|
||||
hint: "运行已取消,已完成的成果仍然可用",
|
||||
},
|
||||
};
|
||||
|
||||
/** 统一输入框:按页面状态提示;Enter 发送 / Shift+Enter 换行。 */
|
||||
export function ReportComposer({
|
||||
phase,
|
||||
busy,
|
||||
disabled = false,
|
||||
target,
|
||||
onClearTarget,
|
||||
onSend,
|
||||
focusSignal,
|
||||
}: ReportComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const hint = PHASE_HINTS[phase] ?? PHASE_HINTS.empty;
|
||||
|
||||
useEffect(() => {
|
||||
if (focusSignal > 0) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [focusSignal]);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || busy || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rc-composer">
|
||||
<div className="rc-composer-hint" data-tone={hint.tone} aria-live="polite">
|
||||
{busy ? <Loader2 className="rc-spin size-3.5" aria-hidden /> : null}
|
||||
<span>{busy ? "正在识别修改范围…" : hint.hint}</span>
|
||||
</div>
|
||||
{target ? (
|
||||
<div className="mb-1 flex items-center gap-2 px-1">
|
||||
<span className="rc-composer-target">
|
||||
<Target className="size-3" aria-hidden />
|
||||
对节点「{target.nodeName}」提出意见
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-[var(--rc-text-secondary)] hover:text-[var(--rc-danger)]"
|
||||
onClick={onClearTarget}
|
||||
aria-label="清除节点目标"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="min-h-24 w-full resize-none rounded-lg border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] px-3 py-2.5 pr-14 text-[13px] leading-6 outline-none transition-colors focus:border-[var(--rc-accent)] disabled:opacity-60"
|
||||
rows={4}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={hint.placeholder}
|
||||
aria-label="报告协作输入框"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-2.5 right-2.5 inline-flex size-9 items-center justify-center rounded-lg text-white shadow disabled:opacity-50"
|
||||
style={{ background: "var(--rc-accent)" }}
|
||||
disabled={!value.trim() || busy || disabled}
|
||||
onClick={submit}
|
||||
aria-label="发送"
|
||||
>
|
||||
{busy ? <Loader2 className="rc-spin size-4" /> : <Send className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-end gap-1 pr-1 text-[10px] text-[var(--rc-text-secondary)]">
|
||||
<CornerDownLeft className="size-3" aria-hidden />
|
||||
<span>Enter 发送 · Shift+Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
interface ReportConversationPanelProps {
|
||||
sessionId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
businessCardSlot: (message: Message) => React.ReactNode;
|
||||
stageSelector?: React.ReactNode;
|
||||
composer: React.ReactNode;
|
||||
loading: boolean;
|
||||
emptyHint?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧对话面板:外层结构参考 Coze,内部严格复用 DeerFlow `MessageList`
|
||||
* (原生 assistant / tool_calls / tool 消息、引用与协助卡渲染)。
|
||||
*/
|
||||
export function ReportConversationPanel({
|
||||
sessionId,
|
||||
thread,
|
||||
businessCardSlot,
|
||||
stageSelector,
|
||||
composer,
|
||||
loading,
|
||||
emptyHint,
|
||||
}: ReportConversationPanelProps) {
|
||||
const hasMessages = thread.messages.length > 0;
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (loading && !hasMessages) {
|
||||
return (
|
||||
<div className="rc-empty" aria-busy="true">
|
||||
<MessagesSquare className="size-8 opacity-30" aria-hidden />
|
||||
<p>正在恢复会话…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hasMessages) {
|
||||
return <div className="rc-empty">{emptyHint ?? "输入报告需求开始协作"}</div>;
|
||||
}
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock: true }}>
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={sessionId}
|
||||
thread={thread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-2"
|
||||
businessCardSlot={businessCardSlot}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}, [businessCardSlot, emptyHint, hasMessages, loading, sessionId, thread]);
|
||||
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{stageSelector}
|
||||
{content}
|
||||
{composer}
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Workflow } from "lucide-react";
|
||||
|
||||
import type {
|
||||
CollaborationNodeState,
|
||||
ReportPlanCandidate,
|
||||
} from "@/report-collaboration/api/types";
|
||||
import {
|
||||
CollaborationFlowNode,
|
||||
type ReportFlowNodeDatum,
|
||||
} from "@/report-collaboration/components/ReportFlowNode";
|
||||
|
||||
const NODE_TYPES: NodeTypes = { collaboration: CollaborationFlowNode };
|
||||
const COLUMN_GAP = 280;
|
||||
const ROW_GAP = 130;
|
||||
|
||||
interface ReportFlowCanvasProps {
|
||||
plan: ReportPlanCandidate | null;
|
||||
nodeStates: Record<string, CollaborationNodeState>;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode: (nodeId: string | null) => void;
|
||||
/** 空态文案(无方案时)。 */
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
/** 分层自动布局:depth = 最长依赖链长度;同层纵向排列。 */
|
||||
function layoutPlan(plan: ReportPlanCandidate): Map<string, { x: number; y: number }> {
|
||||
const depthById = new Map<string, number>();
|
||||
const nodeIds = new Set(plan.nodes.map((node) => node.id));
|
||||
const visit = (nodeId: string, seen: Set<string>): number => {
|
||||
if (depthById.has(nodeId)) return depthById.get(nodeId)!;
|
||||
if (seen.has(nodeId)) return 0; // 防御环
|
||||
seen.add(nodeId);
|
||||
const node = plan.nodes.find((candidate) => candidate.id === nodeId);
|
||||
const depth =
|
||||
node && node.depends_on.length > 0
|
||||
? Math.max(...node.depends_on.filter((dep) => nodeIds.has(dep)).map((dep) => visit(dep, seen))) + 1
|
||||
: 0;
|
||||
depthById.set(nodeId, depth);
|
||||
return depth;
|
||||
};
|
||||
for (const node of plan.nodes) visit(node.id, new Set());
|
||||
|
||||
const layers = new Map<number, string[]>();
|
||||
for (const node of plan.nodes) {
|
||||
const depth = depthById.get(node.id) ?? 0;
|
||||
layers.set(depth, [...(layers.get(depth) ?? []), node.id]);
|
||||
}
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const [depth, ids] of layers) {
|
||||
ids.forEach((id, index) => {
|
||||
positions.set(id, { x: depth * COLUMN_GAP, y: index * ROW_GAP });
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint }: ReportFlowCanvasProps) {
|
||||
const rf = useReactFlow();
|
||||
const autoPositions = useMemo(() => (plan ? layoutPlan(plan) : new Map<string, { x: number; y: number }>()), [plan]);
|
||||
// 拖动位置是纯浏览器展示状态:per-plan 保存,方案切换即清理。
|
||||
const draggedRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const [draggedVersion, setDraggedVersion] = useState(0);
|
||||
const planKey = plan?.id ?? "none";
|
||||
|
||||
useEffect(() => {
|
||||
draggedRef.current = new Map();
|
||||
setDraggedVersion((v) => v + 1);
|
||||
}, [planKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// 方案切换后适配视图(下一帧节点已挂载)。
|
||||
const timer = window.setTimeout(() => rf.fitView({ padding: 0.18, duration: 240 }), 60);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [planKey, rf]);
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
if (!plan) return [];
|
||||
void draggedVersion;
|
||||
return plan.nodes.map((node) => {
|
||||
const state = nodeStates[node.id];
|
||||
const datum: ReportFlowNodeDatum = {
|
||||
nodeId: node.id,
|
||||
label: node.label,
|
||||
roleName: state?.role_display_name ?? node.role_display_name ?? null,
|
||||
angle: state?.angle ?? node.angle ?? null,
|
||||
status: state?.status ?? "planned",
|
||||
attempt: state?.attempt ?? 0,
|
||||
selected: selectedNodeId === node.id,
|
||||
};
|
||||
const dragged = draggedRef.current.get(node.id);
|
||||
const position = dragged ?? autoPositions.get(node.id) ?? { x: 0, y: 0 };
|
||||
return {
|
||||
id: node.id,
|
||||
type: "collaboration",
|
||||
position,
|
||||
data: datum,
|
||||
draggable: true,
|
||||
};
|
||||
});
|
||||
}, [plan, nodeStates, selectedNodeId, autoPositions, draggedVersion]);
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!plan) return [];
|
||||
return plan.edges.map((edge, index) => {
|
||||
const targetState = nodeStates[edge.target];
|
||||
const active = targetState?.status === "running" || targetState?.status === "validating";
|
||||
return {
|
||||
id: `${edge.source}->${edge.target}-${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label ?? undefined,
|
||||
animated: false,
|
||||
className: active ? "rc-edge-active" : undefined,
|
||||
style: { stroke: "var(--rc-border-strong)", strokeWidth: 1.5 },
|
||||
};
|
||||
});
|
||||
}, [plan, nodeStates]);
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="rc-empty">
|
||||
<Workflow className="size-10 opacity-30" aria-hidden />
|
||||
<p>{emptyHint ?? "确认候选方案后,这里会展示协作流程图"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onNodeClick={(_, node) => onSelectNode(node.id)}
|
||||
onPaneClick={() => onSelectNode(null)}
|
||||
onNodeDragStop={(_, node) => {
|
||||
draggedRef.current.set(node.id, { x: node.position.x, y: node.position.y });
|
||||
setDraggedVersion((v) => v + 1);
|
||||
}}
|
||||
onNodeDoubleClick={(_, node) => onSelectNode(node.id)}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.18 }}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.75}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
proOptions={{ hideAttribution: true }}
|
||||
aria-label="协作流程画布"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="var(--rc-border-strong)" />
|
||||
<Controls position="bottom-left" showInteractive={false} />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
style={{ border: "1px solid var(--rc-border)", borderRadius: 8 }}
|
||||
nodeColor={() => "var(--rc-border-strong)"}
|
||||
maskColor="rgba(247, 247, 252, 0.7)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
/** 协作流程画布:只读展示 + 本地拖动;重跑/纠偏一律通过左侧对话完成。 */
|
||||
export function ReportFlowCanvas(props: ReportFlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleDashed,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ShieldQuestion,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { CollaborationNodeStatus } from "@/report-collaboration/api/types";
|
||||
|
||||
/** 画布自定义节点数据(由 ReportFlowCanvas 组装)。 */
|
||||
export interface ReportFlowNodeDatum {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
roleName?: string | null;
|
||||
angle?: string | null;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
selected: boolean;
|
||||
/** xyflow Node.data 需要 Record 兼容的索引签名。 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ReportFlowNodeType = "collaboration";
|
||||
|
||||
const STATUS_META: Record<
|
||||
CollaborationNodeStatus,
|
||||
{ text: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
planned: { text: "规划中", color: "var(--rc-text-secondary)", icon: <CircleDashed className="size-3.5" /> },
|
||||
queued: { text: "排队中", color: "var(--rc-text-secondary)", icon: <CircleDashed className="size-3.5" /> },
|
||||
running: { text: "运行中", color: "var(--rc-running)", icon: <Loader2 className="rc-spin size-3.5" /> },
|
||||
validating: { text: "校验中", color: "var(--rc-running)", icon: <ShieldQuestion className="size-3.5" /> },
|
||||
completed: { text: "已完成", color: "var(--rc-success)", icon: <Check className="size-3.5" /> },
|
||||
failed: { text: "失败", color: "var(--rc-danger)", icon: <X className="size-3.5" /> },
|
||||
awaiting_input: { text: "等待用户", color: "var(--rc-waiting)", icon: <AlertTriangle className="size-3.5" /> },
|
||||
cancel_requested: { text: "取消中", color: "var(--rc-waiting)", icon: <X className="size-3.5" /> },
|
||||
cancelled: { text: "已取消", color: "var(--rc-text-secondary)", icon: <X className="size-3.5" /> },
|
||||
superseded: { text: "已被替代", color: "var(--rc-text-secondary)", icon: <RefreshCw className="size-3.5" /> },
|
||||
reopened: { text: "已重开", color: "var(--rc-waiting)", icon: <RefreshCw className="size-3.5" /> },
|
||||
interrupted: { text: "已中断", color: "var(--rc-waiting)", icon: <AlertTriangle className="size-3.5" /> },
|
||||
};
|
||||
|
||||
function CollaborationFlowNodeComponent({ data }: NodeProps) {
|
||||
const node = data as unknown as ReportFlowNodeDatum;
|
||||
const meta = STATUS_META[node.status] ?? STATUS_META.planned;
|
||||
return (
|
||||
<div
|
||||
className="rc-flow-node"
|
||||
data-status={node.status}
|
||||
data-selected={node.selected}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`节点 ${node.label},${meta.text}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="!bg-[var(--rc-border-strong)]" isConnectable={false} />
|
||||
<div className="rc-flow-node-title">{node.label}</div>
|
||||
{(node.roleName || node.angle) && (
|
||||
<div className="rc-flow-node-sub truncate">
|
||||
{[node.roleName, node.angle].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="rc-flow-node-status" style={{ color: meta.color }}>
|
||||
{meta.icon}
|
||||
<span>
|
||||
{meta.text}
|
||||
{node.attempt > 1 ? ` · 第 ${node.attempt} 次` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-[var(--rc-border-strong)]" isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CollaborationFlowNode = memo(CollaborationFlowNodeComponent);
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { Clock, Gauge, Play, RefreshCw, ShieldCheck, Star, Users } from "lucide-react";
|
||||
|
||||
import type { ReportPlanCandidate } from "@/report-collaboration/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReportPlanCardsProps {
|
||||
plans: ReportPlanCandidate[];
|
||||
selectedPlanId: string | null;
|
||||
previewPlanId: string | null;
|
||||
onPreview: (planId: string) => void;
|
||||
onSelect: (planId: string) => void;
|
||||
onStart: (planId: string) => void;
|
||||
onRegenerate: () => void;
|
||||
selectBusy: boolean;
|
||||
startBusy: boolean;
|
||||
regenerateBusy: boolean;
|
||||
}
|
||||
|
||||
const STRATEGY_LABELS: Record<string, string> = {
|
||||
balanced_review: "均衡审阅",
|
||||
parallel_depth: "深度并行",
|
||||
focused_fast: "快速聚焦",
|
||||
};
|
||||
|
||||
function costLabel(level: string | null | undefined): string {
|
||||
if (level === "low") return "成本 低";
|
||||
if (level === "high") return "成本 高";
|
||||
return "成本 中";
|
||||
}
|
||||
|
||||
function durationLabel(seconds: number): string {
|
||||
if (seconds >= 3600) return `约 ${Math.round(seconds / 3600)} 小时`;
|
||||
if (seconds >= 60) return `约 ${Math.round(seconds / 60)} 分钟`;
|
||||
return `约 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选方案卡组:点击卡片只切换右侧画布预览(纯展示状态),
|
||||
* 「采用此方案」才写服务器 selectedPlanId,「开始协作」创建 run。
|
||||
*/
|
||||
export function ReportPlanCards({
|
||||
plans,
|
||||
selectedPlanId,
|
||||
previewPlanId,
|
||||
onPreview,
|
||||
onSelect,
|
||||
onStart,
|
||||
onRegenerate,
|
||||
selectBusy,
|
||||
startBusy,
|
||||
regenerateBusy,
|
||||
}: ReportPlanCardsProps) {
|
||||
if (plans.length === 0) return null;
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[13px] font-semibold">我整理了 {plans.length} 条候选协作方案</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerateBusy}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", regenerateBusy && "rc-spin")} aria-hidden />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-1 text-[12px] text-[var(--rc-text-secondary)]">
|
||||
点击卡片在右侧预览协作流程;采用后才会按该方案执行。
|
||||
</p>
|
||||
{plans.map((plan, index) => {
|
||||
const isSelected = plan.id === selectedPlanId;
|
||||
const isPreviewed = plan.id === (previewPlanId ?? selectedPlanId ?? plans[0]?.id);
|
||||
const invalid = plan.validation && plan.validation.ok === false;
|
||||
return (
|
||||
<article
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"rc-plan-card",
|
||||
isPreviewed && "rc-plan-card-selected",
|
||||
invalid && "rc-plan-card-disabled",
|
||||
)}
|
||||
onClick={() => onPreview(plan.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isPreviewed}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onPreview(plan.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rc-chip">
|
||||
{plan.recommended || index === 0 ? (
|
||||
<>
|
||||
<Star className="size-3" aria-hidden /> 推荐
|
||||
</>
|
||||
) : (
|
||||
`方案 ${index + 1}`
|
||||
)}
|
||||
</span>
|
||||
<span className="rc-chip" style={{ background: "var(--rc-bg-secondary)", color: "var(--rc-text-secondary)" }}>
|
||||
{STRATEGY_LABELS[plan.strategy] ?? plan.strategy}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<span className="rc-chip" style={{ background: "rgba(22, 163, 74, 0.1)", color: "var(--rc-success)" }}>
|
||||
已采用
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="text-[13px] font-semibold leading-5">{plan.title}</h3>
|
||||
<p className="text-[12px] leading-5 text-[var(--rc-text-secondary)]">{plan.summary}</p>
|
||||
<p className="text-[11px] leading-4 text-[var(--rc-text-secondary)] opacity-80">{plan.rationale}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Users className="size-3 text-[var(--rc-text-secondary)]" aria-hidden />
|
||||
{plan.roles.slice(0, 6).map((role) => (
|
||||
<span key={role.key} className="rc-chip">
|
||||
{role.display_name}
|
||||
</span>
|
||||
))}
|
||||
{plan.roles.length > 6 ? (
|
||||
<span className="text-[11px] text-[var(--rc-text-secondary)]">+{plan.roles.length - 6}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-[var(--rc-text-secondary)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" aria-hidden />
|
||||
{durationLabel(plan.estimated_duration_seconds)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Gauge className="size-3" aria-hidden />
|
||||
{costLabel(plan.estimated_cost_level)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3" aria-hidden />
|
||||
{plan.nodes.length} 节点 · {plan.quality_gates.length} 质量门
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{invalid ? (
|
||||
<div
|
||||
className="rounded-md px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
background: "rgba(220, 38, 38, 0.08)",
|
||||
color: "var(--rc-danger)",
|
||||
}}
|
||||
>
|
||||
方案校验未通过:{plan.validation.errors.join(";")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
disabled={isSelected || selectBusy || invalid}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(plan.id);
|
||||
}}
|
||||
>
|
||||
{isSelected ? "已采用" : "采用此方案"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn rc-btn-primary"
|
||||
disabled={!isSelected || startBusy || invalid}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStart(plan.id);
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" aria-hidden />
|
||||
开始协作
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, History, Loader2, RotateCcw } from "lucide-react";
|
||||
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
|
||||
import type { ReportVersion } from "@/report-collaboration/api/types";
|
||||
|
||||
interface ReportPreviewDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
versions: ReportVersion[];
|
||||
activeVersionId: string | null;
|
||||
onSelectVersion: (versionId: string) => void;
|
||||
onRestore: (versionId: string) => void;
|
||||
restoreBusy: boolean;
|
||||
}
|
||||
|
||||
function downloadMarkdownFile(title: string, markdown: string): void {
|
||||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${title || "报告"}.md`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** 大尺寸报告预览 Dialog:Markdown 渲染 + 版本切换 + 下载/恢复。 */
|
||||
export function ReportPreviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
versions,
|
||||
activeVersionId,
|
||||
onSelectVersion,
|
||||
onRestore,
|
||||
restoreBusy,
|
||||
}: ReportPreviewDialogProps) {
|
||||
const [downloading, setDownloading] = useState<"md" | "docx" | null>(null);
|
||||
if (!open) return null;
|
||||
const active =
|
||||
versions.find((version) => version.id === activeVersionId) ?? versions.at(-1) ?? null;
|
||||
if (!active) return null;
|
||||
|
||||
const sorted = [...versions].sort((a, b) => b.version - a.version);
|
||||
const isHead = active.is_head || active.id === versions.at(-1)?.id;
|
||||
|
||||
const handleDocx = async () => {
|
||||
if (!active) return;
|
||||
setDownloading("docx");
|
||||
try {
|
||||
await downloadMarkdownDocxFromBackend({
|
||||
title: `报告 v${active.version}`,
|
||||
markdown: active.markdown,
|
||||
});
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/40 p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="报告预览"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-full max-h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-[var(--rc-border)] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="size-4 shrink-0" style={{ color: "var(--rc-accent)" }} aria-hidden />
|
||||
<span className="truncate text-[14px] font-semibold">报告预览 · v{active.version}</span>
|
||||
{active.change_summary ? (
|
||||
<span className="truncate text-[12px] text-[var(--rc-text-secondary)]">
|
||||
{active.change_summary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button type="button" className="rc-btn" onClick={() => downloadMarkdownFile(`报告_v${active.version}`, active.markdown)}>
|
||||
下载 Markdown
|
||||
</button>
|
||||
<button type="button" className="rc-btn" onClick={() => void handleDocx()} disabled={downloading === "docx"}>
|
||||
{downloading === "docx" ? <Loader2 className="rc-spin size-3.5" /> : null}
|
||||
下载 Word
|
||||
</button>
|
||||
{!isHead ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
disabled={restoreBusy}
|
||||
onClick={() => onRestore(active.id)}
|
||||
title="恢复此版本(创建新的头版本,不删除历史)"
|
||||
>
|
||||
{restoreBusy ? <Loader2 className="rc-spin size-3.5" /> : <RotateCcw className="size-3.5" />}
|
||||
恢复此版本
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="rc-btn" onClick={onClose} aria-label="关闭预览">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{sorted.length > 1 ? (
|
||||
<nav className="w-44 shrink-0 overflow-y-auto border-r border-[var(--rc-border)] p-2" aria-label="版本历史">
|
||||
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] font-semibold text-[var(--rc-text-secondary)]">
|
||||
<History className="size-3" aria-hidden /> 版本
|
||||
</div>
|
||||
{sorted.map((version) => (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
className="rc-tab block w-full text-left"
|
||||
data-active={version.id === active.id}
|
||||
onClick={() => onSelectVersion(version.id)}
|
||||
>
|
||||
v{version.version}
|
||||
{version.is_head ? "(当前)" : ""}
|
||||
<span className="block text-[10px] font-normal opacity-70">
|
||||
{version.change_type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<MarkdownContent
|
||||
content={active.markdown}
|
||||
className="rc-report-preview"
|
||||
rehypePlugins={[]}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MessageList 业务卡:报告入口卡(点击打开大尺寸预览 Dialog)。 */
|
||||
export function ReportAvailableCard({
|
||||
version,
|
||||
versionCount,
|
||||
onOpen,
|
||||
}: {
|
||||
version: ReportVersion;
|
||||
versionCount: number;
|
||||
onOpen: (versionId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rc-report-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="size-4" style={{ color: "var(--rc-success)" }} aria-hidden />
|
||||
<span className="text-[13px] font-semibold">报告 v{version.version} 已生成</span>
|
||||
{versionCount > 1 ? (
|
||||
<span className="rc-chip" style={{ background: "var(--rc-bg-secondary)", color: "var(--rc-text-secondary)" }}>
|
||||
共 {versionCount} 个版本
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{version.change_summary ? (
|
||||
<p className="text-[12px] text-[var(--rc-text-secondary)]">{version.change_summary}</p>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="rc-btn rc-btn-primary" onClick={() => onOpen(version.id)}>
|
||||
预览报告
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
onClick={() => downloadMarkdownFile(`报告_v${version.version}`, version.markdown)}
|
||||
>
|
||||
下载 Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, Loader2, MessageSquarePlus, Pencil, Trash2, X } from "lucide-react";
|
||||
|
||||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
import type { ReportSessionSummary } from "@/report-collaboration/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
empty: "空会话",
|
||||
clarifying: "澄清需求",
|
||||
planning: "生成方案中",
|
||||
proposal_ready: "待确认方案",
|
||||
running: "协作中",
|
||||
awaiting_input: "等待输入",
|
||||
reviewing: "审稿中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
/** 历史会话面板:切换 / 重命名 / 删除 / 新建。 */
|
||||
export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: string | null }) {
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<ReportSessionSummary[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
try {
|
||||
const list = await getReportCollaborationDriver().listSessions();
|
||||
setSessions(list);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const commitRename = async (sessionId: string) => {
|
||||
const title = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
if (!title) return;
|
||||
setBusyId(sessionId);
|
||||
try {
|
||||
await getReportCollaborationDriver().updateSession(sessionId, { title });
|
||||
await reload(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSession = async (sessionId: string) => {
|
||||
if (!window.confirm("删除该会话将同时删除其消息、方案与报告版本,且不可恢复。确定删除?")) {
|
||||
return;
|
||||
}
|
||||
setBusyId(sessionId);
|
||||
try {
|
||||
await getReportCollaborationDriver().deleteSession(sessionId);
|
||||
if (activeSessionId === sessionId) {
|
||||
navigate("/page/workspace/report-collaboration");
|
||||
} else {
|
||||
await reload(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-[12px] text-[var(--rc-text-secondary)]">
|
||||
{loading ? "加载中…" : `${sessions?.length ?? 0} 个会话`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
onClick={() => navigate("/page/workspace/report-collaboration")}
|
||||
>
|
||||
<MessageSquarePlus className="size-3.5" aria-hidden />
|
||||
新建会话
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="px-3 pb-1 text-[11px]" style={{ color: "var(--rc-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{(sessions ?? []).map((session) => {
|
||||
const active = session.id === activeSessionId;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-lg px-2.5 py-2 transition-colors",
|
||||
active ? "bg-[var(--rc-accent-soft)]" : "hover:bg-[var(--rc-bg-secondary)]",
|
||||
)}
|
||||
>
|
||||
{renamingId === session.id ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 rounded-md border border-[var(--rc-accent)] bg-[var(--rc-bg-panel)] px-2 py-1 text-[12px] outline-none"
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitRename(session.id);
|
||||
if (event.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
aria-label="会话标题"
|
||||
/>
|
||||
<button type="button" className="rc-btn !min-h-7 !px-1.5" onClick={() => void commitRename(session.id)} aria-label="保存标题">
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" className="rc-btn !min-h-7 !px-1.5" onClick={() => setRenamingId(null)} aria-label="取消重命名">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => navigate(`/page/workspace/report-collaboration/${session.id}`)}
|
||||
>
|
||||
<div className="truncate text-[12px] font-medium">{session.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--rc-text-secondary)]">
|
||||
<span>{STATUS_LABELS[session.status] ?? session.status}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(session.updated_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<span className="hidden shrink-0 items-center gap-1 group-hover:flex">
|
||||
{busyId === session.id ? (
|
||||
<Loader2 className="rc-spin size-3.5" aria-hidden />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-[var(--rc-bg-secondary)]"
|
||||
aria-label="重命名会话"
|
||||
onClick={() => {
|
||||
setRenamingId(session.id);
|
||||
setRenameValue(session.title);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-[var(--rc-bg-secondary)]"
|
||||
aria-label="删除会话"
|
||||
onClick={() => void removeSession(session.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" style={{ color: "var(--rc-danger)" }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && (sessions?.length ?? 0) === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-[12px] text-[var(--rc-text-secondary)]">
|
||||
暂无历史会话
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const sameDay = date.toDateString() === now.toDateString();
|
||||
return sameDay
|
||||
? date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
: date.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 消息组装 hook:状态 → 聚焦过滤 → LangGraph Message[] → BaseStream 外观。
|
||||
*
|
||||
* - 并行聚焦只做客户端过滤(共享 human + 目标成员消息),不重拉不改写。
|
||||
* - 虚拟业务卡(方案组 / 命令确认 / 报告入口)由页面以 extraCards 传入,
|
||||
* 经 MessageList 的 businessCardSlot 渲染。
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
import type { CollaborationMessage } from "@/report-collaboration/api/types";
|
||||
import type { CollaborationState } from "@/report-collaboration/state/event-reducer";
|
||||
import {
|
||||
buildBaseStreamFacade,
|
||||
toLangGraphMessages,
|
||||
} from "@/report-collaboration/state/message-adapter";
|
||||
import { selectMessagesForAgentRun, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors";
|
||||
|
||||
export interface UseReportCollaborationMessagesResult {
|
||||
visibleMessages: CollaborationMessage[];
|
||||
focusedMessages: CollaborationMessage[];
|
||||
displayMessages: Message[];
|
||||
displayThread: BaseStream<AgentThreadState>;
|
||||
/** 存在未完成消息(流式中)。 */
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
export function useReportCollaborationMessages(
|
||||
state: CollaborationState,
|
||||
focusedAgentRunId: string | null,
|
||||
extraCards: Message[],
|
||||
extraLoading: boolean,
|
||||
): UseReportCollaborationMessagesResult {
|
||||
return useMemo(() => {
|
||||
const visibleMessages = selectOrderedVisibleMessages(state);
|
||||
const focusedMessages = selectMessagesForAgentRun(visibleMessages, focusedAgentRunId);
|
||||
const base = toLangGraphMessages(focusedMessages);
|
||||
const displayMessages = extraCards.length > 0 ? [...base, ...extraCards] : base;
|
||||
const streaming =
|
||||
visibleMessages.some((message) => !message.completed_at && message.role !== "human") ||
|
||||
extraLoading;
|
||||
const displayThread = buildBaseStreamFacade(displayMessages, streaming);
|
||||
return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming };
|
||||
}, [extraCards, extraLoading, focusedAgentRunId, state]);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 会话编排 hook:快照加载 → SSE 续接 → 合帧 reducer → 动作派发。
|
||||
*
|
||||
* 页面唯一的数据入口;纯展示状态(预览方案、聚焦成员、面板宽度)不在此层。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
|
||||
import { newIdempotencyKey } from "@/report-collaboration/api/client";
|
||||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
import type {
|
||||
CollaborationEvent,
|
||||
CollaborationMessage,
|
||||
SessionSnapshot,
|
||||
} from "@/report-collaboration/api/types";
|
||||
import {
|
||||
applyCollaborationEvents,
|
||||
applySessionSnapshot,
|
||||
createInitialCollaborationState,
|
||||
hasSeqGap,
|
||||
insertOptimisticUserMessage,
|
||||
type CollaborationState,
|
||||
} from "@/report-collaboration/state/event-reducer";
|
||||
import { normalizeCollaborationEvent } from "@/report-collaboration/state/event-normalizer";
|
||||
import {
|
||||
useReportCollaborationStream,
|
||||
type RcStreamStatus,
|
||||
} from "@/report-collaboration/hooks/useReportCollaborationStream";
|
||||
|
||||
const EVENT_FLUSH_MS = 50;
|
||||
const PRE_RUN_POLL_MS = 2500;
|
||||
|
||||
type RcAction =
|
||||
| { type: "snapshot"; snapshot: SessionSnapshot }
|
||||
| { type: "events"; events: CollaborationEvent[] }
|
||||
| { type: "optimistic"; message: CollaborationMessage };
|
||||
|
||||
function rcReducer(state: CollaborationState, action: RcAction): CollaborationState {
|
||||
switch (action.type) {
|
||||
case "snapshot":
|
||||
return applySessionSnapshot(state, action.snapshot);
|
||||
case "events":
|
||||
return applyCollaborationEvents(state, action.events);
|
||||
case "optimistic":
|
||||
return insertOptimisticUserMessage(state, action.message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RcInterventionTarget {
|
||||
nodeId?: string | null;
|
||||
agentRunId?: string | null;
|
||||
}
|
||||
|
||||
export interface UseReportCollaborationSessionResult {
|
||||
state: CollaborationState;
|
||||
streamStatus: RcStreamStatus;
|
||||
snapshotLoading: boolean;
|
||||
snapshotError: string | null;
|
||||
/** 最近一次动作错误(422/409/429/网络),供页面 toast 展示。 */
|
||||
actionError: string | null;
|
||||
clearActionError: () => void;
|
||||
sendBusy: boolean;
|
||||
|
||||
/** 全过程发送:run 活动中走 commands,其余走 session messages。 */
|
||||
send: (text: string, target?: RcInterventionTarget) => Promise<void>;
|
||||
requestPlans: () => Promise<void>;
|
||||
selectPlan: (planId: string) => Promise<void>;
|
||||
startRun: (planId: string) => Promise<void>;
|
||||
cancelRun: () => Promise<void>;
|
||||
confirmCommand: (commandId: string) => Promise<void>;
|
||||
cancelCommand: (commandId: string) => Promise<void>;
|
||||
refreshSnapshot: () => void;
|
||||
}
|
||||
|
||||
function optimisticUserMessage(text: string, idempotencyKey: string): CollaborationMessage {
|
||||
return {
|
||||
message_id: `pending:${idempotencyKey}`,
|
||||
role: "human",
|
||||
content: text,
|
||||
visibility: "visible",
|
||||
metadata: { optimistic: true, idempotency_key: idempotencyKey },
|
||||
created_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function isRunActive(status: string | undefined | null): boolean {
|
||||
return (
|
||||
status === "queued" ||
|
||||
status === "planning" ||
|
||||
status === "running" ||
|
||||
status === "awaiting_input" ||
|
||||
status === "reviewing" ||
|
||||
status === "recovering"
|
||||
);
|
||||
}
|
||||
|
||||
export function useReportCollaborationSession(sessionId: string): UseReportCollaborationSessionResult {
|
||||
const driver = useMemo(() => getReportCollaborationDriver(), []);
|
||||
const [state, dispatch] = useReducer(rcReducer, sessionId, createInitialCollaborationState);
|
||||
const [streamStatus, setStreamStatus] = useState<RcStreamStatus>({ status: "idle", attempts: 0 });
|
||||
const [snapshotLoading, setSnapshotLoading] = useState(true);
|
||||
const [snapshotError, setSnapshotError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [sendBusy, setSendBusy] = useState(false);
|
||||
|
||||
const lastSeqRef = useRef(0);
|
||||
lastSeqRef.current = state.lastSeq;
|
||||
const runId = state.run?.id ?? state.session?.active_run_id ?? null;
|
||||
|
||||
// ── 事件合帧:高频 delta 先缓冲,统一一帧进 reducer ─────────────────────
|
||||
const pendingEventsRef = useRef<CollaborationEvent[]>([]);
|
||||
const flushTimerRef = useRef<number | undefined>(undefined);
|
||||
const flushEvents = useCallback(() => {
|
||||
flushTimerRef.current = undefined;
|
||||
if (pendingEventsRef.current.length === 0) return;
|
||||
const batch = pendingEventsRef.current;
|
||||
pendingEventsRef.current = [];
|
||||
dispatch({ type: "events", events: batch });
|
||||
}, []);
|
||||
|
||||
const handleRawEvent = useCallback(
|
||||
(raw: unknown) => {
|
||||
const event = normalizeCollaborationEvent(raw);
|
||||
if (!event) return;
|
||||
pendingEventsRef.current.push(event);
|
||||
if (flushTimerRef.current === undefined) {
|
||||
flushTimerRef.current = window.setTimeout(flushEvents, EVENT_FLUSH_MS);
|
||||
}
|
||||
},
|
||||
[flushEvents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flushTimerRef.current !== undefined) window.clearTimeout(flushTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── 快照加载 ─────────────────────────────────────────────────────────────
|
||||
const [snapshotTick, setSnapshotTick] = useState(0);
|
||||
const loadSnapshot = useCallback(
|
||||
async (quiet: boolean) => {
|
||||
if (!quiet) setSnapshotLoading(true);
|
||||
try {
|
||||
const snapshot = await driver.getSnapshot(sessionId);
|
||||
if (!snapshot) {
|
||||
setSnapshotError("会话不存在或已删除");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "snapshot", snapshot });
|
||||
setSnapshotError(null);
|
||||
} catch (err) {
|
||||
setSnapshotError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!quiet) setSnapshotLoading(false);
|
||||
}
|
||||
},
|
||||
[driver, sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSnapshotLoading(true);
|
||||
void driver
|
||||
.getSnapshot(sessionId)
|
||||
.then((snapshot) => {
|
||||
if (cancelled) return;
|
||||
if (!snapshot) {
|
||||
setSnapshotError("会话不存在或已删除");
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "snapshot", snapshot });
|
||||
setSnapshotError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setSnapshotError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSnapshotLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [driver, sessionId, snapshotTick]);
|
||||
|
||||
// ── SSE:run 存在且未终态时连接 ────────────────────────────────────────
|
||||
const runTerminal =
|
||||
state.run?.status === "completed" ||
|
||||
state.run?.status === "failed" ||
|
||||
state.run?.status === "cancelled";
|
||||
const streamEnabled = Boolean(state.snapshotLoaded && runId && !runTerminal);
|
||||
useReportCollaborationStream({
|
||||
sessionId,
|
||||
runId,
|
||||
enabled: streamEnabled,
|
||||
afterSeqRef: lastSeqRef,
|
||||
onEvent: handleRawEvent,
|
||||
onStatusChange: setStreamStatus,
|
||||
});
|
||||
|
||||
// 终态:最后一次 REST 补拉,确保正式版本/收尾消息完整后再停流。
|
||||
const finalizeRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!runTerminal || !state.run) return;
|
||||
const activeRunId = state.run.id;
|
||||
if (finalizeRef.current === activeRunId) return;
|
||||
finalizeRef.current = activeRunId;
|
||||
void driver
|
||||
.listRunEvents(activeRunId, lastSeqRef.current)
|
||||
.then((events) => {
|
||||
if (events.length > 0) dispatch({ type: "events", events });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
setStreamStatus({ status: "closed", attempts: 0 });
|
||||
}, [driver, runTerminal, state.run]);
|
||||
|
||||
// 序号缺口:暂停 live 应用,REST 补拉后由 reducer drain 缓冲。
|
||||
const gap = hasSeqGap(state);
|
||||
const backfillingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!gap || !runId || backfillingRef.current) return;
|
||||
backfillingRef.current = true;
|
||||
void driver
|
||||
.listRunEvents(runId, lastSeqRef.current)
|
||||
.then((events) => {
|
||||
if (events.length > 0) dispatch({ type: "events", events });
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
backfillingRef.current = false;
|
||||
});
|
||||
}, [driver, gap, runId]);
|
||||
|
||||
// 无 run 的前置阶段(澄清/规划):后端事件只在 run 流上,轮询快照兜底。
|
||||
const preRunPolling =
|
||||
state.snapshotLoaded &&
|
||||
!state.run &&
|
||||
(state.session?.status === "clarifying" ||
|
||||
state.session?.status === "planning" ||
|
||||
state.session?.status === "proposal_ready");
|
||||
useEffect(() => {
|
||||
if (!preRunPolling) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadSnapshot(true);
|
||||
}, PRE_RUN_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadSnapshot, preRunPolling]);
|
||||
|
||||
// ── 动作 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, target?: RcInterventionTarget) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
setSendBusy(true);
|
||||
setActionError(null);
|
||||
const idempotencyKey = newIdempotencyKey();
|
||||
// 乐观用户消息:服务器镜像携带同 idempotency_key 时原地替换。
|
||||
dispatch({ type: "optimistic", message: optimisticUserMessage(trimmed, idempotencyKey) });
|
||||
try {
|
||||
// 命令只存在于 run 执行阶段(running / awaiting_input / reviewing);
|
||||
// 澄清、规划、完成后的问答走 session messages。
|
||||
const sessionStatus = state.session?.status;
|
||||
const commandPhase =
|
||||
sessionStatus === "running" ||
|
||||
sessionStatus === "awaiting_input" ||
|
||||
sessionStatus === "reviewing";
|
||||
const activeRun = commandPhase ? state.run : null;
|
||||
if (activeRun && isRunActive(activeRun.status)) {
|
||||
await driver.createCommand(
|
||||
activeRun.id,
|
||||
{
|
||||
text: trimmed,
|
||||
target_node_id: target?.nodeId ?? null,
|
||||
agent_run_id: target?.agentRunId ?? null,
|
||||
},
|
||||
idempotencyKey,
|
||||
);
|
||||
} else {
|
||||
await driver.sendMessage(
|
||||
sessionId,
|
||||
{
|
||||
text: trimmed,
|
||||
target_node_id: target?.nodeId ?? null,
|
||||
agent_run_id: target?.agentRunId ?? null,
|
||||
},
|
||||
idempotencyKey,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// 失败保留乐观消息与输入,提供重试入口(不回滚 UI)。
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSendBusy(false);
|
||||
}
|
||||
},
|
||||
[driver, sessionId, state.run],
|
||||
);
|
||||
|
||||
const requestPlans = useCallback(async () => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await driver.requestPlans(sessionId, state.session?.requirement_revision ?? null);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [driver, sessionId, state.session]);
|
||||
|
||||
const selectPlan = useCallback(
|
||||
async (planId: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await driver.selectPlan(sessionId, planId, state.session?.requirement_revision ?? null);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
// 409:方案 revision 过期,刷新快照让用户基于最新方案重选。
|
||||
setSnapshotTick((tick) => tick + 1);
|
||||
}
|
||||
},
|
||||
[driver, sessionId, state.session],
|
||||
);
|
||||
|
||||
const startRun = useCallback(
|
||||
async (planId: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await driver.startRun(
|
||||
sessionId,
|
||||
{ plan_id: planId },
|
||||
state.session?.requirement_revision ?? null,
|
||||
);
|
||||
// 立即刷新快照拿到 active_run_id,尽早接上 SSE。
|
||||
setSnapshotTick((tick) => tick + 1);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
setSnapshotTick((tick) => tick + 1);
|
||||
}
|
||||
},
|
||||
[driver, sessionId, state.session],
|
||||
);
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const activeRunId = state.run?.id;
|
||||
if (!activeRunId) return;
|
||||
setActionError(null);
|
||||
try {
|
||||
await driver.cancelRun(activeRunId);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [driver, state.run]);
|
||||
|
||||
const confirmCommand = useCallback(
|
||||
async (commandId: string) => {
|
||||
const activeRunId = state.run?.id;
|
||||
if (!activeRunId) return;
|
||||
setActionError(null);
|
||||
try {
|
||||
await driver.confirmCommand(activeRunId, commandId);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[driver, state.run],
|
||||
);
|
||||
|
||||
const cancelCommand = useCallback(
|
||||
async (commandId: string) => {
|
||||
const activeRunId = state.run?.id;
|
||||
if (!activeRunId) return;
|
||||
setActionError(null);
|
||||
try {
|
||||
await driver.cancelCommand(activeRunId, commandId);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[driver, state.run],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
streamStatus,
|
||||
snapshotLoading,
|
||||
snapshotError,
|
||||
actionError,
|
||||
clearActionError: () => setActionError(null),
|
||||
sendBusy,
|
||||
send,
|
||||
requestPlans,
|
||||
selectPlan,
|
||||
startRun,
|
||||
cancelRun,
|
||||
confirmCommand,
|
||||
cancelCommand,
|
||||
refreshSnapshot: () => setSnapshotTick((tick) => tick + 1),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Run SSE 连接管理:断线指数退避重连、Last-Event-ID/after_seq 续接。
|
||||
*
|
||||
* 连接生命周期由 [sessionId, runId, enabled] 驱动;lastSeq 通过 ref 传入,
|
||||
* 避免每次事件应用都重建连接。事件以回调上抛,由会话 hook 合帧后进 reducer。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
import type { CollaborationEvent } from "@/report-collaboration/api/types";
|
||||
|
||||
export type RcConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "live"
|
||||
| "reconnecting"
|
||||
| "closed"
|
||||
| "error";
|
||||
|
||||
export interface RcStreamStatus {
|
||||
status: RcConnectionStatus;
|
||||
attempts: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UseReportCollaborationStreamOptions {
|
||||
sessionId: string | null;
|
||||
runId: string | null;
|
||||
enabled: boolean;
|
||||
/** 最新已应用 seq 的 ref(重连时作为 after_seq)。 */
|
||||
afterSeqRef: React.MutableRefObject<number>;
|
||||
onEvent: (event: CollaborationEvent) => void;
|
||||
onStatusChange?: (status: RcStreamStatus) => void;
|
||||
}
|
||||
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
|
||||
export function useReportCollaborationStream({
|
||||
sessionId,
|
||||
runId,
|
||||
enabled,
|
||||
afterSeqRef,
|
||||
onEvent,
|
||||
onStatusChange,
|
||||
}: UseReportCollaborationStreamOptions): void {
|
||||
const onEventRef = useRef(onEvent);
|
||||
const onStatusRef = useRef(onStatusChange);
|
||||
onEventRef.current = onEvent;
|
||||
onStatusRef.current = onStatusChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !runId || !enabled) {
|
||||
onStatusRef.current?.({ status: "idle", attempts: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = getReportCollaborationDriver();
|
||||
let disposed = false;
|
||||
let closeStream: (() => void) | null = null;
|
||||
let attempts = 0;
|
||||
let reconnectTimer: number | undefined;
|
||||
|
||||
const setStatus = (status: RcConnectionStatus, message?: string) => {
|
||||
if (!disposed) {
|
||||
onStatusRef.current?.({ status, attempts, message });
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
setStatus(attempts === 0 ? "connecting" : "reconnecting");
|
||||
closeStream = driver.openRunStream(runId, afterSeqRef.current, {
|
||||
onEvent: (event) => {
|
||||
if (!disposed) onEventRef.current(event);
|
||||
},
|
||||
onOpen: () => {
|
||||
attempts = 0;
|
||||
setStatus("live");
|
||||
},
|
||||
onError: (message) => {
|
||||
if (disposed) return;
|
||||
setStatus("reconnecting", message);
|
||||
scheduleReconnect();
|
||||
},
|
||||
onClose: () => {
|
||||
// 服务端正常关流(终态)。终态判定与最终补拉由会话 hook 负责,
|
||||
// 这里标记 closed、不再自动重连,避免终态重连风暴。
|
||||
if (!disposed) setStatus("closed");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (disposed) return;
|
||||
attempts += 1;
|
||||
const delay = Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_MAX_MS);
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
closeStream?.();
|
||||
};
|
||||
}, [sessionId, runId, enabled, afterSeqRef]);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 两栏布局状态:左对话宽度(持久化到 localStorage,仅浏览器展示偏好)、
|
||||
* 窄屏单面板切换、「专注对话/专注画布」模式。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const WIDTH_STORAGE_KEY = "deerflow.rc-conversation-width";
|
||||
|
||||
export const PANEL_MIN_WIDTH = 360;
|
||||
export const PANEL_MAX_WIDTH = 720;
|
||||
export const PANEL_DEFAULT_WIDTH = 480;
|
||||
export const CANVAS_MIN_WIDTH = 480;
|
||||
/** 低于该宽度切换为单面板(顶部按钮切换对话/画布)。 */
|
||||
export const NARROW_BREAKPOINT = 960;
|
||||
export const RESIZE_KEYBOARD_STEP = 16;
|
||||
|
||||
export type NarrowView = "conversation" | "canvas";
|
||||
export type FocusMode = "both" | "conversation" | "canvas";
|
||||
|
||||
function loadStoredWidth(): number | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(WIDTH_STORAGE_KEY);
|
||||
const value = raw === null ? Number.NaN : Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseReportPanelLayoutResult {
|
||||
conversationWidth: number;
|
||||
setConversationWidth: (width: number) => void;
|
||||
nudgeConversationWidth: (delta: number) => void;
|
||||
clampConversationWidth: (width: number) => number;
|
||||
isNarrow: boolean;
|
||||
narrowView: NarrowView;
|
||||
setNarrowView: (view: NarrowView) => void;
|
||||
focusMode: FocusMode;
|
||||
setFocusMode: (mode: FocusMode) => void;
|
||||
/** 布局主体容器 ref(用于按容器实际宽度计算拖拽上限)。 */
|
||||
bodyRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useReportPanelLayout(): UseReportPanelLayoutResult {
|
||||
const [conversationWidth, setWidth] = useState<number>(() => {
|
||||
const stored = typeof window === "undefined" ? null : loadStoredWidth();
|
||||
return stored !== null
|
||||
? Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, stored))
|
||||
: PANEL_DEFAULT_WIDTH;
|
||||
});
|
||||
const [isNarrow, setIsNarrow] = useState(
|
||||
() => typeof window !== "undefined" && window.innerWidth < NARROW_BREAKPOINT,
|
||||
);
|
||||
const [narrowView, setNarrowView] = useState<NarrowView>("conversation");
|
||||
const [focusMode, setFocusMode] = useState<FocusMode>("both");
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 窄屏判定 + 宽度上限随容器收缩(画布保底 480px)。
|
||||
useEffect(() => {
|
||||
const evaluate = () => {
|
||||
setIsNarrow(window.innerWidth < NARROW_BREAKPOINT);
|
||||
const bodyWidth = bodyRef.current?.clientWidth ?? 0;
|
||||
if (bodyWidth > 0) {
|
||||
setWidth((current) => clampWidth(current, bodyWidth));
|
||||
}
|
||||
};
|
||||
evaluate();
|
||||
window.addEventListener("resize", evaluate);
|
||||
return () => window.removeEventListener("resize", evaluate);
|
||||
}, []);
|
||||
|
||||
const clampConversationWidth = useCallback((width: number) => {
|
||||
const bodyWidth = bodyRef.current?.clientWidth ?? 0;
|
||||
return clampWidth(width, bodyWidth);
|
||||
}, []);
|
||||
|
||||
const setConversationWidth = useCallback(
|
||||
(width: number) => {
|
||||
setWidth(clampConversationWidth(width));
|
||||
},
|
||||
[clampConversationWidth],
|
||||
);
|
||||
|
||||
const nudgeConversationWidth = useCallback(
|
||||
(delta: number) => {
|
||||
setWidth((current) => clampConversationWidth(current + delta));
|
||||
},
|
||||
[clampConversationWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(WIDTH_STORAGE_KEY, String(conversationWidth));
|
||||
} catch {
|
||||
// 忽略存储失败(隐私模式等)。
|
||||
}
|
||||
}, [conversationWidth]);
|
||||
|
||||
return {
|
||||
conversationWidth,
|
||||
setConversationWidth,
|
||||
nudgeConversationWidth,
|
||||
clampConversationWidth,
|
||||
isNarrow,
|
||||
narrowView,
|
||||
setNarrowView,
|
||||
focusMode,
|
||||
setFocusMode,
|
||||
bodyRef,
|
||||
};
|
||||
}
|
||||
|
||||
function clampWidth(width: number, bodyWidth: number): number {
|
||||
const dynamicMax =
|
||||
bodyWidth > 0 ? Math.min(PANEL_MAX_WIDTH, bodyWidth - CANVAS_MIN_WIDTH) : PANEL_MAX_WIDTH;
|
||||
const max = Math.max(PANEL_MIN_WIDTH, dynamicMax);
|
||||
return Math.round(Math.min(max, Math.max(PANEL_MIN_WIDTH, width)));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 方案选择展示状态:预览(纯前端)与已选定(服务器事实)分离。
|
||||
* 点击卡片只改 previewPlanId;「采用此方案」才触发 select API。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { ReportPlanCandidate } from "@/report-collaboration/api/types";
|
||||
|
||||
export interface UseReportPlanSelectionResult {
|
||||
previewPlanId: string | null;
|
||||
/** 点击卡片/查看流程:仅切换预览,不触发任何服务器变更。 */
|
||||
previewPlan: (planId: string) => void;
|
||||
/** 当前画布应渲染的方案(预览 > 已选定 > 最新提案)。 */
|
||||
effectivePlanId: string | null;
|
||||
}
|
||||
|
||||
export function useReportPlanSelection(
|
||||
plans: ReportPlanCandidate[],
|
||||
selectedPlanId: string | null,
|
||||
): UseReportPlanSelectionResult {
|
||||
const [previewPlanId, setPreviewPlanId] = useState<string | null>(null);
|
||||
|
||||
// 方案组更新(重新生成 / 新 revision)后清理失效预览。
|
||||
useEffect(() => {
|
||||
if (previewPlanId && plans.length > 0 && !plans.some((plan) => plan.id === previewPlanId)) {
|
||||
setPreviewPlanId(null);
|
||||
}
|
||||
}, [plans, previewPlanId]);
|
||||
|
||||
const previewPlan = useCallback((planId: string) => {
|
||||
setPreviewPlanId(planId);
|
||||
}, []);
|
||||
|
||||
const effectivePlanId =
|
||||
(previewPlanId && plans.some((plan) => plan.id === previewPlanId) ? previewPlanId : null) ??
|
||||
selectedPlanId ??
|
||||
plans[0]?.id ??
|
||||
null;
|
||||
|
||||
return { previewPlanId, previewPlan, effectivePlanId };
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { toast } from "sonner";
|
||||
import { FileText, Sparkles } from "lucide-react";
|
||||
|
||||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
import { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector";
|
||||
import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar";
|
||||
import { InterventionCard } from "@/report-collaboration/components/InterventionCard";
|
||||
import { NodeInspectorDrawer } from "@/report-collaboration/components/NodeInspectorDrawer";
|
||||
import { ReportCollaborationHeader } from "@/report-collaboration/components/ReportCollaborationHeader";
|
||||
import { ReportCollaborationLayout, NarrowSwitch } from "@/report-collaboration/components/ReportCollaborationLayout";
|
||||
import { ReportComposer, type ComposerTarget } from "@/report-collaboration/components/ReportComposer";
|
||||
import { ReportConversationPanel } from "@/report-collaboration/components/ReportConversationPanel";
|
||||
import { ReportFlowCanvas } from "@/report-collaboration/components/ReportFlowCanvas";
|
||||
import { ReportPlanCards } from "@/report-collaboration/components/ReportPlanCards";
|
||||
import {
|
||||
ReportAvailableCard,
|
||||
ReportPreviewDialog,
|
||||
} from "@/report-collaboration/components/ReportPreviewDialog";
|
||||
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
||||
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
||||
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
||||
import { useReportCollaborationSession, isRunActive } from "@/report-collaboration/hooks/useReportCollaborationSession";
|
||||
import { useReportPlanSelection } from "@/report-collaboration/hooks/useReportPlanSelection";
|
||||
import {
|
||||
buildCommandConfirmCard,
|
||||
buildPlanGroupCard,
|
||||
buildReportAvailableCard,
|
||||
readReportCollaborationCard,
|
||||
} from "@/report-collaboration/state/message-adapter";
|
||||
import {
|
||||
derivePagePhase,
|
||||
selectAgentTabs,
|
||||
selectAwaitingInputNodes,
|
||||
selectHeadReportVersion,
|
||||
selectNodeStatusMap,
|
||||
selectParallelMembers,
|
||||
selectPendingConfirmationCommands,
|
||||
selectPlanForCanvas,
|
||||
selectStreamingAgentRunIds,
|
||||
} from "@/report-collaboration/state/selectors";
|
||||
import { buildBaseStreamFacade } from "@/report-collaboration/state/message-adapter";
|
||||
|
||||
const NEW_SESSION_THREAD = buildBaseStreamFacade([], false);
|
||||
|
||||
/** 左侧 Tab:对话 / 历史。 */
|
||||
function ConversationSidebarTabs({
|
||||
conversation,
|
||||
history,
|
||||
}: {
|
||||
conversation: React.ReactNode;
|
||||
history: React.ReactNode;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"conversation" | "history">("conversation");
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="rc-tabs" role="tablist" aria-label="左侧面板">
|
||||
<button type="button" role="tab" className="rc-tab" data-active={tab === "conversation"} onClick={() => setTab("conversation")}>
|
||||
对话
|
||||
</button>
|
||||
<button type="button" role="tab" className="rc-tab" data-active={tab === "history"} onClick={() => setTab("history")}>
|
||||
历史
|
||||
</button>
|
||||
</div>
|
||||
{tab === "conversation" ? conversation : history}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新会话入口页(无 sessionId):空态 + 输入需求后创建会话并跳转,
|
||||
* 首条消息通过 location.state 传递,由会话页发送(避免竞态)。
|
||||
*/
|
||||
export default function ReportCollaborationPage() {
|
||||
const navigate = useNavigate();
|
||||
const layout = useReportPanelLayout();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const start = useCallback(
|
||||
async (text: string) => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await getReportCollaborationDriver().createSession();
|
||||
navigate(`/page/workspace/report-collaboration/${created.id}`, {
|
||||
state: { rcInitialMessage: text },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReportCollaborationLayout
|
||||
layout={layout}
|
||||
header={
|
||||
<ReportCollaborationHeader
|
||||
title="新会话"
|
||||
phase="empty"
|
||||
onBack={() => navigate("/page/workspace/chats/new")}
|
||||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||||
onOpenReport={() => undefined}
|
||||
hasReport={false}
|
||||
running={false}
|
||||
onCancelRun={() => undefined}
|
||||
/>
|
||||
}
|
||||
conversation={
|
||||
<ReportConversationPanel
|
||||
sessionId="rc-new"
|
||||
thread={NEW_SESSION_THREAD}
|
||||
businessCardSlot={() => null}
|
||||
loading={false}
|
||||
emptyHint={
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Sparkles className="size-10 opacity-40" style={{ color: "var(--rc-accent)" }} aria-hidden />
|
||||
<p className="text-[14px] font-semibold">描述你的报告需求</p>
|
||||
<p className="max-w-72 text-[12px] leading-5">
|
||||
系统会先澄清关键信息,然后给出 2~3 个候选协作方案;
|
||||
确认方案后多智能体团队开始检索、核验、分析与写作,全过程可在对话中干预。
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="text-[12px]" style={{ color: "var(--rc-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
composer={
|
||||
<ReportComposer
|
||||
phase="empty"
|
||||
busy={starting}
|
||||
target={null}
|
||||
onClearTarget={() => undefined}
|
||||
onSend={(text) => void start(text)}
|
||||
focusSignal={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
|
||||
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话页:左对话(复用 MessageList)/ 右画布 / 节点抽屉 / 报告预览。 */
|
||||
export function ReportCollaborationSessionPage({ sessionId }: { sessionId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const session = useReportCollaborationSession(sessionId);
|
||||
const { state } = session;
|
||||
const layout = useReportPanelLayout();
|
||||
|
||||
const phase = useMemo(() => derivePagePhase(state), [state]);
|
||||
const agentTabs = useMemo(() => selectAgentTabs(state), [state]);
|
||||
const [focusedAgentRunId, setFocusedAgentRunId] = useState<string | null>(null);
|
||||
const [focusPinned, setFocusPinned] = useState(false);
|
||||
const effectiveFocus = agentTabs.length > 0 ? (focusedAgentRunId ?? agentTabs[0].agent_run_id) : null;
|
||||
|
||||
const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection(
|
||||
state.plans,
|
||||
state.selectedPlanId,
|
||||
);
|
||||
const canvasPlan = useMemo(
|
||||
() => selectPlanForCanvas(state, previewPlanId),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[state.plans, state.planMap, state.selectedPlanId, previewPlanId],
|
||||
);
|
||||
void effectivePlanId;
|
||||
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [composerTarget, setComposerTarget] = useState<ComposerTarget | null>(null);
|
||||
const [focusSignal, setFocusSignal] = useState(0);
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewVersionId, setPreviewVersionId] = useState<string | null>(null);
|
||||
const [restoreBusy, setRestoreBusy] = useState(false);
|
||||
|
||||
// 新会话首条消息(location.state.rcInitialMessage),快照就绪且时间线为空时发送一次。
|
||||
const initialSentRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialSentRef.current || !state.snapshotLoaded) return;
|
||||
initialSentRef.current = true;
|
||||
const initial = (location.state as { rcInitialMessage?: unknown } | null)?.rcInitialMessage;
|
||||
if (typeof initial === "string" && initial.trim() && state.messageOrder.length === 0) {
|
||||
void session.send(initial);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.snapshotLoaded]);
|
||||
|
||||
// 动作错误 → toast。
|
||||
useEffect(() => {
|
||||
if (session.actionError) {
|
||||
toast.error(session.actionError, { description: "操作未生效,可重试或刷新快照" });
|
||||
session.clearActionError();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.actionError]);
|
||||
|
||||
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||||
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
|
||||
const runStarted = Boolean(state.run && state.run.plan_id);
|
||||
const extraCards = useMemo<Message[]>(() => {
|
||||
const cards: Message[] = [];
|
||||
if (
|
||||
state.plans.length > 0 &&
|
||||
!runStarted &&
|
||||
(phase === "proposal_ready" || phase === "planning")
|
||||
) {
|
||||
cards.push(
|
||||
buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1),
|
||||
);
|
||||
}
|
||||
for (const command of selectPendingConfirmationCommands(state)) {
|
||||
cards.push(buildCommandConfirmCard(command));
|
||||
}
|
||||
const head = selectHeadReportVersion(state);
|
||||
if (head && (phase === "completed" || phase === "reviewing" || phase === "cancelled")) {
|
||||
cards.push(buildReportAvailableCard(head, state.reportVersions.length));
|
||||
}
|
||||
return cards;
|
||||
}, [phase, runStarted, state]);
|
||||
|
||||
const { displayThread } = useReportCollaborationMessages(
|
||||
state,
|
||||
effectiveFocus,
|
||||
extraCards,
|
||||
session.sendBusy,
|
||||
);
|
||||
|
||||
const streamingIds = useMemo(() => selectStreamingAgentRunIds(state), [state]);
|
||||
const awaitingIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const node of selectAwaitingInputNodes(state)) {
|
||||
for (const agentRun of Object.values(state.agentRuns)) {
|
||||
if (agentRun.node_run_id?.startsWith(`${node.node_id}-`)) {
|
||||
ids.add(agentRun.agent_run_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [state]);
|
||||
|
||||
const findMemberOfNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
const members = selectParallelMembers(state);
|
||||
return (
|
||||
members.find((member) => member.node_run_id?.startsWith(`${nodeId}-`)) ??
|
||||
Object.values(state.agentRuns).find(
|
||||
(agentRun) =>
|
||||
agentRun.role !== "协调者" && agentRun.node_run_id?.startsWith(`${nodeId}-`),
|
||||
) ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
const handleSelectNode = useCallback(
|
||||
(nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
setDrawerOpen(Boolean(nodeId));
|
||||
// 打开并行成员节点时同步聚焦对应标签(用户发送前先看到该成员的真实对话)。
|
||||
if (nodeId) {
|
||||
const member = findMemberOfNode(nodeId);
|
||||
if (member) {
|
||||
setFocusedAgentRunId(member.agent_run_id);
|
||||
setFocusPinned(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[findMemberOfNode],
|
||||
);
|
||||
|
||||
const handleNodeComment = useCallback(
|
||||
(nodeId: string, nodeName: string) => {
|
||||
const member = findMemberOfNode(nodeId);
|
||||
setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null });
|
||||
if (member) {
|
||||
setFocusedAgentRunId(member.agent_run_id);
|
||||
setFocusPinned(true);
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setFocusSignal((signal) => signal + 1);
|
||||
},
|
||||
[findMemberOfNode],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (versionId: string) => {
|
||||
setRestoreBusy(true);
|
||||
try {
|
||||
await getReportCollaborationDriver().restoreReportVersion(sessionId, versionId);
|
||||
session.refreshSnapshot();
|
||||
toast.success("已恢复为新的头版本");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRestoreBusy(false);
|
||||
}
|
||||
},
|
||||
[sessionId, session],
|
||||
);
|
||||
|
||||
const businessCardSlot = useCallback(
|
||||
(message: Message) => {
|
||||
const card = readReportCollaborationCard(message);
|
||||
if (!card) return null;
|
||||
if (card.kind === "plan_group") {
|
||||
return (
|
||||
<ReportPlanCards
|
||||
plans={card.plans}
|
||||
selectedPlanId={card.selectedPlanId}
|
||||
previewPlanId={previewPlanId}
|
||||
onPreview={previewPlan}
|
||||
onSelect={(planId) => void session.selectPlan(planId)}
|
||||
onStart={(planId) => void session.startRun(planId)}
|
||||
onRegenerate={() => void session.requestPlans()}
|
||||
selectBusy={session.sendBusy}
|
||||
startBusy={session.sendBusy}
|
||||
regenerateBusy={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (card.kind === "command_confirm") {
|
||||
return (
|
||||
<InterventionCard
|
||||
command={card.command}
|
||||
busy={session.sendBusy}
|
||||
onConfirm={(commandId) => void session.confirmCommand(commandId)}
|
||||
onCancel={(commandId) => void session.cancelCommand(commandId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (card.kind === "report_available") {
|
||||
return (
|
||||
<ReportAvailableCard
|
||||
version={card.version}
|
||||
versionCount={card.versionCount}
|
||||
onOpen={(versionId) => {
|
||||
setPreviewVersionId(versionId);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[previewPlan, previewPlanId, session],
|
||||
);
|
||||
|
||||
const runActive = isRunActive(state.run?.status);
|
||||
const headVersion = selectHeadReportVersion(state);
|
||||
const nodeStates = selectNodeStatusMap(state);
|
||||
const drawerFallback = selectedNodeId
|
||||
? {
|
||||
label: state.nodes[selectedNodeId]?.label ?? canvasPlan?.nodes.find((n) => n.id === selectedNodeId)?.label ?? selectedNodeId,
|
||||
status: state.nodes[selectedNodeId]?.status ?? "planned",
|
||||
}
|
||||
: null;
|
||||
|
||||
if (session.snapshotError && !state.snapshotLoaded) {
|
||||
return (
|
||||
<ReportCollaborationLayout
|
||||
layout={layout}
|
||||
header={
|
||||
<ReportCollaborationHeader
|
||||
title="会话不可用"
|
||||
phase="empty"
|
||||
onBack={() => navigate("/page/workspace/report-collaboration")}
|
||||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||||
onOpenReport={() => undefined}
|
||||
hasReport={false}
|
||||
running={false}
|
||||
onCancelRun={() => undefined}
|
||||
/>
|
||||
}
|
||||
conversation={
|
||||
<div className="rc-empty">
|
||||
<FileText className="size-8 opacity-30" aria-hidden />
|
||||
<p>{session.snapshotError}</p>
|
||||
<button type="button" className="rc-btn" onClick={() => navigate("/page/workspace/report-collaboration")}>
|
||||
返回新建会话
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
|
||||
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportCollaborationLayout
|
||||
layout={layout}
|
||||
header={
|
||||
<ReportCollaborationHeader
|
||||
title={state.session?.title ?? "报告协作"}
|
||||
phase={phase}
|
||||
onBack={() => navigate("/page/workspace/chats/new")}
|
||||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||||
onOpenReport={() => {
|
||||
if (headVersion) {
|
||||
setPreviewVersionId(headVersion.id);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
}}
|
||||
hasReport={Boolean(headVersion)}
|
||||
running={runActive}
|
||||
onCancelRun={() => void session.cancelRun()}
|
||||
extra={
|
||||
layout.isNarrow ? (
|
||||
<NarrowSwitch narrowView={layout.narrowView} setNarrowView={layout.setNarrowView} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
conversation={
|
||||
<ConversationSidebarTabs
|
||||
conversation={
|
||||
<ReportConversationPanel
|
||||
sessionId={sessionId}
|
||||
thread={displayThread}
|
||||
businessCardSlot={businessCardSlot}
|
||||
loading={session.snapshotLoading}
|
||||
stageSelector={
|
||||
<AgentStageSelector
|
||||
tabs={agentTabs}
|
||||
focusedAgentRunId={effectiveFocus}
|
||||
onFocus={(agentRunId) => {
|
||||
setFocusedAgentRunId(agentRunId);
|
||||
setFocusPinned(true);
|
||||
}}
|
||||
streamingAgentRunIds={streamingIds}
|
||||
awaitingAgentRunIds={awaitingIds}
|
||||
/>
|
||||
}
|
||||
composer={
|
||||
<ReportComposer
|
||||
phase={phase}
|
||||
busy={session.sendBusy}
|
||||
target={composerTarget}
|
||||
onClearTarget={() => setComposerTarget(null)}
|
||||
onSend={(text) =>
|
||||
void session.send(text, {
|
||||
nodeId: composerTarget?.nodeId ?? null,
|
||||
agentRunId: composerTarget?.agentRunId ?? null,
|
||||
})
|
||||
}
|
||||
focusSignal={focusSignal}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
history={<SessionHistoryPanel activeSessionId={sessionId} />}
|
||||
/>
|
||||
}
|
||||
canvas={
|
||||
<>
|
||||
<ReportFlowCanvas
|
||||
plan={canvasPlan}
|
||||
nodeStates={nodeStates}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelectNode={handleSelectNode}
|
||||
/>
|
||||
<NodeInspectorDrawer
|
||||
open={drawerOpen}
|
||||
nodeId={selectedNodeId}
|
||||
runId={state.run?.id ?? state.session?.active_run_id ?? null}
|
||||
fallback={drawerFallback}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onComment={handleNodeComment}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
statusBar={
|
||||
<ConnectionStatusBar
|
||||
status={session.streamStatus.status}
|
||||
runId={state.run?.id ?? null}
|
||||
lastSeq={state.lastSeq}
|
||||
message={session.streamStatus.message}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ReportPreviewDialog
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
versions={state.reportVersions}
|
||||
activeVersionId={previewVersionId}
|
||||
onSelectVersion={setPreviewVersionId}
|
||||
onRestore={(versionId) => void handleRestore(versionId)}
|
||||
restoreBusy={restoreBusy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* SSE / REST 事件包的防御性归一化: malformed 帧静默丢弃,不进入 reducer。
|
||||
*/
|
||||
|
||||
import type { CollaborationEvent, CollaborationEventType } from "@/report-collaboration/api/types";
|
||||
|
||||
const KNOWN_EVENT_TYPES = new Set<string>([
|
||||
"session.snapshot",
|
||||
"message.created",
|
||||
"message.delta",
|
||||
"message.completed",
|
||||
"tool_call.created",
|
||||
"tool_call.delta",
|
||||
"tool_call.completed",
|
||||
"tool_result.created",
|
||||
"tool_result.delta",
|
||||
"tool_result.completed",
|
||||
"team_message.created",
|
||||
"team_message.delta",
|
||||
"team_message.completed",
|
||||
"requirement.updated",
|
||||
"clarification.requested",
|
||||
"clarification.resolved",
|
||||
"plan.progress",
|
||||
"plan.proposed",
|
||||
"plan.selected",
|
||||
"run.status.changed",
|
||||
"node.created",
|
||||
"node.status.changed",
|
||||
"node.progress",
|
||||
"node.attempt.created",
|
||||
"artifact.created",
|
||||
"artifact.validated",
|
||||
"artifact.rejected",
|
||||
"source.added",
|
||||
"command.classified",
|
||||
"command.confirmation.required",
|
||||
"command.accepted",
|
||||
"command.completed",
|
||||
"command.failed",
|
||||
"report.delta",
|
||||
"report.version.created",
|
||||
"review.created",
|
||||
"error",
|
||||
"heartbeat",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并归一化一个事件包。返回 null 表示丢弃(结构非法)。
|
||||
* 未知 `type` 保留透传(前向兼容:新事件类型不应导致旧前端崩溃),
|
||||
* 但 seq/sessionId 必须合法。
|
||||
*/
|
||||
export function normalizeCollaborationEvent(raw: unknown): CollaborationEvent | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const eventId = raw.eventId ?? raw.event_id;
|
||||
const sessionId = raw.sessionId ?? raw.session_id;
|
||||
const runId = raw.runId ?? raw.run_id;
|
||||
const seq = raw.seq;
|
||||
const type = raw.type;
|
||||
const timestamp = raw.timestamp;
|
||||
if (typeof eventId !== "string" || eventId === "") return null;
|
||||
if (typeof sessionId !== "string" || sessionId === "") return null;
|
||||
if (typeof seq !== "number" || !Number.isFinite(seq) || seq < 0) return null;
|
||||
if (typeof type !== "string" || type === "") return null;
|
||||
|
||||
return {
|
||||
eventId,
|
||||
sessionId,
|
||||
runId: typeof runId === "string" ? runId : null,
|
||||
seq,
|
||||
type: (KNOWN_EVENT_TYPES.has(type) ? type : type) as CollaborationEventType,
|
||||
timestamp: typeof timestamp === "string" ? timestamp : new Date().toISOString(),
|
||||
data: raw.data ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 是否为已知业务事件类型(用于状态栏与测试)。 */
|
||||
export function isKnownEventType(type: string): boolean {
|
||||
return KNOWN_EVENT_TYPES.has(type);
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
/**
|
||||
* Durable 事件 Reducer:把 SSE / 快照事件连续应用成前端投影。
|
||||
*
|
||||
* 不变量:
|
||||
* - 以 `seq` 单调去重;重复事件、乱序事件(先缓冲后 drain)不产生重复消息或状态倒退。
|
||||
* - 消息行以 `message_id`、工具结果以 `tool_call_id` 定位,禁止数组下标。
|
||||
* - 并行成员事件按 `agentRunId + messageId` 更新,与当前聚焦标签无关。
|
||||
* - 节点状态只走 §5.5 状态机的合法迁移;晚到/非法事件被忽略。
|
||||
* - 协助卡出现/提交不改变节点状态(awaiting_input → queued 只由
|
||||
* node.status.changed 驱动)。
|
||||
* - 页面切换后旧连接晚到事件按 sessionId/runId 校验丢弃。
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentRun,
|
||||
ArtifactCreatedPayload,
|
||||
ArtifactRejectedPayload,
|
||||
ArtifactValidatedPayload,
|
||||
ClarificationRecord,
|
||||
ClarificationRequestedPayload,
|
||||
ClarificationResolvedPayload,
|
||||
CollaborationArtifact,
|
||||
CollaborationCommand,
|
||||
CollaborationEvent,
|
||||
CollaborationMessage,
|
||||
CollaborationNodeState,
|
||||
CollaborationNodeStatus,
|
||||
CommandAcceptedPayload,
|
||||
CommandClassifiedPayload,
|
||||
CommandCompletedPayload,
|
||||
CommandConfirmationRequiredPayload,
|
||||
CommandFailedPayload,
|
||||
ErrorEventPayload,
|
||||
MessageCompletedPayload,
|
||||
MessageEventPayload,
|
||||
MessageDeltaPayload,
|
||||
NodeAwaitingInput,
|
||||
NodeAttemptCreatedPayload,
|
||||
NodeCreatedPayload,
|
||||
NodeProgressPayload,
|
||||
NodeStatusChangedPayload,
|
||||
PlanProgressPayload,
|
||||
PlanProposedPayload,
|
||||
PlanSelectedPayload,
|
||||
ReportRequirementSnapshot,
|
||||
ReportPlanCandidate,
|
||||
ReportRun,
|
||||
ReportRunStatus,
|
||||
ReportSession,
|
||||
ReportSessionStatus,
|
||||
ReportSource,
|
||||
ReportVersion,
|
||||
RequirementUpdatedPayload,
|
||||
ReviewCreatedPayload,
|
||||
RunStatusChangedPayload,
|
||||
SessionSnapshot,
|
||||
SourceAddedPayload,
|
||||
ToolCallCreatedPayload,
|
||||
ToolCallCompletedPayload,
|
||||
ToolCallDeltaPayload,
|
||||
ToolResultCompletedPayload,
|
||||
ToolResultCreatedPayload,
|
||||
ToolResultDeltaPayload,
|
||||
ReportVersionCreatedPayload,
|
||||
ReportDeltaPayload,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
// ── 状态 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CollaborationPlanProgress {
|
||||
stage: string;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface CollaborationLastError {
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
nodeId?: string | null;
|
||||
}
|
||||
|
||||
export interface CollaborationState {
|
||||
sessionId: string;
|
||||
session: ReportSession | null;
|
||||
snapshotLoaded: boolean;
|
||||
requirement: ReportRequirementSnapshot | null;
|
||||
|
||||
messagesById: Record<string, CollaborationMessage>;
|
||||
/** 到达顺序(= 时间线顺序);并行成员交错到达时按事件先后。 */
|
||||
messageOrder: string[];
|
||||
|
||||
plans: ReportPlanCandidate[];
|
||||
planMap: Record<string, ReportPlanCandidate>;
|
||||
proposalGroupId: string | null;
|
||||
selectedPlanId: string | null;
|
||||
planProgress: CollaborationPlanProgress | null;
|
||||
|
||||
run: ReportRun | null;
|
||||
nodes: Record<string, CollaborationNodeState>;
|
||||
agentRuns: Record<string, AgentRun>;
|
||||
artifacts: CollaborationArtifact[];
|
||||
sources: Record<string, ReportSource>;
|
||||
|
||||
commands: Record<string, CollaborationCommand>;
|
||||
commandOrder: string[];
|
||||
|
||||
reportVersions: ReportVersion[];
|
||||
reportDraft: { draftId: string; markdown: string } | null;
|
||||
lastReview: ReviewCreatedPayload["review"] | null;
|
||||
clarifications: Record<string, ClarificationRecord>;
|
||||
|
||||
/** 已连续应用到的最大 seq。 */
|
||||
lastSeq: number;
|
||||
/** 乱序缓冲:seq -> event,等待缺口补齐后 drain。 */
|
||||
pendingBySeq: Record<number, CollaborationEvent>;
|
||||
|
||||
lastError: CollaborationLastError | null;
|
||||
}
|
||||
|
||||
export function createInitialCollaborationState(sessionId: string): CollaborationState {
|
||||
return {
|
||||
sessionId,
|
||||
session: null,
|
||||
snapshotLoaded: false,
|
||||
requirement: null,
|
||||
messagesById: {},
|
||||
messageOrder: [],
|
||||
plans: [],
|
||||
planMap: {},
|
||||
proposalGroupId: null,
|
||||
selectedPlanId: null,
|
||||
planProgress: null,
|
||||
run: null,
|
||||
nodes: {},
|
||||
agentRuns: {},
|
||||
artifacts: [],
|
||||
sources: {},
|
||||
commands: {},
|
||||
commandOrder: [],
|
||||
reportVersions: [],
|
||||
reportDraft: null,
|
||||
lastReview: null,
|
||||
clarifications: {},
|
||||
lastSeq: 0,
|
||||
pendingBySeq: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 节点状态机(后端 §5.5)──────────────────────────────────────────────────
|
||||
|
||||
const NODE_TRANSITIONS: Record<CollaborationNodeStatus, CollaborationNodeStatus[]> = {
|
||||
planned: ["queued", "running", "superseded", "cancelled"],
|
||||
queued: ["running", "cancel_requested", "cancelled", "superseded", "interrupted"],
|
||||
running: [
|
||||
"validating",
|
||||
"awaiting_input",
|
||||
"cancel_requested",
|
||||
"failed",
|
||||
"superseded",
|
||||
"interrupted",
|
||||
],
|
||||
validating: ["completed", "failed", "superseded"],
|
||||
awaiting_input: ["queued", "cancel_requested", "cancelled", "superseded"],
|
||||
cancel_requested: ["cancelled"],
|
||||
cancelled: [],
|
||||
completed: ["reopened", "superseded"],
|
||||
reopened: ["queued", "superseded"],
|
||||
failed: ["queued", "reopened", "superseded"],
|
||||
interrupted: ["queued", "reopened", "superseded"],
|
||||
superseded: [],
|
||||
};
|
||||
|
||||
/** planned 视为「任何节点都可能尚未注册」的兜底起点。 */
|
||||
export function canTransitionNode(
|
||||
from: CollaborationNodeStatus | undefined,
|
||||
to: CollaborationNodeStatus,
|
||||
): boolean {
|
||||
if (!from || from === "planned") return true;
|
||||
if (from === to) return false;
|
||||
return NODE_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
function sessionStatusFromRunStatus(status: ReportRunStatus): ReportSessionStatus {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
case "planning":
|
||||
return "planning";
|
||||
case "running":
|
||||
case "recovering":
|
||||
return "running";
|
||||
case "awaiting_input":
|
||||
return "awaiting_input";
|
||||
case "reviewing":
|
||||
return "reviewing";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 快照 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function applySessionSnapshot(
|
||||
state: CollaborationState,
|
||||
snapshot: SessionSnapshot,
|
||||
): CollaborationState {
|
||||
const messagesById: Record<string, CollaborationMessage> = {};
|
||||
const messageOrder: string[] = [];
|
||||
for (const message of snapshot.messages ?? []) {
|
||||
if (messagesById[message.message_id]) continue;
|
||||
messagesById[message.message_id] = message;
|
||||
messageOrder.push(message.message_id);
|
||||
}
|
||||
const nodes: Record<string, CollaborationNodeState> = {};
|
||||
for (const node of snapshot.nodes ?? []) {
|
||||
nodes[node.node_id] = node;
|
||||
}
|
||||
const agentRuns: Record<string, AgentRun> = {};
|
||||
for (const agentRun of snapshot.agent_runs ?? []) {
|
||||
agentRuns[agentRun.agent_run_id] = agentRun;
|
||||
}
|
||||
const commands: Record<string, CollaborationCommand> = {};
|
||||
const commandOrder: string[] = [];
|
||||
for (const command of snapshot.commands ?? []) {
|
||||
commands[command.command_id] = command;
|
||||
commandOrder.push(command.command_id);
|
||||
}
|
||||
const sources: Record<string, ReportSource> = {};
|
||||
for (const version of snapshot.report_versions ?? []) {
|
||||
for (const source of version.source_index ?? []) {
|
||||
sources[source.id] = source;
|
||||
}
|
||||
}
|
||||
const planMap: Record<string, ReportPlanCandidate> = {};
|
||||
for (const plan of snapshot.plans ?? []) {
|
||||
planMap[plan.id] = plan;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
sessionId: snapshot.session.id,
|
||||
session: snapshot.session,
|
||||
snapshotLoaded: true,
|
||||
messagesById,
|
||||
messageOrder,
|
||||
plans: snapshot.plans ?? [],
|
||||
planMap,
|
||||
proposalGroupId: snapshot.plans?.[0]?.proposal_group_id ?? null,
|
||||
selectedPlanId: snapshot.session.selected_plan_id,
|
||||
run: snapshot.run,
|
||||
nodes,
|
||||
agentRuns,
|
||||
artifacts: [],
|
||||
sources,
|
||||
commands,
|
||||
commandOrder,
|
||||
reportVersions: snapshot.report_versions ?? [],
|
||||
lastSeq: snapshot.last_seq ?? 0,
|
||||
pendingBySeq: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 事件应用 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function eventBelongsToState(state: CollaborationState, event: CollaborationEvent): boolean {
|
||||
if (event.sessionId !== state.sessionId) return false;
|
||||
// 旧 run 的晚到事件不混入当前 run 投影。
|
||||
if (event.runId && state.run?.id && event.runId !== state.run.id) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量应用事件(SSE 合帧后调用)。返回新 state;事件重复/乱序安全。
|
||||
*/
|
||||
export function applyCollaborationEvents(
|
||||
state: CollaborationState,
|
||||
events: CollaborationEvent[],
|
||||
): CollaborationState {
|
||||
let next = state;
|
||||
for (const event of events) {
|
||||
if (!eventBelongsToState(next, event)) continue;
|
||||
if (event.seq <= next.lastSeq) continue; // 重复(含重连重放)
|
||||
if (next.pendingBySeq[event.seq]) continue; // 已缓冲
|
||||
if (event.seq === next.lastSeq + 1) {
|
||||
next = applyOne(next, event);
|
||||
next = { ...next, lastSeq: event.seq };
|
||||
next = drainPending(next);
|
||||
} else {
|
||||
next = { ...next, pendingBySeq: { ...next.pendingBySeq, [event.seq]: event } };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function drainPending(state: CollaborationState): CollaborationState {
|
||||
let next = state;
|
||||
while (next.pendingBySeq[next.lastSeq + 1]) {
|
||||
const event = next.pendingBySeq[next.lastSeq + 1];
|
||||
const pendingBySeq = { ...next.pendingBySeq };
|
||||
delete pendingBySeq[next.lastSeq + 1];
|
||||
next = applyOne({ ...next, pendingBySeq }, event);
|
||||
next = { ...next, lastSeq: event.seq };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 序号缺口:需要 REST 补拉(after_seq = lastSeq)。 */
|
||||
export function hasSeqGap(state: CollaborationState): boolean {
|
||||
if (state.snapshotLoaded === false) return false;
|
||||
const keys = Object.keys(state.pendingBySeq).map(Number);
|
||||
if (keys.length === 0) return false;
|
||||
return !keys.includes(state.lastSeq + 1) || keys.some((k) => k > state.lastSeq + 1);
|
||||
}
|
||||
|
||||
function applyOne(state: CollaborationState, event: CollaborationEvent): CollaborationState {
|
||||
switch (event.type) {
|
||||
case "session.snapshot":
|
||||
return applyOneSnapshotEvent(state, event.data as Partial<SessionSnapshot>);
|
||||
case "message.created":
|
||||
case "team_message.created":
|
||||
return upsertMessage(state, (event.data as MessageEventPayload).message);
|
||||
case "message.delta":
|
||||
case "team_message.delta":
|
||||
return appendMessageDelta(state, event.data as MessageDeltaPayload);
|
||||
case "message.completed":
|
||||
case "team_message.completed":
|
||||
return completeMessage(state, event.data as MessageCompletedPayload);
|
||||
case "tool_call.created":
|
||||
return applyToolCallCreated(state, event.data as ToolCallCreatedPayload);
|
||||
case "tool_call.delta":
|
||||
return applyToolCallDelta(state, event.data as ToolCallDeltaPayload);
|
||||
case "tool_call.completed":
|
||||
return applyToolCallCompleted(state, event.data as ToolCallCompletedPayload);
|
||||
case "tool_result.created":
|
||||
return applyToolResultCreated(state, event.data as ToolResultCreatedPayload);
|
||||
case "tool_result.delta":
|
||||
return applyToolResultDelta(state, event.data as ToolResultDeltaPayload);
|
||||
case "tool_result.completed":
|
||||
return applyToolResultCompleted(state, event.data as ToolResultCompletedPayload);
|
||||
case "requirement.updated":
|
||||
return {
|
||||
...state,
|
||||
requirement: (event.data as RequirementUpdatedPayload).requirement,
|
||||
};
|
||||
case "clarification.requested": {
|
||||
const payload = event.data as ClarificationRequestedPayload;
|
||||
const clarifications = {
|
||||
...state.clarifications,
|
||||
[payload.clarification.id]: payload.clarification,
|
||||
};
|
||||
const withClarification = { ...state, clarifications };
|
||||
return payload.message
|
||||
? upsertMessage(withClarification, payload.message)
|
||||
: withClarification;
|
||||
}
|
||||
case "clarification.resolved": {
|
||||
const payload = event.data as ClarificationResolvedPayload;
|
||||
const existing = state.clarifications[payload.clarification_id];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
clarifications: {
|
||||
...state.clarifications,
|
||||
[payload.clarification_id]: { ...existing, resolved: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
case "plan.progress": {
|
||||
const payload = event.data as PlanProgressPayload;
|
||||
return { ...state, planProgress: { stage: payload.stage, detail: payload.detail ?? null } };
|
||||
}
|
||||
case "plan.proposed":
|
||||
return applyPlansProposed(state, event.data as PlanProposedPayload);
|
||||
case "plan.selected": {
|
||||
const payload = event.data as PlanSelectedPayload;
|
||||
return syncSelectedPlan(state, payload.plan_id);
|
||||
}
|
||||
case "run.status.changed":
|
||||
return applyRunStatusChanged(state, event.data as RunStatusChangedPayload, event);
|
||||
case "node.created": {
|
||||
const payload = event.data as NodeCreatedPayload;
|
||||
return { ...state, nodes: { ...state.nodes, [payload.node.node_id]: payload.node } };
|
||||
}
|
||||
case "node.status.changed":
|
||||
return applyNodeStatusChanged(state, event.data as NodeStatusChangedPayload);
|
||||
case "node.progress": {
|
||||
const payload = event.data as NodeProgressPayload;
|
||||
const node = state.nodes[payload.node_id];
|
||||
if (!node) return state;
|
||||
return {
|
||||
...state,
|
||||
nodes: { ...state.nodes, [payload.node_id]: { ...node, progress: payload.progress } },
|
||||
};
|
||||
}
|
||||
case "node.attempt.created":
|
||||
return applyNodeAttemptCreated(state, event.data as NodeAttemptCreatedPayload);
|
||||
case "artifact.created": {
|
||||
const payload = event.data as ArtifactCreatedPayload;
|
||||
return { ...state, artifacts: [...state.artifacts, payload.artifact] };
|
||||
}
|
||||
case "artifact.validated":
|
||||
case "artifact.rejected": {
|
||||
const payload = event.data as ArtifactValidatedPayload | ArtifactRejectedPayload;
|
||||
const artifacts = state.artifacts.map((artifact) =>
|
||||
artifact.id === payload.artifact_id
|
||||
? {
|
||||
...artifact,
|
||||
validation_status:
|
||||
event.type === "artifact.validated"
|
||||
? ("validated" as const)
|
||||
: ("rejected" as const),
|
||||
}
|
||||
: artifact,
|
||||
);
|
||||
return { ...state, artifacts };
|
||||
}
|
||||
case "source.added": {
|
||||
const payload = event.data as SourceAddedPayload;
|
||||
return { ...state, sources: { ...state.sources, [payload.source.id]: payload.source } };
|
||||
}
|
||||
case "command.classified":
|
||||
case "command.confirmation.required": {
|
||||
const payload = event.data as CommandClassifiedPayload | CommandConfirmationRequiredPayload;
|
||||
return upsertCommand(state, payload.command);
|
||||
}
|
||||
case "command.accepted": {
|
||||
const payload = event.data as CommandAcceptedPayload;
|
||||
const command = state.commands[payload.command_id];
|
||||
if (!command) return state;
|
||||
return upsertCommand(state, {
|
||||
...command,
|
||||
status: payload.waiting_boundary
|
||||
? "accepted_waiting_boundary"
|
||||
: "accepted",
|
||||
impact_nodes: payload.impact_nodes ?? command.impact_nodes,
|
||||
});
|
||||
}
|
||||
case "command.completed": {
|
||||
const payload = event.data as CommandCompletedPayload;
|
||||
const command = state.commands[payload.command_id];
|
||||
if (!command) return state;
|
||||
return upsertCommand(state, {
|
||||
...command,
|
||||
status: "completed",
|
||||
reason: payload.result_summary ?? command.reason,
|
||||
});
|
||||
}
|
||||
case "command.failed": {
|
||||
const payload = event.data as CommandFailedPayload;
|
||||
const command = state.commands[payload.command_id];
|
||||
if (!command) return state;
|
||||
return upsertCommand(state, { ...command, status: "failed", error: payload.error });
|
||||
}
|
||||
case "report.delta": {
|
||||
const payload = event.data as ReportDeltaPayload;
|
||||
const draftId = payload.draft_id ?? "draft";
|
||||
const draft = state.reportDraft && state.reportDraft.draftId === draftId
|
||||
? state.reportDraft
|
||||
: { draftId, markdown: "" };
|
||||
return {
|
||||
...state,
|
||||
reportDraft: { draftId, markdown: draft.markdown + payload.delta },
|
||||
};
|
||||
}
|
||||
case "report.version.created": {
|
||||
const payload = event.data as ReportVersionCreatedPayload;
|
||||
const exists = state.reportVersions.some((v) => v.id === payload.version.id);
|
||||
const reportVersions = exists
|
||||
? state.reportVersions.map((v) => (v.id === payload.version.id ? payload.version : v))
|
||||
: [...state.reportVersions, payload.version];
|
||||
const session = state.session
|
||||
? { ...state.session, current_report_version_id: payload.version.id }
|
||||
: state.session;
|
||||
// 正式版本落库后清空流式草稿(草稿永不覆盖正式版本)。
|
||||
return { ...state, reportVersions, reportDraft: null, session };
|
||||
}
|
||||
case "review.created":
|
||||
return { ...state, lastReview: (event.data as ReviewCreatedPayload).review };
|
||||
case "error": {
|
||||
const payload = event.data as ErrorEventPayload;
|
||||
return {
|
||||
...state,
|
||||
lastError: {
|
||||
message: payload.message,
|
||||
hint: payload.recover_hint ?? null,
|
||||
nodeId: payload.node_id ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "heartbeat":
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 快照事件(SSE 内的轻量 session 状态刷新)────────────────────────────────
|
||||
|
||||
function applyOneSnapshotEvent(
|
||||
state: CollaborationState,
|
||||
data: Partial<SessionSnapshot>,
|
||||
): CollaborationState {
|
||||
let next = state;
|
||||
if (data.session) {
|
||||
next = {
|
||||
...next,
|
||||
session: data.session,
|
||||
selectedPlanId: data.session.selected_plan_id ?? next.selectedPlanId,
|
||||
};
|
||||
}
|
||||
if (data.run !== undefined) {
|
||||
next = { ...next, run: data.run };
|
||||
}
|
||||
// 增量快照可只带部分集合(如运行中广播 agent_runs);按稳定 id 合并,不整组替换。
|
||||
if (data.agent_runs) {
|
||||
const agentRuns = { ...next.agentRuns };
|
||||
for (const agentRun of data.agent_runs) {
|
||||
agentRuns[agentRun.agent_run_id] = agentRun;
|
||||
}
|
||||
next = { ...next, agentRuns };
|
||||
}
|
||||
if (data.nodes) {
|
||||
const nodes = { ...next.nodes };
|
||||
for (const node of data.nodes) {
|
||||
const existing = nodes[node.node_id];
|
||||
// 已有状态不回退(快照可能落后于实时事件)。
|
||||
if (!existing || node.attempt > existing.attempt) {
|
||||
nodes[node.node_id] = node;
|
||||
}
|
||||
}
|
||||
next = { ...next, nodes };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── 消息 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function upsertMessage(
|
||||
state: CollaborationState,
|
||||
message: CollaborationMessage | undefined | null,
|
||||
): CollaborationState {
|
||||
if (!message || !message.message_id) return state;
|
||||
|
||||
// 乐观消息确认:服务器镜像携带同一 idempotency_key 时,原地替换
|
||||
// `pending:{key}` 行(保留时间线位置),不产生重复用户消息。
|
||||
const idempotencyKey = message.metadata?.idempotency_key;
|
||||
if (
|
||||
typeof idempotencyKey === "string" &&
|
||||
!state.messagesById[message.message_id] &&
|
||||
state.messagesById[`pending:${idempotencyKey}`]
|
||||
) {
|
||||
const pendingId = `pending:${idempotencyKey}`;
|
||||
const messagesById = { ...state.messagesById };
|
||||
delete messagesById[pendingId];
|
||||
messagesById[message.message_id] = { ...message };
|
||||
return {
|
||||
...state,
|
||||
messagesById,
|
||||
messageOrder: state.messageOrder.map((id) => (id === pendingId ? message.message_id : id)),
|
||||
};
|
||||
}
|
||||
|
||||
const existing = state.messagesById[message.message_id];
|
||||
if (existing) {
|
||||
// delta 期间到达的 created/重放:保留更完整的字段,不回退 content。
|
||||
const merged: CollaborationMessage = {
|
||||
...existing,
|
||||
...message,
|
||||
content: message.content || existing.content,
|
||||
completed_at: message.completed_at ?? existing.completed_at,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
messagesById: { ...state.messagesById, [message.message_id]: merged },
|
||||
};
|
||||
}
|
||||
// 入库时浅拷贝:事件 payload 可能与发送方共享引用(如本地 mock),
|
||||
// 后续变异不得穿透进前端状态。
|
||||
const stored: CollaborationMessage = { ...message };
|
||||
return {
|
||||
...state,
|
||||
messagesById: { ...state.messagesById, [message.message_id]: stored },
|
||||
messageOrder: [...state.messageOrder, message.message_id],
|
||||
};
|
||||
}
|
||||
|
||||
function appendMessageDelta(state: CollaborationState, payload: MessageDeltaPayload): CollaborationState {
|
||||
if (!payload.message_id) return state;
|
||||
const existing = state.messagesById[payload.message_id];
|
||||
if (existing) {
|
||||
return {
|
||||
...state,
|
||||
messagesById: {
|
||||
...state.messagesById,
|
||||
[payload.message_id]: { ...existing, content: existing.content + payload.delta },
|
||||
},
|
||||
};
|
||||
}
|
||||
// created 丢失时防御性建行(缺省按 payload 的归属字段)。
|
||||
return upsertMessage(state, {
|
||||
message_id: payload.message_id,
|
||||
role: "ai",
|
||||
content: payload.delta,
|
||||
agent_run_id: payload.agent_run_id ?? null,
|
||||
node_run_id: payload.node_run_id ?? null,
|
||||
visibility: "visible",
|
||||
created_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
});
|
||||
}
|
||||
|
||||
function completeMessage(state: CollaborationState, payload: MessageCompletedPayload): CollaborationState {
|
||||
const existing = state.messagesById[payload.message_id];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
messagesById: {
|
||||
...state.messagesById,
|
||||
[payload.message_id]: {
|
||||
...existing,
|
||||
content: payload.content ?? existing.content,
|
||||
completed_at: payload.completed_at ?? new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 工具调用 / 工具返回 ─────────────────────────────────────────────────────
|
||||
|
||||
function ownerMessageId(messageId: string | undefined, toolCallId: string, agentRunId?: string | null): string {
|
||||
return messageId || `ai-tc-${agentRunId ?? "shared"}-${toolCallId}`;
|
||||
}
|
||||
|
||||
function applyToolCallCreated(state: CollaborationState, payload: ToolCallCreatedPayload): CollaborationState {
|
||||
const messageId = ownerMessageId(payload.message_id, payload.tool_call.id, payload.agent_run_id);
|
||||
const existing = state.messagesById[messageId];
|
||||
if (existing) {
|
||||
const hasCall = existing.tool_calls?.some((call) => call.id === payload.tool_call.id);
|
||||
const toolCalls = hasCall
|
||||
? existing.tool_calls
|
||||
: [...(existing.tool_calls ?? []), payload.tool_call];
|
||||
return {
|
||||
...state,
|
||||
messagesById: { ...state.messagesById, [messageId]: { ...existing, tool_calls: toolCalls } },
|
||||
};
|
||||
}
|
||||
return upsertMessage(state, {
|
||||
message_id: messageId,
|
||||
role: "ai",
|
||||
content: "",
|
||||
tool_calls: [payload.tool_call],
|
||||
agent_run_id: payload.agent_run_id ?? null,
|
||||
visibility: "visible",
|
||||
created_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolCallDelta(state: CollaborationState, payload: ToolCallDeltaPayload): CollaborationState {
|
||||
const owner = Object.values(state.messagesById).find((message) =>
|
||||
message.tool_calls?.some((call) => call.id === payload.tool_call_id),
|
||||
);
|
||||
if (!owner) return state;
|
||||
const toolCalls = owner.tool_calls!.map((call) => {
|
||||
if (call.id !== payload.tool_call_id) return call;
|
||||
return { ...call, args: (typeof call.args === "string" ? call.args : "") + payload.args_delta };
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
messagesById: { ...state.messagesById, [owner.message_id]: { ...owner, tool_calls: toolCalls } },
|
||||
};
|
||||
}
|
||||
|
||||
function applyToolCallCompleted(state: CollaborationState, payload: ToolCallCompletedPayload): CollaborationState {
|
||||
const owner = Object.values(state.messagesById).find((message) =>
|
||||
message.tool_calls?.some((call) => call.id === payload.tool_call.id),
|
||||
);
|
||||
if (!owner) {
|
||||
return applyToolCallCreated(state, {
|
||||
message_id: payload.message_id,
|
||||
tool_call: payload.tool_call,
|
||||
} as ToolCallCreatedPayload);
|
||||
}
|
||||
const toolCalls = owner.tool_calls!.map((call) =>
|
||||
call.id === payload.tool_call.id ? payload.tool_call : call,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
messagesById: { ...state.messagesById, [owner.message_id]: { ...owner, tool_calls: toolCalls } },
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessageId(payload: { message_id?: string; tool_call_id: string }): string {
|
||||
return payload.message_id || `tool-result-${payload.tool_call_id}`;
|
||||
}
|
||||
|
||||
function applyToolResultCreated(state: CollaborationState, payload: ToolResultCreatedPayload): CollaborationState {
|
||||
return upsertMessage(state, {
|
||||
message_id: toolResultMessageId(payload),
|
||||
role: "tool",
|
||||
content: payload.content ?? "",
|
||||
name: payload.name,
|
||||
tool_call_id: payload.tool_call_id,
|
||||
agent_run_id: payload.agent_run_id ?? null,
|
||||
node_run_id: payload.node_run_id ?? null,
|
||||
phase_id: payload.phase_id ?? null,
|
||||
visibility: "visible",
|
||||
created_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolResultDelta(state: CollaborationState, payload: ToolResultDeltaPayload): CollaborationState {
|
||||
const messageId = toolResultMessageId(payload);
|
||||
const existing = state.messagesById[messageId];
|
||||
if (existing) {
|
||||
return {
|
||||
...state,
|
||||
messagesById: {
|
||||
...state.messagesById,
|
||||
[messageId]: { ...existing, content: existing.content + payload.delta },
|
||||
},
|
||||
};
|
||||
}
|
||||
return applyToolResultCreated(state, {
|
||||
message_id: payload.message_id,
|
||||
tool_call_id: payload.tool_call_id,
|
||||
name: "",
|
||||
content: payload.delta,
|
||||
} as ToolResultCreatedPayload);
|
||||
}
|
||||
|
||||
function applyToolResultCompleted(
|
||||
state: CollaborationState,
|
||||
payload: ToolResultCompletedPayload,
|
||||
): CollaborationState {
|
||||
const messageId = toolResultMessageId(payload);
|
||||
const existing = state.messagesById[messageId];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
messagesById: {
|
||||
...state.messagesById,
|
||||
[messageId]: {
|
||||
...existing,
|
||||
content: payload.content ?? existing.content,
|
||||
completed_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 计划 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyPlansProposed(state: CollaborationState, payload: PlanProposedPayload): CollaborationState {
|
||||
const plans = payload.plans ?? [];
|
||||
const planMap: Record<string, ReportPlanCandidate> = { ...state.planMap };
|
||||
for (const plan of plans) {
|
||||
planMap[plan.id] = plan;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
plans,
|
||||
planMap,
|
||||
proposalGroupId: plans[0]?.proposal_group_id ?? state.proposalGroupId,
|
||||
planProgress: null,
|
||||
};
|
||||
}
|
||||
|
||||
function syncSelectedPlan(state: CollaborationState, planId: string): CollaborationState {
|
||||
const planMap: Record<string, ReportPlanCandidate> = {};
|
||||
for (const plan of state.plans) {
|
||||
planMap[plan.id] = plan.id === planId ? { ...plan, status: "selected" } : plan;
|
||||
}
|
||||
const session: ReportSession | null = state.session
|
||||
? { ...state.session, selected_plan_id: planId, status: "proposal_ready" }
|
||||
: null;
|
||||
return {
|
||||
...state,
|
||||
planMap: { ...state.planMap, ...planMap },
|
||||
plans: state.plans.map((plan) =>
|
||||
plan.id === planId ? { ...plan, status: "selected" } : plan,
|
||||
),
|
||||
selectedPlanId: planId,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Run / 节点 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function applyRunStatusChanged(
|
||||
state: CollaborationState,
|
||||
payload: RunStatusChangedPayload,
|
||||
event: CollaborationEvent,
|
||||
): CollaborationState {
|
||||
const run: ReportRun = state.run
|
||||
? { ...state.run, status: payload.status, current_phase: payload.phase ?? state.run.current_phase }
|
||||
: {
|
||||
id: event.runId ?? "",
|
||||
session_id: state.sessionId,
|
||||
plan_id: "",
|
||||
status: payload.status,
|
||||
cancel_requested: false,
|
||||
next_event_seq: event.seq,
|
||||
current_phase: payload.phase ?? null,
|
||||
created_at: event.timestamp,
|
||||
updated_at: event.timestamp,
|
||||
};
|
||||
const session = state.session
|
||||
? { ...state.session, status: sessionStatusFromRunStatus(payload.status) }
|
||||
: state.session;
|
||||
return { ...state, run, session };
|
||||
}
|
||||
|
||||
function applyNodeStatusChanged(
|
||||
state: CollaborationState,
|
||||
payload: NodeStatusChangedPayload,
|
||||
): CollaborationState {
|
||||
const existing = state.nodes[payload.node_id];
|
||||
const targetStatus = payload.status;
|
||||
if (existing) {
|
||||
// 状态机守卫:晚到/非法迁移(如 cancelled 后回写 completed)直接忽略。
|
||||
if (!canTransitionNode(existing.status, targetStatus)) return state;
|
||||
const sameAttempt = existing.attempt === payload.attempt;
|
||||
if (sameAttempt && existing.status === targetStatus) return state;
|
||||
const attempts = sameAttempt
|
||||
? existing.attempts.map((attempt) =>
|
||||
attempt.attempt === payload.attempt
|
||||
? {
|
||||
...attempt,
|
||||
status: targetStatus,
|
||||
ended_at: isTerminalNodeStatus(targetStatus) ? new Date().toISOString() : attempt.ended_at,
|
||||
error: payload.error ?? attempt.error,
|
||||
}
|
||||
: attempt,
|
||||
)
|
||||
: [
|
||||
...existing.attempts,
|
||||
{
|
||||
attempt: payload.attempt,
|
||||
status: targetStatus,
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: isTerminalNodeStatus(targetStatus) ? new Date().toISOString() : null,
|
||||
error: payload.error ?? null,
|
||||
},
|
||||
];
|
||||
const awaitingInput: NodeAwaitingInput | null =
|
||||
targetStatus === "awaiting_input" ? payload.awaiting_input ?? existing.awaiting_input ?? null : null;
|
||||
return {
|
||||
...state,
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[payload.node_id]: {
|
||||
...existing,
|
||||
status: targetStatus,
|
||||
attempt: payload.attempt,
|
||||
attempts,
|
||||
awaiting_input: awaitingInput,
|
||||
error: payload.error ?? (targetStatus === "failed" ? existing.error : null),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// 未注册节点(事件先于 node.created 到达)先落骨架。
|
||||
return {
|
||||
...state,
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[payload.node_id]: {
|
||||
node_id: payload.node_id,
|
||||
label: payload.node_id,
|
||||
status: targetStatus,
|
||||
attempt: payload.attempt,
|
||||
awaiting_input: targetStatus === "awaiting_input" ? payload.awaiting_input ?? null : null,
|
||||
error: payload.error ?? null,
|
||||
attempts: [
|
||||
{
|
||||
attempt: payload.attempt,
|
||||
status: targetStatus,
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: isTerminalNodeStatus(targetStatus) ? new Date().toISOString() : null,
|
||||
error: payload.error ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyNodeAttemptCreated(
|
||||
state: CollaborationState,
|
||||
payload: NodeAttemptCreatedPayload,
|
||||
): CollaborationState {
|
||||
const existing = state.nodes[payload.node_id];
|
||||
if (!existing) return state;
|
||||
if (existing.attempts.some((attempt) => attempt.attempt === payload.attempt)) return state;
|
||||
return {
|
||||
...state,
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[payload.node_id]: {
|
||||
...existing,
|
||||
attempt: payload.attempt,
|
||||
status: "queued",
|
||||
error: null,
|
||||
awaiting_input: null,
|
||||
attempts: [
|
||||
...existing.attempts,
|
||||
{ attempt: payload.attempt, status: "queued", started_at: new Date().toISOString() },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isTerminalNodeStatus(status: CollaborationNodeStatus): boolean {
|
||||
return (
|
||||
status === "completed" ||
|
||||
status === "failed" ||
|
||||
status === "cancelled" ||
|
||||
status === "superseded"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 命令 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function upsertCommand(state: CollaborationState, command: CollaborationCommand): CollaborationState {
|
||||
const exists = Boolean(state.commands[command.command_id]);
|
||||
return {
|
||||
...state,
|
||||
commands: { ...state.commands, [command.command_id]: command },
|
||||
commandOrder: exists
|
||||
? state.commandOrder
|
||||
: [...state.commandOrder, command.command_id],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 乐观更新(前端 pending 用户消息;不改变服务器事实)──────────────────────
|
||||
|
||||
/**
|
||||
* 插入一条 pending 用户消息(Composer 发送后、服务器持久化前)。
|
||||
* 服务器 message.created 到达时按相同 message_id 覆盖,不重复。
|
||||
*/
|
||||
export function insertOptimisticUserMessage(
|
||||
state: CollaborationState,
|
||||
message: CollaborationMessage,
|
||||
): CollaborationState {
|
||||
return upsertMessage(state, { ...message, visibility: "visible" });
|
||||
}
|
||||
|
||||
/** pending 用户消息未被服务器确认时移除(发送失败保留重试入口由 UI 层负责)。 */
|
||||
export function removeMessage(state: CollaborationState, messageId: string): CollaborationState {
|
||||
if (!state.messagesById[messageId]) return state;
|
||||
const messagesById = { ...state.messagesById };
|
||||
delete messagesById[messageId];
|
||||
return {
|
||||
...state,
|
||||
messagesById,
|
||||
messageOrder: state.messageOrder.filter((id) => id !== messageId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 无损协议适配:CollaborationMessage → LangGraph SDK Message + BaseStream 外观。
|
||||
*
|
||||
* 规则(FE-5):
|
||||
* - 一对一映射,不概括文本、不吞工具结果。
|
||||
* - 工具调用挂在所属 ai 消息的 `tool_calls`;返回为 `tool` 消息,`tool_call_id`
|
||||
* 严格配对(现有 MessageGroup 直接渲染)。
|
||||
* - AgentScope 归属写入 `additional_kwargs.report_collaboration`。
|
||||
* - 业务卡(方案组 / 命令确认 / 报告入口)为**前端派生的虚拟 ai 消息**,经
|
||||
* MessageList 的通用业务卡插槽(`additional_kwargs.report_collaboration_card`)
|
||||
* 渲染;未传插槽的页面零渲染。
|
||||
*/
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
import type {
|
||||
CollaborationCommand,
|
||||
CollaborationMessage,
|
||||
ReportPlanCandidate,
|
||||
ReportVersion,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
// ── 归属标记 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportCollaborationKwargs {
|
||||
agentRunId?: string;
|
||||
nodeRunId?: string;
|
||||
phaseId?: string;
|
||||
/** 前端乐观消息(尚未被服务器确认)。 */
|
||||
optimistic?: boolean;
|
||||
}
|
||||
|
||||
export function collaborationKwargs(message: CollaborationMessage): ReportCollaborationKwargs {
|
||||
const kwargs: ReportCollaborationKwargs = {};
|
||||
if (message.agent_run_id) kwargs.agentRunId = message.agent_run_id;
|
||||
if (message.node_run_id) kwargs.nodeRunId = message.node_run_id;
|
||||
if (message.phase_id) kwargs.phaseId = message.phase_id;
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
// ── 一对一映射 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function toLangGraphMessage(message: CollaborationMessage): Message {
|
||||
const additional_kwargs: Record<string, unknown> = {};
|
||||
const rc = collaborationKwargs(message);
|
||||
if (Object.keys(rc).length > 0) {
|
||||
additional_kwargs.report_collaboration = rc;
|
||||
}
|
||||
|
||||
if (message.role === "human") {
|
||||
return {
|
||||
id: message.message_id,
|
||||
type: "human",
|
||||
content: message.content,
|
||||
additional_kwargs,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
return {
|
||||
id: message.message_id,
|
||||
type: "tool",
|
||||
content: message.content,
|
||||
name: message.name ?? undefined,
|
||||
tool_call_id: message.tool_call_id ?? "",
|
||||
additional_kwargs,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
// ai:tool_calls 原样透传(args 流式期间为部分 JSON 字符串)。
|
||||
return {
|
||||
id: message.message_id,
|
||||
type: "ai",
|
||||
content: message.content,
|
||||
name: message.name ?? undefined,
|
||||
tool_calls: message.tool_calls?.length
|
||||
? message.tool_calls.map((call) => ({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
args: call.args,
|
||||
}))
|
||||
: undefined,
|
||||
additional_kwargs,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
export function toLangGraphMessages(messages: CollaborationMessage[]): Message[] {
|
||||
return messages.map(toLangGraphMessage);
|
||||
}
|
||||
|
||||
// ── 虚拟业务卡 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReportCollaborationCardPayload =
|
||||
| {
|
||||
kind: "plan_group";
|
||||
plans: ReportPlanCandidate[];
|
||||
selectedPlanId: string | null;
|
||||
requirementRevision: number;
|
||||
}
|
||||
| {
|
||||
kind: "command_confirm";
|
||||
command: CollaborationCommand;
|
||||
}
|
||||
| {
|
||||
kind: "report_available";
|
||||
version: ReportVersion;
|
||||
versionCount: number;
|
||||
};
|
||||
|
||||
export function hasReportCollaborationCard(message: Message): boolean {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof (message.additional_kwargs as Record<string, unknown> | undefined)
|
||||
?.report_collaboration_card === "object"
|
||||
);
|
||||
}
|
||||
|
||||
export function readReportCollaborationCard(
|
||||
message: Message,
|
||||
): ReportCollaborationCardPayload | null {
|
||||
const payload = (message.additional_kwargs as Record<string, unknown> | undefined)
|
||||
?.report_collaboration_card;
|
||||
return (payload as ReportCollaborationCardPayload) ?? null;
|
||||
}
|
||||
|
||||
function buildCardMessage(id: string, payload: ReportCollaborationCardPayload): Message {
|
||||
return {
|
||||
id,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { report_collaboration_card: payload },
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
/** 方案组卡(proposal_ready 时注入时间线,点击预览 / 确认后执行)。 */
|
||||
export function buildPlanGroupCard(
|
||||
plans: ReportPlanCandidate[],
|
||||
selectedPlanId: string | null,
|
||||
requirementRevision: number,
|
||||
): Message {
|
||||
const groupId = plans[0]?.proposal_group_id ?? "plans";
|
||||
return buildCardMessage(`rc-card-plans-${groupId}-r${requirementRevision}`, {
|
||||
kind: "plan_group",
|
||||
plans,
|
||||
selectedPlanId,
|
||||
requirementRevision,
|
||||
});
|
||||
}
|
||||
|
||||
/** 命令确认卡(command.confirmation.required 之后)。 */
|
||||
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
|
||||
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
|
||||
kind: "command_confirm",
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
/** 报告入口卡(正式版本落库后)。 */
|
||||
export function buildReportAvailableCard(version: ReportVersion, versionCount: number): Message {
|
||||
return buildCardMessage(`rc-card-report-${version.id}`, {
|
||||
kind: "report_available",
|
||||
version,
|
||||
versionCount,
|
||||
});
|
||||
}
|
||||
|
||||
// ── BaseStream 外观 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 MessageList 需要的最小 `BaseStream<AgentThreadState>`:
|
||||
* MessageList 只读 `messages` / `isLoading` / `isThreadLoading`。
|
||||
*/
|
||||
export function buildBaseStreamFacade(
|
||||
messages: Message[],
|
||||
isLoading: boolean,
|
||||
): BaseStream<AgentThreadState> {
|
||||
return {
|
||||
messages,
|
||||
values: { title: "", messages, artifacts: [], todos: [] },
|
||||
isLoading,
|
||||
isThreadLoading: false,
|
||||
error: undefined,
|
||||
interrupt: undefined,
|
||||
stop: async () => undefined,
|
||||
submit: async () => undefined,
|
||||
joinStream: async () => undefined,
|
||||
} as unknown as BaseStream<AgentThreadState>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 状态投影 selector:从 CollaborationState 派生页面所需的视图数据。
|
||||
* 纯函数、无副作用;并行聚焦只过滤本地消息,不改写事实。
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentRun,
|
||||
CollaborationCommand,
|
||||
CollaborationMessage,
|
||||
CollaborationNodeState,
|
||||
ReportPlanCandidate,
|
||||
} from "@/report-collaboration/api/types";
|
||||
import type { CollaborationState } from "@/report-collaboration/state/event-reducer";
|
||||
|
||||
/** 用户可见消息,按时间线顺序。 */
|
||||
export function selectOrderedVisibleMessages(state: CollaborationState): CollaborationMessage[] {
|
||||
return state.messageOrder
|
||||
.map((id) => state.messagesById[id])
|
||||
.filter(
|
||||
(message): message is CollaborationMessage =>
|
||||
Boolean(message) && message.visibility === "visible",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行成员聚焦:null = 全量(单成员 / 协调者阶段);指定 agentRunId 时
|
||||
* 只保留「共享消息(human + 无归属 ai/tool)+ 目标成员消息」。
|
||||
* 只做客户端过滤,不重拉、不重排、不改写。
|
||||
*/
|
||||
export function selectMessagesForAgentRun(
|
||||
messages: CollaborationMessage[],
|
||||
focusedAgentRunId: string | null,
|
||||
): CollaborationMessage[] {
|
||||
if (!focusedAgentRunId) return messages;
|
||||
return messages.filter((message) => {
|
||||
if (message.role === "human") return true;
|
||||
return !message.agent_run_id || message.agent_run_id === focusedAgentRunId;
|
||||
});
|
||||
}
|
||||
|
||||
function isCoordinator(agentRun: AgentRun): boolean {
|
||||
return /coord/i.test(agentRun.role) || /协调/.test(agentRun.display_name);
|
||||
}
|
||||
|
||||
/** 并行阶段成员(不含协调者);同一 phase 下 >= 2 个成员时显示选择器。 */
|
||||
export function selectParallelMembers(state: CollaborationState): AgentRun[] {
|
||||
const runs = Object.values(state.agentRuns);
|
||||
const members = runs.filter((run) => !isCoordinator(run));
|
||||
if (members.length < 2) return [];
|
||||
// 以最新 phase 聚合(并行阶段成员共享 phase_id)。
|
||||
const latestPhase = members
|
||||
.map((run) => run.phase_id ?? "")
|
||||
.sort()
|
||||
.at(-1);
|
||||
const phaseMembers = members.filter((run) => (run.phase_id ?? "") === latestPhase);
|
||||
return phaseMembers.length >= 2 ? phaseMembers : [];
|
||||
}
|
||||
|
||||
/** 选择器标签:协调者在首位,后跟并行成员(仅并行阶段非空时使用)。 */
|
||||
export function selectAgentTabs(state: CollaborationState): AgentRun[] {
|
||||
const members = selectParallelMembers(state);
|
||||
if (members.length === 0) return [];
|
||||
const coordinator =
|
||||
Object.values(state.agentRuns).find(isCoordinator) ?? null;
|
||||
const byStart = (a: AgentRun, b: AgentRun) =>
|
||||
(a.started_at ?? "").localeCompare(b.started_at ?? "");
|
||||
return [...(coordinator ? [coordinator] : []), ...[...members].sort(byStart)];
|
||||
}
|
||||
|
||||
/** 画布用计划:预览 > 已选定 > 最新提案组第一张。 */
|
||||
export function selectPlanForCanvas(
|
||||
state: CollaborationState,
|
||||
previewPlanId: string | null,
|
||||
): ReportPlanCandidate | null {
|
||||
if (previewPlanId && state.planMap[previewPlanId]) {
|
||||
return state.planMap[previewPlanId];
|
||||
}
|
||||
if (state.selectedPlanId && state.planMap[state.selectedPlanId]) {
|
||||
return state.planMap[state.selectedPlanId];
|
||||
}
|
||||
return state.plans.find((plan) => plan.validation?.ok !== false) ?? state.plans[0] ?? null;
|
||||
}
|
||||
|
||||
/** 运行态节点状态映射(plan 节点 id -> run 节点状态)。 */
|
||||
export function selectNodeStatusMap(state: CollaborationState): Record<string, CollaborationNodeState> {
|
||||
return state.nodes;
|
||||
}
|
||||
|
||||
export type PagePhase =
|
||||
| "empty"
|
||||
| "clarifying"
|
||||
| "planning"
|
||||
| "proposal_ready"
|
||||
| "running"
|
||||
| "awaiting_input"
|
||||
| "reviewing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
/** 页面主状态:服务器 session 状态为事实源;awaiting_input 节点细化 running。 */
|
||||
export function derivePagePhase(state: CollaborationState): PagePhase {
|
||||
const status = state.session?.status;
|
||||
if (!status) return "empty";
|
||||
if (status === "running" && Object.values(state.nodes).some((n) => n.status === "awaiting_input")) {
|
||||
return "awaiting_input";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 待用户确认的命令(高成本 / 低置信度)。 */
|
||||
export function selectPendingConfirmationCommands(state: CollaborationState): CollaborationCommand[] {
|
||||
return state.commandOrder
|
||||
.map((id) => state.commands[id])
|
||||
.filter(
|
||||
(command): command is CollaborationCommand =>
|
||||
Boolean(command) && command.status === "confirmation_required",
|
||||
);
|
||||
}
|
||||
|
||||
export function selectAwaitingInputNodes(state: CollaborationState): CollaborationNodeState[] {
|
||||
return Object.values(state.nodes).filter((node) => node.status === "awaiting_input");
|
||||
}
|
||||
|
||||
export function selectHeadReportVersion(state: CollaborationState) {
|
||||
const marked = state.reportVersions.find((version) => version.is_head);
|
||||
return marked ?? state.reportVersions.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/** 正在流式输出(未完成)的成员消息计数,用于标签上的「回复中」状态。 */
|
||||
export function selectStreamingAgentRunIds(state: CollaborationState): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const id of state.messageOrder) {
|
||||
const message = state.messagesById[id];
|
||||
if (
|
||||
message &&
|
||||
message.role !== "human" &&
|
||||
message.agent_run_id &&
|
||||
!message.completed_at
|
||||
) {
|
||||
ids.add(message.agent_run_id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 报告协作工作台 —— 页面专用语义 token 与组件皮肤。
|
||||
*
|
||||
* - token 从 DeerFlow 主题变量派生(--background/--card/--border/--primary...),
|
||||
* 组件不散落十六进制颜色;状态色为固定语义色(同时以图标+文字表达,
|
||||
* 不单靠颜色区分状态)。
|
||||
* - 交互过渡 150–300ms;`prefers-reduced-motion` 下关闭流动/滑入动画。
|
||||
* - 布局尺寸参考 Coze Studio 基线:顶栏 56px、状态栏 28px、左栏 480px。
|
||||
*/
|
||||
|
||||
.rc-root {
|
||||
/* Chrome 109 兼容:不使用 color-mix,透明度变体用 rgba / legacy rgb 变量表达。 */
|
||||
--rc-bg-page: var(--background, #f7f7fc);
|
||||
--rc-bg-panel: var(--card, #fcfcff);
|
||||
--rc-bg-canvas: var(--background, #f7f7fc);
|
||||
--rc-bg-secondary: var(--muted, #f0f0f7);
|
||||
--rc-border: var(--border, rgba(82, 100, 154, 0.13));
|
||||
--rc-border-strong: var(--border, rgba(82, 100, 154, 0.13));
|
||||
--rc-text-primary: var(--foreground, #0f1528);
|
||||
--rc-text-secondary: var(--muted-foreground, rgba(15, 21, 40, 0.62));
|
||||
--rc-accent: var(--primary, #5c62ff);
|
||||
--rc-accent-rgb: var(--legacy-primary-rgb, 92 98 255);
|
||||
--rc-accent-soft: rgb(var(--rc-accent-rgb) / 10%);
|
||||
--rc-running: #2563eb;
|
||||
--rc-waiting: #d97706;
|
||||
--rc-success: #16a34a;
|
||||
--rc-danger: #dc2626;
|
||||
--rc-panel-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
|
||||
--rc-card-radius: 9px;
|
||||
--rc-panel-radius: 12px;
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
background: var(--rc-bg-page);
|
||||
color: var(--rc-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── 顶栏 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--rc-border);
|
||||
background: var(--rc-bg-panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rc-header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
.rc-header-meta {
|
||||
font-size: 12px;
|
||||
color: var(--rc-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 主体两栏 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rc-conversation-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: var(--rc-bg-panel);
|
||||
border-right: 1px solid var(--rc-border);
|
||||
}
|
||||
|
||||
.rc-canvas-panel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: var(--rc-bg-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 分隔条:宽热区 + 4px 视觉把手,键盘可聚焦 */
|
||||
.rc-resizer {
|
||||
position: relative;
|
||||
flex: 0 0 16px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.rc-resizer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 36%;
|
||||
bottom: 36%;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--rc-border-strong);
|
||||
transition: background 0.15s ease, top 0.15s ease, bottom 0.15s ease;
|
||||
}
|
||||
|
||||
.rc-resizer:hover,
|
||||
.rc-resizer:focus-visible {
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--rc-accent-rgb) / 24%);
|
||||
}
|
||||
|
||||
.rc-resizer:hover::after,
|
||||
.rc-resizer:focus-visible::after {
|
||||
top: 24%;
|
||||
bottom: 24%;
|
||||
background: var(--rc-accent);
|
||||
}
|
||||
|
||||
/* ── 状态栏 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--rc-text-secondary);
|
||||
background: var(--rc-bg-secondary);
|
||||
border-top: 1px solid var(--rc-border);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
background: var(--rc-text-secondary);
|
||||
}
|
||||
|
||||
.rc-status-dot[data-status="live"] {
|
||||
background: var(--rc-success);
|
||||
}
|
||||
|
||||
.rc-status-dot[data-status="connecting"],
|
||||
.rc-status-dot[data-status="reconnecting"] {
|
||||
background: var(--rc-waiting);
|
||||
}
|
||||
|
||||
.rc-status-dot[data-status="error"] {
|
||||
background: var(--rc-danger);
|
||||
}
|
||||
|
||||
/* ── 按钮 / 标签 / 卡片通用 ──────────────────────────────────────────── */
|
||||
|
||||
.rc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--rc-border-strong);
|
||||
background: var(--rc-bg-panel);
|
||||
color: var(--rc-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-btn:hover:not(:disabled) {
|
||||
border-color: var(--rc-accent);
|
||||
color: var(--rc-accent);
|
||||
}
|
||||
|
||||
.rc-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rc-btn-primary {
|
||||
background: var(--rc-accent);
|
||||
border-color: var(--rc-accent);
|
||||
color: var(--rc-bg-panel);
|
||||
}
|
||||
|
||||
.rc-btn-primary:hover:not(:disabled) {
|
||||
color: var(--rc-bg-panel);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.rc-btn-danger {
|
||||
background: var(--rc-danger);
|
||||
border-color: var(--rc-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rc-btn-danger:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.rc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
background: var(--rc-accent-soft);
|
||||
color: var(--rc-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-badge-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--rc-waiting);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 方案卡 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-plan-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
border-radius: var(--rc-card-radius);
|
||||
border: 1px solid var(--rc-border);
|
||||
background: var(--rc-bg-panel);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rc-plan-card:hover {
|
||||
border-color: rgb(var(--rc-accent-rgb) / 45%);
|
||||
}
|
||||
|
||||
.rc-plan-card-selected {
|
||||
border-color: var(--rc-accent);
|
||||
box-shadow: 0 0 0 2px rgb(var(--rc-accent-rgb) / 14%);
|
||||
}
|
||||
|
||||
.rc-plan-card-disabled {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── 并行成员选择器 ───────────────────────────────────────────────────── */
|
||||
|
||||
.rc-agent-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.rc-agent-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rc-border);
|
||||
background: var(--rc-bg-panel);
|
||||
font-size: 12px;
|
||||
color: var(--rc-text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.rc-agent-tab:hover {
|
||||
border-color: var(--rc-accent);
|
||||
}
|
||||
|
||||
.rc-agent-tab[data-active="true"] {
|
||||
border-color: var(--rc-accent);
|
||||
background: var(--rc-accent-soft);
|
||||
color: var(--rc-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 画布节点 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-flow-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 168px;
|
||||
max-width: 216px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--rc-card-radius);
|
||||
border: 1.5px solid var(--rc-border-strong);
|
||||
background: var(--rc-bg-panel);
|
||||
box-shadow: var(--rc-panel-shadow);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.rc-flow-node:hover {
|
||||
border-color: var(--rc-accent);
|
||||
}
|
||||
|
||||
.rc-flow-node[data-status="running"],
|
||||
.rc-flow-node[data-status="validating"] {
|
||||
border-color: var(--rc-running);
|
||||
}
|
||||
|
||||
.rc-flow-node[data-status="awaiting_input"] {
|
||||
border-color: var(--rc-waiting);
|
||||
}
|
||||
|
||||
.rc-flow-node[data-status="completed"] {
|
||||
border-color: var(--rc-success);
|
||||
}
|
||||
|
||||
.rc-flow-node[data-status="failed"] {
|
||||
border-color: var(--rc-danger);
|
||||
}
|
||||
|
||||
.rc-flow-node[data-status="superseded"],
|
||||
.rc-flow-node[data-status="cancelled"] {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.rc-flow-node[data-selected="true"] {
|
||||
box-shadow: 0 0 0 2px rgb(var(--rc-accent-rgb) / 26%);
|
||||
}
|
||||
|
||||
.rc-flow-node-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rc-flow-node-sub {
|
||||
color: var(--rc-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rc-flow-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 活动连线低强度流动 */
|
||||
.rc-edge-active {
|
||||
stroke: var(--rc-running);
|
||||
stroke-dasharray: 6 4;
|
||||
animation: rc-edge-flow 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rc-edge-flow {
|
||||
to {
|
||||
stroke-dashoffset: -10;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 节点说明抽屉(覆盖画布,不占第三栏)─────────────────────────────── */
|
||||
|
||||
.rc-drawer-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.rc-drawer {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: min(420px, calc(100% - 16px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--rc-border);
|
||||
background: var(--rc-bg-panel);
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 21;
|
||||
overflow: hidden;
|
||||
animation: rc-drawer-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes rc-drawer-in {
|
||||
from {
|
||||
transform: translateX(12px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rc-drawer-section {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--rc-border);
|
||||
}
|
||||
|
||||
.rc-drawer-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rc-drawer-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--rc-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Composer ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-composer {
|
||||
border-top: 1px solid var(--rc-border);
|
||||
padding: 8px;
|
||||
background: var(--rc-bg-panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rc-composer-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--rc-text-secondary);
|
||||
padding: 2px 4px 6px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.rc-composer-hint[data-tone="warning"] {
|
||||
color: var(--rc-waiting);
|
||||
}
|
||||
|
||||
.rc-composer-hint[data-tone="danger"] {
|
||||
color: var(--rc-danger);
|
||||
}
|
||||
|
||||
.rc-composer-target {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--rc-accent-soft);
|
||||
color: var(--rc-accent);
|
||||
}
|
||||
|
||||
/* ── 命令确认卡 / 干预卡 ──────────────────────────────────────────────── */
|
||||
|
||||
.rc-intervention-card {
|
||||
border: 1px solid rgba(217, 119, 6, 0.45);
|
||||
border-radius: var(--rc-card-radius);
|
||||
background: rgba(217, 119, 6, 0.05);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── 报告卡 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-report-card {
|
||||
border: 1px solid rgba(22, 163, 74, 0.4);
|
||||
border-radius: var(--rc-card-radius);
|
||||
background: rgba(22, 163, 74, 0.04);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── 历史侧栏 Tab ─────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
|
||||
.rc-tab {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--rc-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.rc-tab[data-active="true"] {
|
||||
background: var(--rc-accent-soft);
|
||||
color: var(--rc-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 窄屏单面板切换 ───────────────────────────────────────────────────── */
|
||||
|
||||
.rc-narrow-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--rc-border);
|
||||
background: var(--rc-bg-secondary);
|
||||
}
|
||||
|
||||
.rc-narrow-switch button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--rc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rc-narrow-switch button[data-active="true"] {
|
||||
background: var(--rc-bg-panel);
|
||||
color: var(--rc-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--rc-panel-shadow);
|
||||
}
|
||||
|
||||
/* ── 空态 / 加载 ──────────────────────────────────────────────────────── */
|
||||
|
||||
.rc-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
color: var(--rc-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.rc-spin {
|
||||
animation: rc-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rc-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 无障碍:减少动效 ─────────────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rc-edge-active {
|
||||
animation: none;
|
||||
}
|
||||
.rc-drawer {
|
||||
animation: none;
|
||||
}
|
||||
.rc-spin {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
.rc-resizer::after,
|
||||
.rc-btn,
|
||||
.rc-plan-card,
|
||||
.rc-agent-tab,
|
||||
.rc-flow-node {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CollaborationEvent, SessionSnapshot } from "../api/types";
|
||||
import {
|
||||
applyCollaborationEvents,
|
||||
applySessionSnapshot,
|
||||
createInitialCollaborationState,
|
||||
hasSeqGap,
|
||||
} from "../state/event-reducer";
|
||||
|
||||
const SESSION = "session_test";
|
||||
const RUN = "run_test";
|
||||
|
||||
function ev(seq: number, type: CollaborationEvent["type"], data: unknown): CollaborationEvent {
|
||||
return {
|
||||
eventId: `evt_${seq}`,
|
||||
sessionId: SESSION,
|
||||
runId: RUN,
|
||||
seq,
|
||||
type,
|
||||
timestamp: "2026-09-02T10:00:00Z",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
return {
|
||||
session: {
|
||||
id: SESSION,
|
||||
title: "测试会话",
|
||||
status: "empty",
|
||||
requirement_revision: 1,
|
||||
selected_plan_id: null,
|
||||
active_run_id: RUN,
|
||||
current_report_version_id: null,
|
||||
created_at: "2026-09-02T10:00:00Z",
|
||||
updated_at: "2026-09-02T10:00:00Z",
|
||||
},
|
||||
messages: [],
|
||||
plans: [],
|
||||
run: {
|
||||
id: RUN,
|
||||
session_id: SESSION,
|
||||
plan_id: "",
|
||||
status: "running",
|
||||
cancel_requested: false,
|
||||
next_event_seq: 1,
|
||||
created_at: "2026-09-02T10:00:00Z",
|
||||
updated_at: "2026-09-02T10:00:00Z",
|
||||
},
|
||||
nodes: [],
|
||||
agent_runs: [],
|
||||
commands: [],
|
||||
report_versions: [],
|
||||
last_seq: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("重复事件按 seq 去重,不产生重复消息", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
const created = ev(1, "message.created", {
|
||||
message: {
|
||||
message_id: "m1",
|
||||
role: "ai",
|
||||
content: "",
|
||||
agent_run_id: "a1",
|
||||
visibility: "visible",
|
||||
created_at: "2026-09-02T10:00:01Z",
|
||||
completed_at: null,
|
||||
},
|
||||
});
|
||||
state = applyCollaborationEvents(state, [created, created]);
|
||||
assert.equal(state.messageOrder.length, 1);
|
||||
assert.equal(state.messagesById.m1.content, "");
|
||||
});
|
||||
|
||||
test("message.delta 多次更新同一 messageId,completed 收尾", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "message.created", {
|
||||
message: {
|
||||
message_id: "m1",
|
||||
role: "ai",
|
||||
content: "",
|
||||
agent_run_id: "a1",
|
||||
visibility: "visible",
|
||||
created_at: "2026-09-02T10:00:01Z",
|
||||
completed_at: null,
|
||||
},
|
||||
}),
|
||||
ev(2, "message.delta", { message_id: "m1", agent_run_id: "a1", delta: "你" }),
|
||||
ev(3, "message.delta", { message_id: "m1", agent_run_id: "a1", delta: "好" }),
|
||||
ev(4, "message.completed", { message_id: "m1" }),
|
||||
]);
|
||||
assert.equal(state.messagesById.m1.content, "你好");
|
||||
assert.ok(state.messagesById.m1.completed_at);
|
||||
assert.equal(state.lastSeq, 4);
|
||||
});
|
||||
|
||||
test("乱序事件先缓冲,缺口补齐后按序 drain", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
// seq=2 先到 → 缓冲并出现缺口
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(2, "message.delta", { message_id: "m1", delta: "B" }),
|
||||
]);
|
||||
assert.equal(state.messagesById.m1, undefined);
|
||||
assert.equal(hasSeqGap(state), true);
|
||||
// seq=1 到达 → 1、2 依序应用
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "message.created", {
|
||||
message: {
|
||||
message_id: "m1",
|
||||
role: "ai",
|
||||
content: "A",
|
||||
visibility: "visible",
|
||||
created_at: "2026-09-02T10:00:01Z",
|
||||
completed_at: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.equal(state.messagesById.m1.content, "AB");
|
||||
assert.equal(hasSeqGap(state), false);
|
||||
});
|
||||
|
||||
test("工具调用挂在所属 ai 消息,返回以 tool 消息配对 tool_call_id", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "tool_call.created", {
|
||||
message_id: "ai_owner",
|
||||
agent_run_id: "a1",
|
||||
tool_call: { id: "tc1", name: "web_search", args: "" },
|
||||
}),
|
||||
ev(2, "tool_call.delta", { message_id: "ai_owner", tool_call_id: "tc1", args_delta: '{"q":"' }),
|
||||
ev(3, "tool_call.delta", { message_id: "ai_owner", tool_call_id: "tc1", args_delta: '市场"}' }),
|
||||
ev(4, "tool_result.created", {
|
||||
message_id: "tr1",
|
||||
tool_call_id: "tc1",
|
||||
name: "web_search",
|
||||
agent_run_id: "a1",
|
||||
content: '{"count":8}',
|
||||
}),
|
||||
]);
|
||||
const owner = state.messagesById.ai_owner;
|
||||
assert.equal(owner.role, "ai");
|
||||
assert.equal(owner.tool_calls?.[0].id, "tc1");
|
||||
assert.equal(owner.tool_calls?.[0].args, '{"q":"市场"}');
|
||||
const toolMessage = state.messagesById.tr1;
|
||||
assert.equal(toolMessage.role, "tool");
|
||||
assert.equal(toolMessage.tool_call_id, "tc1");
|
||||
assert.equal(state.messageOrder[0], "ai_owner");
|
||||
assert.equal(state.messageOrder[1], "tr1");
|
||||
});
|
||||
|
||||
test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "node.created", {
|
||||
node: {
|
||||
node_id: "n1",
|
||||
label: "研究",
|
||||
status: "planned",
|
||||
attempt: 0,
|
||||
attempts: [],
|
||||
},
|
||||
}),
|
||||
ev(2, "node.status.changed", { node_id: "n1", status: "running", attempt: 1 }),
|
||||
// §5.5:running 经 cancel_requested 再到 cancelled
|
||||
ev(3, "node.status.changed", { node_id: "n1", status: "cancel_requested", attempt: 1 }),
|
||||
ev(4, "node.status.changed", { node_id: "n1", status: "cancelled", attempt: 1 }),
|
||||
// 取消后的晚到输出不得改写终态
|
||||
ev(5, "node.status.changed", { node_id: "n1", status: "validating", attempt: 1 }),
|
||||
ev(6, "node.status.changed", { node_id: "n1", status: "completed", attempt: 1 }),
|
||||
]);
|
||||
assert.equal(state.nodes.n1.status, "cancelled");
|
||||
// completed -> reopened -> queued 合法
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(7, "node.status.changed", { node_id: "n2", status: "completed", attempt: 1 }),
|
||||
ev(8, "node.status.changed", { node_id: "n2", status: "reopened", attempt: 1 }),
|
||||
ev(9, "node.status.changed", { node_id: "n2", status: "queued", attempt: 2 }),
|
||||
]);
|
||||
assert.equal(state.nodes.n2.status, "queued");
|
||||
});
|
||||
|
||||
test("协助卡出现(awaiting_input)不等于完成;回到 queued 由状态事件驱动", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "node.created", {
|
||||
node: { node_id: "n1", label: "研究", status: "running", attempt: 1, attempts: [] },
|
||||
}),
|
||||
ev(2, "node.status.changed", {
|
||||
node_id: "n1",
|
||||
status: "awaiting_input",
|
||||
attempt: 1,
|
||||
awaiting_input: { reason: "需要补充范围", question: "覆盖哪些品牌?" },
|
||||
}),
|
||||
]);
|
||||
assert.equal(state.nodes.n1.status, "awaiting_input");
|
||||
assert.equal(state.nodes.n1.awaiting_input?.question, "覆盖哪些品牌?");
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(3, "node.status.changed", { node_id: "n1", status: "queued", attempt: 1 }),
|
||||
]);
|
||||
assert.equal(state.nodes.n1.status, "queued");
|
||||
assert.equal(state.nodes.n1.awaiting_input, null);
|
||||
});
|
||||
|
||||
test("并行成员事件按 agentRunId 各自更新,互不影响", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "message.created", {
|
||||
message: {
|
||||
message_id: "m_market",
|
||||
role: "ai",
|
||||
content: "",
|
||||
agent_run_id: "a_market",
|
||||
visibility: "visible",
|
||||
created_at: "t",
|
||||
completed_at: null,
|
||||
},
|
||||
}),
|
||||
ev(2, "message.created", {
|
||||
message: {
|
||||
message_id: "m_brand",
|
||||
role: "ai",
|
||||
content: "",
|
||||
agent_run_id: "a_brand",
|
||||
visibility: "visible",
|
||||
created_at: "t",
|
||||
completed_at: null,
|
||||
},
|
||||
}),
|
||||
ev(3, "message.delta", { message_id: "m_market", agent_run_id: "a_market", delta: "市场" }),
|
||||
ev(4, "message.delta", { message_id: "m_brand", agent_run_id: "a_brand", delta: "品牌" }),
|
||||
ev(5, "message.completed", { message_id: "m_market" }),
|
||||
]);
|
||||
assert.equal(state.messagesById.m_market.content, "市场");
|
||||
assert.equal(state.messagesById.m_brand.content, "品牌");
|
||||
assert.ok(state.messagesById.m_market.completed_at);
|
||||
assert.equal(state.messagesById.m_brand.completed_at, null);
|
||||
});
|
||||
|
||||
test("乐观用户消息由同 idempotency_key 的服务器镜像原地替换", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "message.created", {
|
||||
message: {
|
||||
message_id: "pending:key-1",
|
||||
role: "human",
|
||||
content: "写报告",
|
||||
visibility: "visible",
|
||||
metadata: { optimistic: true, idempotency_key: "key-1" },
|
||||
created_at: "t",
|
||||
completed_at: "t",
|
||||
},
|
||||
}),
|
||||
ev(2, "message.created", {
|
||||
message: {
|
||||
message_id: "srv-1",
|
||||
role: "human",
|
||||
content: "写报告",
|
||||
visibility: "visible",
|
||||
metadata: { idempotency_key: "key-1" },
|
||||
created_at: "t",
|
||||
completed_at: "t",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.equal(state.messageOrder.length, 1);
|
||||
assert.equal(state.messageOrder[0], "srv-1");
|
||||
assert.equal(state.messagesById["pending:key-1"], undefined);
|
||||
});
|
||||
|
||||
test("其他 session / 旧 run 的晚到事件被丢弃", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
const foreignSession = { ...ev(1, "message.created", { message: { message_id: "x" } }) };
|
||||
foreignSession.sessionId = "session_other";
|
||||
const foreignRun = ev(2, "message.created", {
|
||||
message: {
|
||||
message_id: "y",
|
||||
role: "ai",
|
||||
content: "",
|
||||
visibility: "visible",
|
||||
created_at: "t",
|
||||
completed_at: null,
|
||||
},
|
||||
});
|
||||
foreignRun.runId = "run_old";
|
||||
state = applyCollaborationEvents(state, [foreignSession, foreignRun]);
|
||||
assert.equal(state.messageOrder.length, 0);
|
||||
assert.equal(state.lastSeq, 0);
|
||||
});
|
||||
|
||||
test("plan.proposed 替换方案组;plan.selected 记录选定且不改执行", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
const plan = {
|
||||
id: "plan-1",
|
||||
proposal_group_id: "g1",
|
||||
title: "方案一",
|
||||
strategy: "balanced_review",
|
||||
summary: "",
|
||||
rationale: "",
|
||||
estimated_duration_seconds: 60,
|
||||
estimated_cost_level: "medium" as const,
|
||||
requirement_revision: 1,
|
||||
roles: [],
|
||||
nodes: [],
|
||||
edges: [],
|
||||
quality_gates: [],
|
||||
report_structure_id: null,
|
||||
validation: { ok: true, errors: [] },
|
||||
revision: 1,
|
||||
status: "proposed" as const,
|
||||
};
|
||||
state = applyCollaborationEvents(state, [ev(1, "plan.proposed", { plans: [plan] })]);
|
||||
assert.equal(state.plans.length, 1);
|
||||
assert.equal(state.selectedPlanId, null);
|
||||
state = applyCollaborationEvents(state, [ev(2, "plan.selected", { plan_id: "plan-1" })]);
|
||||
assert.equal(state.selectedPlanId, "plan-1");
|
||||
assert.equal(state.plans[0].status, "selected");
|
||||
assert.equal(state.run?.status, "running"); // 选择不触发执行
|
||||
});
|
||||
|
||||
test("report.delta 累积草稿,version.created 后草稿清空", () => {
|
||||
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(1, "report.delta", { draft_id: "d1", delta: "# 报" }),
|
||||
ev(2, "report.delta", { draft_id: "d1", delta: "告" }),
|
||||
]);
|
||||
assert.equal(state.reportDraft?.markdown, "# 报告");
|
||||
state = applyCollaborationEvents(state, [
|
||||
ev(3, "report.version.created", {
|
||||
version: {
|
||||
id: "v1",
|
||||
session_id: SESSION,
|
||||
version: 1,
|
||||
parent_version_id: null,
|
||||
change_type: "initial",
|
||||
markdown: "# 报告",
|
||||
created_at: "t",
|
||||
is_head: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.equal(state.reportDraft, null);
|
||||
assert.equal(state.reportVersions.length, 1);
|
||||
assert.equal(state.session?.current_report_version_id, "v1");
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { groupMessages } from "../../core/messages/utils";
|
||||
import {
|
||||
buildCommandConfirmCard,
|
||||
buildPlanGroupCard,
|
||||
buildBaseStreamFacade,
|
||||
toLangGraphMessages,
|
||||
} from "../state/message-adapter";
|
||||
import { selectMessagesForAgentRun } from "../state/selectors";
|
||||
import type { CollaborationMessage } from "../api/types";
|
||||
|
||||
function human(id: string, content: string): CollaborationMessage {
|
||||
return { message_id: id, role: "human", content, visibility: "visible", created_at: "t", completed_at: "t" };
|
||||
}
|
||||
|
||||
function ai(id: string, agentRunId: string | null, content: string): CollaborationMessage {
|
||||
return {
|
||||
message_id: id,
|
||||
role: "ai",
|
||||
content,
|
||||
agent_run_id: agentRunId,
|
||||
name: agentRunId === "a_brand" ? "品牌竞争分析师" : "协调者",
|
||||
visibility: "visible",
|
||||
created_at: "t",
|
||||
completed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("一对一映射:human/ai/tool 保持原文与归属标记", () => {
|
||||
const toolMessage: CollaborationMessage = {
|
||||
message_id: "t1",
|
||||
role: "tool",
|
||||
content: '{"count":8}',
|
||||
name: "web_search",
|
||||
tool_call_id: "tc1",
|
||||
agent_run_id: "a_brand",
|
||||
visibility: "visible",
|
||||
created_at: "t",
|
||||
completed_at: "t",
|
||||
};
|
||||
const messages = toLangGraphMessages([
|
||||
human("h1", "写报告"),
|
||||
{ ...ai("a1", "a_brand", ""), tool_calls: [{ id: "tc1", name: "web_search", args: { q: "市场" } }] },
|
||||
toolMessage,
|
||||
]);
|
||||
assert.equal(messages[0].type, "human");
|
||||
assert.equal(messages[0].content, "写报告");
|
||||
assert.equal(messages[1].type, "ai");
|
||||
const aiMessage = messages[1] as { tool_calls?: { id: string; name: string }[] };
|
||||
assert.equal(aiMessage.tool_calls?.[0].name, "web_search");
|
||||
assert.equal(messages[2].type, "tool");
|
||||
assert.equal((messages[2] as { tool_call_id?: string }).tool_call_id, "tc1");
|
||||
const kwargs = (messages[1] as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
|
||||
?.report_collaboration as { agentRunId?: string } | undefined;
|
||||
assert.equal(kwargs?.agentRunId, "a_brand");
|
||||
});
|
||||
|
||||
test("并行聚焦:共享 human + 无归属 + 目标成员,其余成员被过滤", () => {
|
||||
const messages = [
|
||||
human("h1", "开始"),
|
||||
ai("c1", null, "我先分派任务"),
|
||||
ai("m1", "a_market", "市场规模结论……"),
|
||||
ai("b1", "a_brand", "品牌竞争结论……"),
|
||||
];
|
||||
const focusedBrand = selectMessagesForAgentRun(messages, "a_brand");
|
||||
assert.deepEqual(
|
||||
focusedBrand.map((message) => message.message_id),
|
||||
["h1", "c1", "b1"],
|
||||
);
|
||||
// null = 全量视图(单成员 / 协调者阶段)
|
||||
assert.equal(selectMessagesForAgentRun(messages, null).length, 4);
|
||||
});
|
||||
|
||||
test("业务卡虚拟消息经 groupMessages 识别为 assistant:business-card(集中式扩展)", () => {
|
||||
const card = buildCommandConfirmCard({
|
||||
command_id: "cmd1",
|
||||
idempotency_key: "k",
|
||||
text: "重新检索",
|
||||
intent: "research_again",
|
||||
target_ids: [],
|
||||
requires_confirmation: true,
|
||||
status: "confirmation_required",
|
||||
created_at: "t",
|
||||
});
|
||||
const types = groupMessages([card], (group) => group.type);
|
||||
assert.deepEqual(types, ["assistant:business-card"]);
|
||||
});
|
||||
|
||||
test("方案组卡可被 groupMessages 识别且不产生正文气泡", () => {
|
||||
const card = buildPlanGroupCard(
|
||||
[
|
||||
{
|
||||
id: "p1",
|
||||
proposal_group_id: "g1",
|
||||
title: "方案",
|
||||
strategy: "balanced_review",
|
||||
summary: "",
|
||||
rationale: "",
|
||||
estimated_duration_seconds: 1,
|
||||
estimated_cost_level: "low",
|
||||
requirement_revision: 1,
|
||||
roles: [],
|
||||
nodes: [],
|
||||
edges: [],
|
||||
quality_gates: [],
|
||||
report_structure_id: null,
|
||||
validation: { ok: true, errors: [] },
|
||||
revision: 1,
|
||||
status: "proposed",
|
||||
},
|
||||
],
|
||||
null,
|
||||
1,
|
||||
);
|
||||
const types = groupMessages([card], (group) => group.type);
|
||||
assert.deepEqual(types, ["assistant:business-card"]);
|
||||
});
|
||||
|
||||
test("BaseStream 外观提供 MessageList 所需字段", () => {
|
||||
const thread = buildBaseStreamFacade([], true);
|
||||
assert.deepEqual(thread.messages, []);
|
||||
assert.equal(thread.isLoading, true);
|
||||
assert.equal(thread.isThreadLoading, false);
|
||||
assert.equal(typeof thread.stop, "function");
|
||||
});
|
||||
@@ -268,14 +268,17 @@ export default function BusinessChainListPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 self-start md:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/strategy/business-chain-mapping")}
|
||||
className="flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-xs font-bold text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:scale-95 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]"
|
||||
>
|
||||
<SlidersHorizontalIcon className="size-4" />
|
||||
<span>配置管理</span>
|
||||
</button>
|
||||
{/* 配置管理(业务映射)目标路由仅放行管理员(非管理员会被重定向到问答页),入口同步只对管理员显示。 */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/strategy/business-chain-mapping")}
|
||||
className="flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-xs font-bold text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:scale-95 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]"
|
||||
>
|
||||
<SlidersHorizontalIcon className="size-4" />
|
||||
<span>配置管理</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goConfigure()}
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface DeepResearchSession {
|
||||
usage?: Record<string, unknown>;
|
||||
activeJobId?: string;
|
||||
error?: string;
|
||||
/** Durable backend execution trace for production incident diagnosis. */
|
||||
runtimeDiagnostics?: Record<string, unknown>;
|
||||
/** The real LangGraph thread the collector agent chats on (chat collection). */
|
||||
runtimeThreadId?: string;
|
||||
createdAt?: string;
|
||||
@@ -219,6 +221,11 @@ function normalizeSession(row: WireRecord): DeepResearchSession {
|
||||
usage: (pick(row, "usage", "usage_snapshot") ?? undefined) as Record<string, unknown> | undefined,
|
||||
activeJobId: pick<string>(row, "activeJobId", "active_job_id"),
|
||||
error: typeof row.error === "string" ? row.error : undefined,
|
||||
runtimeDiagnostics: pick<Record<string, unknown>>(
|
||||
row,
|
||||
"runtimeDiagnostics",
|
||||
"runtime_diagnostics",
|
||||
),
|
||||
runtimeThreadId: pick<string>(row, "runtimeThreadId", "runtime_thread_id"),
|
||||
createdAt: pick<string>(row, "createdAt", "created_at"),
|
||||
updatedAt: pick<string>(row, "updatedAt", "updated_at"),
|
||||
@@ -345,6 +352,8 @@ export interface StartJobOptions {
|
||||
entry?: "chat" | "legacy" | "regenerate";
|
||||
/** Report-config card choices merged over the frozen session snapshot. */
|
||||
config?: Record<string, unknown>;
|
||||
/** Compact collector evidence frozen before the report job starts. */
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export async function startJob(
|
||||
@@ -360,6 +369,7 @@ export async function startJob(
|
||||
request_id: opts.requestId ?? createClientId(),
|
||||
entry: opts.entry ?? "legacy",
|
||||
config: opts.config ?? null,
|
||||
collector_materials: opts.collectorMaterials ?? null,
|
||||
}),
|
||||
});
|
||||
return readJson(res, "startJob");
|
||||
@@ -1000,11 +1010,13 @@ export function useResearchActions() {
|
||||
sessionId,
|
||||
entry,
|
||||
config,
|
||||
collectorMaterials,
|
||||
}: {
|
||||
sessionId: string;
|
||||
entry?: "chat" | "legacy" | "regenerate";
|
||||
config?: Record<string, unknown>;
|
||||
}) => startJob(sessionId, { entry, config }),
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
}) => startJob(sessionId, { entry, config, collectorMaterials }),
|
||||
onSuccess: (_result, { sessionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
|
||||
|
||||
Reference in new issue
Block a user