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: "企业深度研究",
|
||||
|
||||
Reference in new issue
Block a user