Rewrite oklch/color-mix and space-separated rgb before iframe preview so pie and bar fills are not dropped on Chrome 109. Co-authored-by: Cursor <cursoragent@cursor.com>
2223 lines
84 KiB
TypeScript
2223 lines
84 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowDownIcon,
|
||
Code2Icon,
|
||
CopyIcon,
|
||
DownloadIcon,
|
||
ExternalLinkIcon,
|
||
EyeIcon,
|
||
FileType2Icon,
|
||
LoaderIcon,
|
||
PackageIcon,
|
||
SaveIcon,
|
||
SparklesIcon,
|
||
UploadCloudIcon,
|
||
XIcon,
|
||
} from "lucide-react";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { useParams } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||
import { toast } from "sonner";
|
||
import { defaultUrlTransform } from "react-markdown";
|
||
import { Streamdown } from "streamdown";
|
||
|
||
import {
|
||
Artifact,
|
||
ArtifactAction,
|
||
ArtifactActions,
|
||
ArtifactContent,
|
||
ArtifactHeader,
|
||
ArtifactTitle,
|
||
} from "@/components/ai-elements/artifact";
|
||
import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog";
|
||
import { Select, SelectItem } from "@/components/ui/select";
|
||
import {
|
||
SelectContent,
|
||
SelectGroup,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
||
import {
|
||
useCapabilities as useResearchCapabilities,
|
||
useResearchSources,
|
||
type DeepResearchSource,
|
||
} from "@/strategy-components/api/deep-research";
|
||
import {
|
||
listDurableDocumentRewrites,
|
||
streamDurableDocumentRewriteJob,
|
||
type DocumentRewriteSummary,
|
||
type DocumentRewriteStreamEvent,
|
||
} from "@/open-canvas/api/rewrite";
|
||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||
import { useAgent } from "@/core/agents/hooks";
|
||
import { apiFetch } from "@/core/api";
|
||
import { copyTextToClipboard } from "@/core/clipboard";
|
||
import {
|
||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||
useDocumentRewritePresentation,
|
||
} from "@/core/document-rewrite/context";
|
||
import {
|
||
clearArtifactRewriteFallback,
|
||
completeArtifactRewriteFallback,
|
||
primeArtifactRewriteFallback,
|
||
} from "@/core/document-rewrite/local-fallback";
|
||
import {
|
||
downloadArtifactFile,
|
||
} from "@/core/artifacts/download";
|
||
import {
|
||
useArtifactContent,
|
||
useUpdateArtifactContent,
|
||
} from "@/core/artifacts/hooks";
|
||
import { isWriteFileInFlight } from "@/core/artifacts/write-file-in-flight";
|
||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||
import {
|
||
isSandboxStreamPerfEnabled,
|
||
useSandboxFollowScroll,
|
||
} from "@/core/artifacts/sandbox-stream-perf";
|
||
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 {
|
||
checkCodeFile,
|
||
getFileName,
|
||
isImageFile,
|
||
} from "@/core/utils/files";
|
||
import {
|
||
downloadMermaidSvgFile,
|
||
isMermaidFile,
|
||
openMermaidSvgInNewTab,
|
||
renderMermaidToSvg,
|
||
} from "@/core/utils/mermaid";
|
||
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||
import {
|
||
createMarkdownAnchorResolver,
|
||
isInPageAnchor,
|
||
scrollToInPageAnchor,
|
||
} from "@/core/utils/markdown";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
import { env } from "@/env";
|
||
import { desensitize } from "@/lib/desensitize";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { ArtifactLink } from "../citations/artifact-link";
|
||
import { useThread } from "../messages/context";
|
||
import { Tooltip } from "../tooltip";
|
||
|
||
import { useArtifacts } from "./context";
|
||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||
|
||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||
const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi;
|
||
const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i;
|
||
|
||
/** Keep durable source ids in Markdown while presenting plain numeric citations. */
|
||
function formatResearchCitationsForPreview(
|
||
content: string,
|
||
sources: DeepResearchSource[],
|
||
): string {
|
||
const citationNumbers = new Map(
|
||
sources.map((source, index) => [source.id, index + 1]),
|
||
);
|
||
let nextCitationNumber = citationNumbers.size + 1;
|
||
|
||
return preserveMarkdownStandaloneLines(
|
||
content
|
||
.replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => {
|
||
const sourceId = legacyId || displayId;
|
||
let citationNumber = citationNumbers.get(sourceId);
|
||
if (!citationNumber) {
|
||
citationNumber = nextCitationNumber;
|
||
nextCitationNumber += 1;
|
||
citationNumbers.set(sourceId, citationNumber);
|
||
}
|
||
return `[${citationNumber}]`;
|
||
})
|
||
// Hide a citation token while it is still arriving over the live stream.
|
||
.replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, ""),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Markdown treats a single newline as a space. Cover fields and chapter
|
||
* headings like 「日期:」「第一章」「1.1」must be their own blocks in preview.
|
||
*/
|
||
function preserveMarkdownStandaloneLines(markdown: string): string {
|
||
const standalone =
|
||
/^(?:《.+》|(?:日期|类型|密级)\s*[::].+|第[一二三四五六七八九十百千0-9]+章\b.*|\d+\.\d+(?:\.\d+)?\s+\S+)$/;
|
||
const lines = markdown.split("\n");
|
||
const output: string[] = [];
|
||
for (const line of lines) {
|
||
const stripped = line.trim();
|
||
if (stripped && standalone.test(stripped) && output.length > 0 && output[output.length - 1]!.trim()) {
|
||
output.push("");
|
||
}
|
||
output.push(line);
|
||
}
|
||
return output.join("\n");
|
||
}
|
||
|
||
async function sha256Content(content: string): Promise<string | undefined> {
|
||
if (!globalThis.crypto?.subtle) return undefined;
|
||
const digest = await globalThis.crypto.subtle.digest(
|
||
"SHA-256",
|
||
new TextEncoder().encode(content),
|
||
);
|
||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
function extractTextFromChildren(children: ReactNode): string {
|
||
if (typeof children === "string" || typeof children === "number") return String(children);
|
||
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
|
||
return "";
|
||
}
|
||
|
||
/** 取 md 标题用于入库:优先文中第一个标题行,否则退回文件名(去扩展名)。 */
|
||
function extractMarkdownTitle(content: string, filepath: string): string {
|
||
const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
|
||
if (heading?.[1]) return heading[1].trim();
|
||
return getFileName(filepath).replace(/\.(md|markdown)$/i, "");
|
||
}
|
||
|
||
function findScrollableArtifactElement(root: HTMLElement | null) {
|
||
if (!root) {
|
||
return null;
|
||
}
|
||
|
||
const candidates = [
|
||
root.querySelector<HTMLElement>(".cm-scroller"),
|
||
root.querySelector<HTMLTextAreaElement>("textarea"),
|
||
root.querySelector<HTMLElement>("[data-artifact-preview-scroll]"),
|
||
root,
|
||
];
|
||
|
||
return (
|
||
candidates.find(
|
||
(element) =>
|
||
!!element && element.scrollHeight - element.clientHeight > 1,
|
||
) ?? root
|
||
);
|
||
}
|
||
|
||
export function ArtifactFileDetail({
|
||
className,
|
||
filepath: filepathFromProps,
|
||
threadId,
|
||
mimeType = null,
|
||
onClose,
|
||
allowVirtualMarkdownEdit = false,
|
||
disableDesensitize = false,
|
||
hideFileName = false,
|
||
onDownloadClick,
|
||
toolbarActions,
|
||
headerLeadingAction,
|
||
headerLeadingActionBare = false,
|
||
researchSessionId,
|
||
}: {
|
||
className?: string;
|
||
filepath: string;
|
||
threadId: string;
|
||
/** Server/tool supplied type wins over path guessing when available. */
|
||
mimeType?: string | null;
|
||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||
onClose?: () => void;
|
||
/**
|
||
* 圆桌沙箱(Step2/Step3)用:流式结束后,把 `write-file:` 虚拟 md 预览切回真实
|
||
* artifacts API(可加载 + PATCH 保存),从而支持用户编辑(对齐主聊天页 md 可编辑)。
|
||
* 默认 false —— 主聊天页等其它调用方行为不变。
|
||
*/
|
||
allowVirtualMarkdownEdit?: boolean;
|
||
/**
|
||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||
* 默认 false,沿用全局脱敏。
|
||
*/
|
||
disableDesensitize?: boolean;
|
||
hideFileName?: boolean;
|
||
/**
|
||
* 覆盖默认下载行为:传入后点击「下载」按钮不再走内置的各格式下载,而是回调宿主
|
||
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
|
||
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
|
||
*/
|
||
onDownloadClick?: () => void;
|
||
/** Deep-research reports use protected `deep-research://` image references. */
|
||
researchSessionId?: string;
|
||
/**
|
||
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
|
||
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
|
||
*/
|
||
toolbarActions?: ReactNode;
|
||
/** Replaces the left file-type badge for an embedded workspace action. */
|
||
headerLeadingAction?: ReactNode;
|
||
/**
|
||
* 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。
|
||
* 默认保留原有徽标容器,避免影响普通聊天和产物预览。
|
||
*/
|
||
headerLeadingActionBare?: boolean;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const { artifacts, setOpen, select } = useArtifacts();
|
||
const { isMock, thread, publicShareCode } = useThread();
|
||
const isWriteFileVirtual = useMemo(() => {
|
||
return filepathFromProps.startsWith("write-file:");
|
||
}, [filepathFromProps]);
|
||
// A streamed tool call can be opened before the path has been normalized by
|
||
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||
const writeFileLanguageHint = useMemo(() => {
|
||
if (!isWriteFileVirtual) return null;
|
||
try {
|
||
const language = new URL(filepathFromProps).searchParams.get("language");
|
||
return language === "markdown" || language === "html" || language === "mermaid"
|
||
? language
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}, [filepathFromProps, isWriteFileVirtual]);
|
||
const mimeLanguageHint = useMemo(() => {
|
||
const normalized = mimeType?.toLowerCase().trim() ?? "";
|
||
if (normalized.includes("markdown")) return "markdown";
|
||
if (normalized.includes("html")) return "html";
|
||
if (normalized.includes("mermaid")) return "mermaid";
|
||
if (normalized.startsWith("text/")) return "text";
|
||
return null;
|
||
}, [mimeType]);
|
||
const filepath = useMemo(() => {
|
||
if (isWriteFileVirtual) {
|
||
const url = new URL(filepathFromProps);
|
||
return decodeURIComponent(url.pathname).trim();
|
||
}
|
||
// Tool arguments may contain harmless surrounding whitespace. Passing it
|
||
// into extension detection turns `report.md ` into a generic TEXT file.
|
||
return filepathFromProps.trim();
|
||
}, [filepathFromProps, isWriteFileVirtual]);
|
||
const isSkillFile = useMemo(() => {
|
||
return filepath.endsWith(".skill");
|
||
}, [filepath]);
|
||
// .xlsx is a ZIP archive — the generic binary iframe preview renders its
|
||
// raw bytes as mojibake, so it gets a dedicated table preview instead.
|
||
const isSpreadsheetFile = useMemo(() => {
|
||
return /\.xlsx$/i.test(filepath);
|
||
}, [filepath]);
|
||
// 仅当「当前这个文件」的 write_file 还没返回 tool result 时才算在写。
|
||
// 不能用 thread.isLoading:用户协助后的下一轮问答也会 loading,否则会把已写完的
|
||
// md 从 CodeMirror 拽回巨大 textarea,主线程卡住、聊天变成「发送中然后一口气出来」。
|
||
const writeFileInFlight = useMemo(() => {
|
||
if (!isWriteFileVirtual || isImageFile(filepath)) {
|
||
return false;
|
||
}
|
||
return isWriteFileInFlight(thread, filepathFromProps);
|
||
}, [filepath, filepathFromProps, isWriteFileVirtual, thread.messages]);
|
||
// 圆桌/主聊天沙箱:write_file 虚拟预览的 md 在该文件写完后切回真实 artifacts API
|
||
// (可加载 + PATCH 保存)。后续问答轮次的 loading 不再把预览拽回去。
|
||
const editVirtualMarkdown = useMemo(() => {
|
||
return (
|
||
allowVirtualMarkdownEdit &&
|
||
isWriteFileVirtual &&
|
||
!isImageFile(filepath) &&
|
||
!isMock &&
|
||
!writeFileInFlight &&
|
||
checkCodeFile(filepath).language === "markdown"
|
||
);
|
||
}, [
|
||
allowVirtualMarkdownEdit,
|
||
isWriteFileVirtual,
|
||
filepath,
|
||
isMock,
|
||
writeFileInFlight,
|
||
]);
|
||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
|
||
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
|
||
const useWriteFileStreaming = useMemo(() => {
|
||
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||
const { isCodeFile, language } = useMemo(() => {
|
||
if (useWriteFileStreaming) {
|
||
let nextLanguage =
|
||
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||
nextLanguage ??= "text";
|
||
return { isCodeFile: true, language: nextLanguage };
|
||
}
|
||
if (isSkillFile) {
|
||
return { isCodeFile: true, language: "markdown" };
|
||
}
|
||
const detected = checkCodeFile(filepath);
|
||
if (mimeLanguageHint) {
|
||
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||
}
|
||
return detected;
|
||
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||
const isSupportPreview = useMemo(() => {
|
||
return (
|
||
language === "html" ||
|
||
language === "markdown" ||
|
||
language === "mermaid"
|
||
);
|
||
}, [language]);
|
||
const {
|
||
content,
|
||
isApplyingEdit,
|
||
isLoading: isArtifactContentLoading,
|
||
error: artifactContentError,
|
||
refetch: refetchArtifactContent,
|
||
} = useArtifactContent({
|
||
threadId,
|
||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||
// 不会被旧的内存 stub 值覆盖回退。
|
||
filepath:
|
||
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||
enabled: isCodeFile && !useWriteFileStreaming,
|
||
});
|
||
|
||
const canEditArtifact =
|
||
language === "markdown" &&
|
||
!useWriteFileStreaming &&
|
||
!isSkillFile &&
|
||
!isMock;
|
||
// 「AI 写作」md 展示开关:在 源码/预览 旁多一个切换项,支持划词「AI 改写」。
|
||
// 深度研究等 write-file 虚拟预览没有 artifacts PATCH,但仍应提供该视图(本地 draft);
|
||
// 流式写入中不挂载,避免编辑器在内容加载期间白屏。
|
||
const canUseAiEditor =
|
||
language === "markdown" &&
|
||
!isSkillFile &&
|
||
!isMock &&
|
||
!writeFileInFlight &&
|
||
(canEditArtifact || useWriteFileStreaming);
|
||
const [draftContent, setDraftContent] = useState("");
|
||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||
const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false);
|
||
const [documentRewritePresentationId, setDocumentRewritePresentationId] = useState<string | null>(null);
|
||
const updateArtifact = useUpdateArtifactContent();
|
||
const queryClient = useQueryClient();
|
||
const documentRewrite = useDocumentRewrite();
|
||
const imageCapabilities = useResearchCapabilities();
|
||
const rewritePresentation = useDocumentRewritePresentation();
|
||
const hydratedRewriteScopeRef = useRef<string | null>(null);
|
||
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
|
||
presentationId: null,
|
||
plan: "",
|
||
});
|
||
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
|
||
presentationId: null,
|
||
thinking: "",
|
||
});
|
||
const documentRewriteActive = documentRewrite.isActive;
|
||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||
const displayContent = documentRewriteActive
|
||
? documentRewrite.run.draft
|
||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||
? draftContent
|
||
: (content ?? ""));
|
||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||
const sandboxDisplayText = useMemo(() => {
|
||
const raw = displayContent ?? "";
|
||
if (disableDesensitize) return raw;
|
||
if (streamPerfEnabled && isLiveStreaming) return raw;
|
||
return desensitize(raw);
|
||
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
|
||
const artifactContentPending =
|
||
isCodeFile &&
|
||
!useWriteFileStreaming &&
|
||
content === undefined &&
|
||
isArtifactContentLoading;
|
||
const artifactContentUnavailable =
|
||
isCodeFile &&
|
||
!useWriteFileStreaming &&
|
||
content === undefined &&
|
||
!isArtifactContentLoading;
|
||
const isAutoPreviewLanguage =
|
||
language === "html" || language === "markdown" || language === "mermaid";
|
||
const isPreviewStreaming =
|
||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||
const fileTypeLabel = useMemo(() => {
|
||
if (isImageFile(filepath)) return "IMG";
|
||
if (isSpreadsheetFile) return "XLSX";
|
||
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
|
||
if (language === "html") return "HTML";
|
||
if (language === "markdown") return "MD";
|
||
if (isSkillFile) return "SKILL";
|
||
if (language) return language.toUpperCase();
|
||
return "FILE";
|
||
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
|
||
|
||
const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code");
|
||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
|
||
null,
|
||
);
|
||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!documentRewriteActive) return;
|
||
setAllowAutoViewSwitch(false);
|
||
setAutoScrollEnabled(true);
|
||
setShowResumeAutoScroll(false);
|
||
setViewMode("code");
|
||
}, [documentRewriteActive]);
|
||
|
||
useEffect(() => {
|
||
if (viewMode !== "edit" || canUseAiEditor) return;
|
||
setViewMode(isSupportPreview ? "preview" : "code");
|
||
}, [canUseAiEditor, isSupportPreview, viewMode]);
|
||
|
||
useEffect(() => {
|
||
setDraftContent(content ?? "");
|
||
setHasLocalEdits(false);
|
||
}, [filepathFromProps]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
(!canEditArtifact && !canUseAiEditor) ||
|
||
hasLocalEdits ||
|
||
documentRewriteActive
|
||
) {
|
||
return;
|
||
}
|
||
setDraftContent(content ?? "");
|
||
}, [canEditArtifact, canUseAiEditor, content, documentRewriteActive, hasLocalEdits]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!canEditArtifact ||
|
||
!threadId ||
|
||
!filepath ||
|
||
isMock ||
|
||
useWriteFileStreaming ||
|
||
documentRewriteActive ||
|
||
content === undefined ||
|
||
artifactContentError
|
||
) {
|
||
return;
|
||
}
|
||
const scope = `${threadId}:${filepath}`;
|
||
if (hydratedRewriteScopeRef.current === scope) return;
|
||
hydratedRewriteScopeRef.current = scope;
|
||
let disposed = false;
|
||
|
||
const updatePresentation = (
|
||
presentationId: string,
|
||
event: DocumentRewriteStreamEvent,
|
||
) => {
|
||
if (!rewritePresentation) return;
|
||
switch (event.type) {
|
||
case "snapshot_fixed":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "requirements",
|
||
fileName: event.fileName || getFileName(filepath),
|
||
originalCharCount: event.originalCharCount,
|
||
requirementsPlan: "",
|
||
});
|
||
break;
|
||
case "requirements_started":
|
||
rewritePresentation.update(presentationId, { stage: "requirements", requirementsPlan: "" });
|
||
break;
|
||
case "requirements_checkpoint":
|
||
case "requirements_completed":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "requirements",
|
||
requirementsPlan: event.content,
|
||
});
|
||
break;
|
||
case "requirements_resolved":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "writing",
|
||
...(event.plan ? { requirementsPlan: event.plan } : {}),
|
||
});
|
||
break;
|
||
case "rewrite_delta":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "writing",
|
||
writtenCharCount: event.charCount,
|
||
});
|
||
break;
|
||
case "illustration_started":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "illustrating",
|
||
illustrationTarget: event.section,
|
||
maxGeneratedImages: event.total || 1,
|
||
});
|
||
break;
|
||
case "illustration_generated":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "illustrating",
|
||
illustrationTarget: event.section,
|
||
generatedImageCount: event.generatedCount ?? event.position,
|
||
maxGeneratedImages: event.total || 1,
|
||
});
|
||
break;
|
||
case "illustration_completed":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "illustrating",
|
||
generatedImageCount: event.generatedCount,
|
||
maxGeneratedImages: event.requestedCount || 1,
|
||
warnings: event.warnings,
|
||
});
|
||
break;
|
||
case "validation_started":
|
||
rewritePresentation.update(presentationId, { stage: "validation" });
|
||
break;
|
||
case "validation_completed":
|
||
rewritePresentation.update(presentationId, { warnings: event.warnings });
|
||
break;
|
||
case "comparison_ready":
|
||
rewritePresentation.update(presentationId, { summary: event.summary });
|
||
break;
|
||
case "commit_started":
|
||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||
break;
|
||
case "committed":
|
||
rewritePresentation.update(presentationId, {
|
||
rewrittenContent: event.content,
|
||
versionId: event.versionId ?? null,
|
||
committedHash: event.committedHash,
|
||
});
|
||
break;
|
||
case "done":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "completed",
|
||
versionId: event.versionId ?? null,
|
||
});
|
||
break;
|
||
case "conflict":
|
||
rewritePresentation.update(presentationId, { stage: "conflict", error: event.message });
|
||
break;
|
||
case "cancelled":
|
||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||
break;
|
||
case "error":
|
||
console.error("AI 全文改写历史恢复失败:", event.message);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
void (async () => {
|
||
try {
|
||
const jobs = await listDurableDocumentRewrites({ threadId, path: filepath });
|
||
const latest = jobs[0];
|
||
if (disposed || !latest) return;
|
||
// Restore prior terminal rewrites into the ordinary message timeline in
|
||
// chronological order. The newest job is handled below because it may
|
||
// still be active and needs its SSE stream resumed.
|
||
for (const historical of jobs.slice(1).reverse()) {
|
||
const historicalResult = historical.result;
|
||
const historicalCommittedHash = typeof historicalResult.committedHash === "string"
|
||
? historicalResult.committedHash
|
||
: null;
|
||
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||
? historicalResult.summary as DocumentRewriteSummary
|
||
: null;
|
||
const historicalPresentationId = rewritePresentation?.create({
|
||
threadId,
|
||
source: { kind: "artifact", id: threadId, path: filepath },
|
||
fileName: getFileName(filepath),
|
||
instruction: historical.request.instruction,
|
||
style: historical.request.style,
|
||
modelName: historical.request.modelName,
|
||
generateImages: historical.request.generateImages,
|
||
maxGeneratedImages: historical.request.maxGeneratedImages,
|
||
originalContent: historical.request.originalContent,
|
||
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
||
committedHash: historicalCommittedHash,
|
||
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
||
? historicalResult.requirementsPlan
|
||
: "",
|
||
});
|
||
if (!historicalPresentationId || !rewritePresentation) continue;
|
||
rewritePresentation.update(historicalPresentationId, {
|
||
stage: historical.status === "completed"
|
||
? historicalResult.status === "conflict" ? "conflict" : "completed"
|
||
: historical.status === "cancelled" ? "cancelled" : "error",
|
||
summary: historicalSummary,
|
||
warnings: historicalSummary?.validationWarnings ?? [],
|
||
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||
error: historicalResult.status === "conflict"
|
||
? "文件在改写期间已变化,未覆盖原文件。"
|
||
: historical.status === "cancelled"
|
||
? "已停止全文改写,原文件未被修改。"
|
||
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
||
});
|
||
}
|
||
const result = latest.result;
|
||
const summary = result.summary && typeof result.summary === "object"
|
||
? result.summary as DocumentRewriteSummary
|
||
: null;
|
||
const originalContent = latest.request.originalContent || draftContent;
|
||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||
if (!versionSnapshotFailed) {
|
||
clearArtifactRewriteFallback(threadId, filepath);
|
||
}
|
||
const localFallbackAvailable =
|
||
versionSnapshotFailed &&
|
||
latest.status === "completed" &&
|
||
result.status !== "conflict" &&
|
||
committedHash !== null &&
|
||
completeArtifactRewriteFallback({
|
||
threadId,
|
||
path: filepath,
|
||
originalContent,
|
||
committedHash,
|
||
});
|
||
const presentationId = rewritePresentation?.create({
|
||
threadId,
|
||
source: { kind: "artifact", id: threadId, path: filepath },
|
||
fileName: getFileName(filepath),
|
||
instruction: latest.request.instruction,
|
||
style: latest.request.style,
|
||
modelName: latest.request.modelName,
|
||
generateImages: latest.request.generateImages,
|
||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||
originalContent,
|
||
rewrittenContent,
|
||
committedHash,
|
||
localFallbackAvailable,
|
||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||
});
|
||
if (!presentationId || !rewritePresentation) return;
|
||
setDocumentRewritePresentationId(presentationId);
|
||
|
||
if (latest.status === "completed") {
|
||
rewritePresentation.update(presentationId, {
|
||
stage: result.status === "conflict" ? "conflict" : "completed",
|
||
summary,
|
||
warnings: summary?.validationWarnings ?? [],
|
||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||
committedHash,
|
||
localFallbackAvailable,
|
||
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
||
});
|
||
if (rewrittenContent && result.status !== "conflict") {
|
||
setDraftContent(rewrittenContent);
|
||
setHasLocalEdits(versionSnapshotFailed);
|
||
if (versionSnapshotFailed) {
|
||
console.error(
|
||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||
);
|
||
}
|
||
queryClient.setQueriesData(
|
||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||
{ content: rewrittenContent, url: undefined },
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (latest.status === "failed" || latest.status === "cancelled") {
|
||
rewritePresentation.update(presentationId, {
|
||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||
summary,
|
||
});
|
||
return;
|
||
}
|
||
|
||
void documentRewrite.start(
|
||
{
|
||
threadId,
|
||
path: filepath,
|
||
instruction: latest.request.instruction,
|
||
style: latest.request.style,
|
||
modelName: latest.request.modelName ?? undefined,
|
||
generateImages: latest.request.generateImages,
|
||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||
},
|
||
{
|
||
jobId: latest.id,
|
||
stream: (signal) => streamDurableDocumentRewriteJob(
|
||
{ threadId, path: filepath },
|
||
latest.id,
|
||
signal,
|
||
),
|
||
onEvent: (event) => updatePresentation(presentationId, event),
|
||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||
setDraftContent(rewritten);
|
||
setHasLocalEdits(versionSnapshotFailed);
|
||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||
threadId,
|
||
path: filepath,
|
||
originalContent,
|
||
committedHash,
|
||
});
|
||
if (!versionSnapshotFailed) {
|
||
clearArtifactRewriteFallback(threadId, filepath);
|
||
}
|
||
if (versionSnapshotFailed) {
|
||
console.error(
|
||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||
);
|
||
}
|
||
rewritePresentation.update(presentationId, {
|
||
rewrittenContent: rewritten,
|
||
committedHash,
|
||
localFallbackAvailable,
|
||
});
|
||
queryClient.setQueriesData(
|
||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||
{ content: rewritten, url: undefined },
|
||
);
|
||
},
|
||
},
|
||
);
|
||
} catch {
|
||
// Existing artifacts remain available even if history hydration fails.
|
||
}
|
||
})();
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [
|
||
artifactContentError,
|
||
canEditArtifact,
|
||
content,
|
||
documentRewrite,
|
||
documentRewriteActive,
|
||
draftContent,
|
||
filepath,
|
||
isMock,
|
||
queryClient,
|
||
rewritePresentation,
|
||
threadId,
|
||
useWriteFileStreaming,
|
||
]);
|
||
|
||
// The stream hook already coalesces requirements tokens with rAF. Mirror the
|
||
// coalesced plan into the ordinary message-list card instead of forcing a
|
||
// React context write for every individual SSE delta.
|
||
useEffect(() => {
|
||
const presentationId = documentRewritePresentationId;
|
||
const plan = documentRewrite.run.requirementsPlan;
|
||
if (!presentationId || !rewritePresentation || !plan) {
|
||
return;
|
||
}
|
||
|
||
// `update` replaces the presentation context value, which changes the
|
||
// context object in this effect's dependency list. Only write when this
|
||
// particular card actually receives new text; otherwise React would
|
||
// re-enter this effect indefinitely after each context update.
|
||
const previous = mirroredRequirementsPlanRef.current;
|
||
if (previous.presentationId === presentationId && previous.plan === plan) {
|
||
return;
|
||
}
|
||
mirroredRequirementsPlanRef.current = { presentationId, plan };
|
||
rewritePresentation.update(presentationId, {
|
||
requirementsPlan: plan,
|
||
});
|
||
}, [
|
||
documentRewrite.run.requirementsPlan,
|
||
documentRewritePresentationId,
|
||
rewritePresentation,
|
||
]);
|
||
|
||
// Thoughts use the same rAF coalescing as the draft, then are mirrored once
|
||
// per browser frame into the regular message-list card. They never touch
|
||
// the artifact Markdown or the durable rewrite checkpoint.
|
||
useEffect(() => {
|
||
const presentationId = documentRewritePresentationId;
|
||
const thinking = documentRewrite.run.thinkingText;
|
||
if (!presentationId || !rewritePresentation || !thinking) return;
|
||
const previous = mirroredThinkingTextRef.current;
|
||
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
|
||
mirroredThinkingTextRef.current = { presentationId, thinking };
|
||
rewritePresentation.update(presentationId, { thinkingText: thinking });
|
||
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
|
||
|
||
useEffect(() => {
|
||
const handleLocalRewriteUndoGuard = (event: Event) => {
|
||
const detail = (event as CustomEvent<{
|
||
source?: { kind?: string; id?: string; path?: string };
|
||
rewrittenContent?: string | null;
|
||
unchanged?: boolean;
|
||
}>).detail;
|
||
if (
|
||
detail?.source?.kind !== "artifact" ||
|
||
detail.source.id !== threadId ||
|
||
detail.source.path !== filepath ||
|
||
typeof detail.rewrittenContent !== "string"
|
||
) {
|
||
return;
|
||
}
|
||
detail.unchanged = draftContent === detail.rewrittenContent;
|
||
};
|
||
window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
|
||
return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
|
||
}, [draftContent, filepath, threadId]);
|
||
|
||
useEffect(() => {
|
||
const handleRewriteUndo = (event: Event) => {
|
||
const detail = (event as CustomEvent<{
|
||
source?: { kind?: string; id?: string; path?: string };
|
||
content?: string;
|
||
}>).detail;
|
||
if (
|
||
detail?.source?.kind !== "artifact" ||
|
||
detail.source.id !== threadId ||
|
||
detail.source.path !== filepath ||
|
||
typeof detail.content !== "string"
|
||
) {
|
||
return;
|
||
}
|
||
setDraftContent(detail.content);
|
||
setHasLocalEdits(false);
|
||
queryClient.setQueriesData(
|
||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||
{ content: detail.content, url: undefined },
|
||
);
|
||
};
|
||
window.addEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo);
|
||
return () => window.removeEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo);
|
||
}, [filepath, isMock, queryClient, threadId]);
|
||
|
||
useEffect(() => {
|
||
setAllowAutoViewSwitch(true);
|
||
setAutoScrollEnabled(true);
|
||
setShowResumeAutoScroll(false);
|
||
setShowPreviewSwitchPulse(false);
|
||
}, [filepathFromProps]);
|
||
|
||
useEffect(() => {
|
||
if (isPreviewStreaming) {
|
||
setViewMode("code");
|
||
return;
|
||
}
|
||
|
||
if (language === "html") {
|
||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||
return;
|
||
}
|
||
|
||
if (isSupportPreview) {
|
||
setViewMode("preview");
|
||
return;
|
||
}
|
||
|
||
setViewMode("code");
|
||
}, [
|
||
documentRewriteActive,
|
||
isPreviewStreaming,
|
||
isSupportPreview,
|
||
language,
|
||
writeFileInFlight,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
||
return;
|
||
}
|
||
|
||
if (writeFileInFlight || documentRewriteActive) {
|
||
setViewMode("code");
|
||
return;
|
||
}
|
||
|
||
setViewMode("preview");
|
||
setShowPreviewSwitchPulse(true);
|
||
const timeoutId = window.setTimeout(() => {
|
||
setShowPreviewSwitchPulse(false);
|
||
}, 1400);
|
||
|
||
return () => window.clearTimeout(timeoutId);
|
||
}, [
|
||
allowAutoViewSwitch,
|
||
documentRewriteActive,
|
||
isAutoPreviewLanguage,
|
||
isSupportPreview,
|
||
writeFileInFlight,
|
||
]);
|
||
|
||
const scrollArtifactToBottom = useCallback(
|
||
(behavior: ScrollBehavior = "auto") => {
|
||
const target = findScrollableArtifactElement(artifactContentRef.current);
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
|
||
target.scrollTo({
|
||
top: target.scrollHeight,
|
||
behavior,
|
||
});
|
||
return true;
|
||
},
|
||
[],
|
||
);
|
||
|
||
useSandboxFollowScroll({
|
||
follow: isLiveStreaming && autoScrollEnabled,
|
||
scrollToBottom: scrollArtifactToBottom,
|
||
contentKey: sandboxDisplayText,
|
||
viewMode,
|
||
});
|
||
|
||
useEffect(() => {
|
||
const handleResumeEvent = () => {
|
||
setAutoScrollEnabled(true);
|
||
setShowResumeAutoScroll(false);
|
||
scrollArtifactToBottom("smooth");
|
||
};
|
||
|
||
window.addEventListener(SANDBOX_RESUME_EVENT, handleResumeEvent);
|
||
return () => {
|
||
window.removeEventListener(SANDBOX_RESUME_EVENT, handleResumeEvent);
|
||
};
|
||
}, [scrollArtifactToBottom]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (resumeButtonTimeoutRef.current) {
|
||
window.clearTimeout(resumeButtonTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isCodeFile || isWriteFileVirtual || !isArtifactApiPath(filepath)) {
|
||
setSpreadsheetData(null);
|
||
setBinaryPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return null;
|
||
});
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
try {
|
||
const res = await apiFetch(
|
||
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
|
||
);
|
||
if (cancelled || !res.ok) return;
|
||
if (isSpreadsheetFile) {
|
||
const buffer = await res.arrayBuffer();
|
||
if (!cancelled) {
|
||
setSpreadsheetData(buffer);
|
||
}
|
||
return;
|
||
}
|
||
const blob = await res.blob();
|
||
if (cancelled) return;
|
||
const u = URL.createObjectURL(blob);
|
||
setBinaryPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return u;
|
||
});
|
||
} catch (error) {
|
||
console.error("Artifact preview load failed:", error);
|
||
if (!cancelled) {
|
||
toast.error("Failed to load preview");
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
setSpreadsheetData(null);
|
||
setBinaryPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return null;
|
||
});
|
||
};
|
||
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
|
||
|
||
const handlePauseAutoScroll = useCallback(() => {
|
||
if (!isLiveStreaming || !autoScrollEnabled) {
|
||
return;
|
||
}
|
||
|
||
setAutoScrollEnabled(false);
|
||
setShowResumeAutoScroll(true);
|
||
}, [autoScrollEnabled, isLiveStreaming]);
|
||
|
||
const handleResumeAutoScroll = useCallback(() => {
|
||
setAutoScrollEnabled(true);
|
||
scrollArtifactToBottom("smooth");
|
||
setShowResumeAutoScroll(true);
|
||
|
||
if (resumeButtonTimeoutRef.current) {
|
||
window.clearTimeout(resumeButtonTimeoutRef.current);
|
||
}
|
||
|
||
resumeButtonTimeoutRef.current = window.setTimeout(() => {
|
||
setShowResumeAutoScroll(false);
|
||
}, 1600);
|
||
}, [scrollArtifactToBottom]);
|
||
|
||
const handleSaveArtifact = useCallback(async () => {
|
||
if (!canEditArtifact || updateArtifact.isPending || documentRewriteActive) {
|
||
return;
|
||
}
|
||
try {
|
||
await updateArtifact.mutateAsync({
|
||
filepath,
|
||
threadId,
|
||
isMock,
|
||
content: draftContent,
|
||
});
|
||
setHasLocalEdits(false);
|
||
toast.success("Markdown 已保存");
|
||
} catch (error) {
|
||
console.error("Failed to save artifact:", error);
|
||
// Keep the draft and leave the button enabled. The caller can retry when
|
||
// the artifact endpoint recovers; this must never reset visible text.
|
||
setHasLocalEdits(true);
|
||
}
|
||
}, [
|
||
canEditArtifact,
|
||
draftContent,
|
||
filepath,
|
||
isMock,
|
||
threadId,
|
||
updateArtifact,
|
||
documentRewriteActive,
|
||
]);
|
||
|
||
const handleStartDocumentRewrite = useCallback(
|
||
async (input: {
|
||
instruction: string;
|
||
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||
modelName: string | null;
|
||
reportOutline: string;
|
||
structureMode?: "fixed" | "adaptive";
|
||
generateImages: boolean;
|
||
maxGeneratedImages: number;
|
||
}) => {
|
||
if (!canEditArtifact) {
|
||
toast.error("当前文件暂不支持全文改写。");
|
||
return;
|
||
}
|
||
setDocumentRewriteDialogOpen(false);
|
||
setAllowAutoViewSwitch(false);
|
||
setAutoScrollEnabled(true);
|
||
setViewMode("code");
|
||
|
||
// Do not make users manually save before using AI rewrite. Persist their
|
||
// current draft first so the rewrite starts from exactly what they see,
|
||
// and the optimistic hash remains valid on the server.
|
||
if (hasLocalEdits) {
|
||
try {
|
||
await updateArtifact.mutateAsync({
|
||
filepath,
|
||
threadId,
|
||
isMock,
|
||
content: draftContent,
|
||
});
|
||
setHasLocalEdits(false);
|
||
} catch (error) {
|
||
console.error("Failed to save Markdown before AI rewrite:", error);
|
||
setHasLocalEdits(true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
primeArtifactRewriteFallback({
|
||
threadId,
|
||
path: filepath,
|
||
originalContent: draftContent,
|
||
});
|
||
const expectedHash = await sha256Content(draftContent);
|
||
const presentationId = rewritePresentation?.create({
|
||
threadId,
|
||
source: { kind: "artifact", id: threadId, path: filepath },
|
||
fileName: getFileName(filepath),
|
||
instruction: input.instruction,
|
||
style: input.style,
|
||
modelName: input.modelName,
|
||
generateImages: input.generateImages,
|
||
maxGeneratedImages: input.maxGeneratedImages,
|
||
originalContent: draftContent,
|
||
});
|
||
setDocumentRewritePresentationId(presentationId ?? null);
|
||
window.dispatchEvent(
|
||
new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, {
|
||
detail: { threadId },
|
||
}),
|
||
);
|
||
void documentRewrite.start(
|
||
{
|
||
threadId,
|
||
path: filepath,
|
||
instruction: input.instruction,
|
||
style: input.style,
|
||
modelName: input.modelName ?? undefined,
|
||
reportOutline: input.reportOutline || undefined,
|
||
structureMode: input.structureMode,
|
||
generateImages: input.generateImages,
|
||
maxGeneratedImages: input.maxGeneratedImages,
|
||
expectedHash,
|
||
},
|
||
{
|
||
onEvent: (event) => {
|
||
if (!presentationId || !rewritePresentation) return;
|
||
switch (event.type) {
|
||
case "snapshot_fixed":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "requirements",
|
||
fileName: event.fileName || getFileName(filepath),
|
||
originalCharCount: event.originalCharCount,
|
||
requirementsPlan: "",
|
||
});
|
||
break;
|
||
case "requirements_started":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "requirements",
|
||
requirementsPlan: "",
|
||
});
|
||
break;
|
||
case "requirements_checkpoint":
|
||
case "requirements_completed":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "requirements",
|
||
requirementsPlan: event.content,
|
||
});
|
||
break;
|
||
case "requirements_resolved":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "writing",
|
||
...(event.plan ? { requirementsPlan: event.plan } : {}),
|
||
});
|
||
break;
|
||
case "rewrite_delta":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "writing",
|
||
writtenCharCount: event.charCount,
|
||
});
|
||
break;
|
||
case "illustration_started":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "illustrating",
|
||
illustrationTarget: event.section,
|
||
maxGeneratedImages: event.total || 1,
|
||
});
|
||
break;
|
||
case "illustration_generated":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "illustrating",
|
||
illustrationTarget: event.section,
|
||
generatedImageCount: event.generatedCount ?? event.position,
|
||
maxGeneratedImages: event.total || 1,
|
||
});
|
||
break;
|
||
case "illustration_completed":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "illustrating",
|
||
generatedImageCount: event.generatedCount,
|
||
maxGeneratedImages: event.requestedCount || 1,
|
||
warnings: event.warnings,
|
||
});
|
||
break;
|
||
case "validation_started":
|
||
rewritePresentation.update(presentationId, { stage: "validation" });
|
||
break;
|
||
case "validation_completed":
|
||
rewritePresentation.update(presentationId, { warnings: event.warnings });
|
||
break;
|
||
case "comparison_ready":
|
||
rewritePresentation.update(presentationId, { summary: event.summary });
|
||
break;
|
||
case "commit_started":
|
||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||
break;
|
||
case "committed":
|
||
rewritePresentation.update(presentationId, {
|
||
versionId: event.versionId ?? null,
|
||
committedHash: event.committedHash,
|
||
});
|
||
break;
|
||
case "done":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "completed",
|
||
versionId: event.versionId ?? null,
|
||
});
|
||
break;
|
||
case "conflict":
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "conflict",
|
||
error: event.message,
|
||
});
|
||
break;
|
||
case "error":
|
||
// The sandbox retains the visible draft and exposes manual
|
||
// save instead of showing an error state to the user.
|
||
console.error("AI 全文改写接口失败:", event.message);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
},
|
||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||
setDraftContent(rewritten);
|
||
setHasLocalEdits(versionSnapshotFailed);
|
||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||
threadId,
|
||
path: filepath,
|
||
originalContent: draftContent,
|
||
committedHash,
|
||
});
|
||
if (!versionSnapshotFailed) {
|
||
clearArtifactRewriteFallback(threadId, filepath);
|
||
}
|
||
if (presentationId && rewritePresentation) {
|
||
rewritePresentation.update(presentationId, {
|
||
rewrittenContent: rewritten,
|
||
committedHash,
|
||
localFallbackAvailable,
|
||
});
|
||
}
|
||
queryClient.setQueriesData(
|
||
{
|
||
queryKey: ["artifact", filepath, threadId, isMock],
|
||
},
|
||
{ content: rewritten, url: undefined },
|
||
);
|
||
if (versionSnapshotFailed) {
|
||
console.error(
|
||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||
);
|
||
} else {
|
||
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||
}
|
||
},
|
||
onFailure: ({ content, error }) => {
|
||
// The rewrite transport can fail after the server has already
|
||
// streamed text (or before the first token on a 404). Keep that
|
||
// visible content in the sandbox, make Save actionable, and leave
|
||
// diagnostics to the developer console only.
|
||
const retainedContent = content || draftContent;
|
||
setDraftContent(retainedContent);
|
||
setHasLocalEdits(true);
|
||
queryClient.setQueriesData(
|
||
{
|
||
queryKey: ["artifact", filepath, threadId, isMock],
|
||
},
|
||
{ content: retainedContent, url: undefined },
|
||
);
|
||
if (presentationId && rewritePresentation) {
|
||
rewritePresentation.update(presentationId, {
|
||
stage: "completed",
|
||
rewrittenContent: retainedContent,
|
||
error: null,
|
||
});
|
||
}
|
||
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
|
||
},
|
||
},
|
||
);
|
||
},
|
||
[
|
||
canEditArtifact,
|
||
draftContent,
|
||
documentRewrite,
|
||
filepath,
|
||
hasLocalEdits,
|
||
isMock,
|
||
queryClient,
|
||
rewritePresentation,
|
||
threadId,
|
||
updateArtifact,
|
||
],
|
||
);
|
||
|
||
const handleCancelDocumentRewrite = useCallback(() => {
|
||
documentRewrite.cancel();
|
||
if (documentRewritePresentationId && rewritePresentation) {
|
||
rewritePresentation.update(documentRewritePresentationId, {
|
||
stage: "cancelled",
|
||
error: "已停止全文改写,原文件未被修改。",
|
||
});
|
||
}
|
||
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||
|
||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
||
const { agent: routeAgent } = useAgent(routeAgentId ?? null);
|
||
const ingestTemplateName = routeAgent?.template_name?.trim() ?? "";
|
||
const canPublishToKnowledge =
|
||
!!routeAgentId &&
|
||
!!ingestTemplateName &&
|
||
language === "markdown" &&
|
||
!isSkillFile &&
|
||
!isMock;
|
||
const [isPublishingToKnowledge, setIsPublishingToKnowledge] = useState(false);
|
||
const handlePublishToKnowledge = useCallback(async () => {
|
||
if (!routeAgentId || isPublishingToKnowledge) {
|
||
return;
|
||
}
|
||
const source = (displayContent ?? "").trim();
|
||
if (!source) {
|
||
toast.error("Markdown 内容为空,无法发布入库");
|
||
return;
|
||
}
|
||
setIsPublishingToKnowledge(true);
|
||
try {
|
||
const result = await publishArtifactToKnowledge({
|
||
agent_id: routeAgentId,
|
||
title: extractMarkdownTitle(source, filepath),
|
||
content: source,
|
||
});
|
||
if (result.success) {
|
||
toast.success(`已发布入库:${result.node_name}`);
|
||
} else {
|
||
const last = result.attempts[result.attempts.length - 1];
|
||
toast.error(
|
||
`发布入库失败:${last?.error ?? last?.response_text ?? "接口返回失败"}`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to publish artifact to knowledge base:", error);
|
||
toast.error(error instanceof Error ? error.message : "发布入库失败");
|
||
} finally {
|
||
setIsPublishingToKnowledge(false);
|
||
}
|
||
}, [displayContent, filepath, isPublishingToKnowledge, routeAgentId]);
|
||
|
||
const [isExportingWord, setIsExportingWord] = useState(false);
|
||
const handleDownloadAsWord = useCallback(async () => {
|
||
const source = (displayContent ?? "").trim();
|
||
if (!source) {
|
||
toast.error("Markdown 内容为空,无法另存为 Word");
|
||
return;
|
||
}
|
||
setIsExportingWord(true);
|
||
try {
|
||
const baseName = getFileName(filepath).replace(/\.(md|markdown)$/i, "");
|
||
await downloadMarkdownAsDocx(source, baseName || "document", baseName);
|
||
toast.success("已另存为 Word");
|
||
} catch (error) {
|
||
console.error("Failed to export markdown as docx:", error);
|
||
toast.error(error instanceof Error ? error.message : "另存为 Word 失败");
|
||
} finally {
|
||
setIsExportingWord(false);
|
||
}
|
||
}, [displayContent, filepath]);
|
||
|
||
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
|
||
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
|
||
* 给新窗口足够的加载时间。 */
|
||
const handleOpenInNewWindow = useCallback(() => {
|
||
const source = displayContent ?? "";
|
||
if (!source.trim()) {
|
||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||
return;
|
||
}
|
||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||
type: "text/html;charset=utf-8",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
window.open(url, "_blank", "noopener,noreferrer");
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||
}, [displayContent]);
|
||
|
||
const handleDownloadWriteFile = useCallback(() => {
|
||
const source = displayContent ?? "";
|
||
if (!source.trim()) {
|
||
toast.error("文件内容为空,暂时无法下载");
|
||
return;
|
||
}
|
||
if (language === "mermaid") {
|
||
void downloadMermaidSvgFile(source, filepath).catch((error) => {
|
||
console.error("Failed to download mermaid artifact:", error);
|
||
toast.error(error instanceof Error ? error.message : "Download failed");
|
||
});
|
||
return;
|
||
}
|
||
const blobType =
|
||
language === "html"
|
||
? "text/html;charset=utf-8"
|
||
: language === "markdown"
|
||
? "text/markdown;charset=utf-8"
|
||
: "text/plain;charset=utf-8";
|
||
const blob = new Blob([source], { type: blobType });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = getFileName(filepath);
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, [displayContent, filepath, language]);
|
||
|
||
return (
|
||
<Artifact className={cn(className)}>
|
||
<ArtifactHeader className="relative flex flex-wrap items-center gap-3 px-4 py-3 pr-14">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<div
|
||
className={cn(
|
||
"inline-flex min-w-0 items-center gap-2",
|
||
!headerLeadingActionBare &&
|
||
"bg-muted/65 rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8",
|
||
)}
|
||
>
|
||
{headerLeadingAction ?? (
|
||
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
|
||
{fileTypeLabel}
|
||
</span>
|
||
)}
|
||
{!hideFileName && (
|
||
<ArtifactTitle className="min-w-0">
|
||
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
|
||
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
||
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
|
||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||
) : hasMultipleArtifacts ? (
|
||
<Select value={filepath} onValueChange={select}>
|
||
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
|
||
<SelectValue placeholder="Select a file" />
|
||
</SelectTrigger>
|
||
<SelectContent className="select-none">
|
||
<SelectGroup>
|
||
{(artifacts ?? []).map((artifactPath) => (
|
||
<SelectItem key={artifactPath} value={artifactPath}>
|
||
{getFileName(artifactPath)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||
)}
|
||
</ArtifactTitle>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||
{isSupportPreview && (
|
||
<div className="flex items-center gap-2">
|
||
<ToggleGroup
|
||
className={cn(
|
||
"bg-muted/65 rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8",
|
||
showPreviewSwitchPulse &&
|
||
"animate-[sandbox-preview-glow_1.1s_ease-out_2]",
|
||
)}
|
||
type="single"
|
||
variant="default"
|
||
size="sm"
|
||
spacing={1}
|
||
value={viewMode}
|
||
onValueChange={(value) => {
|
||
if (value) {
|
||
setAllowAutoViewSwitch(false);
|
||
setViewMode(value as "code" | "preview" | "edit");
|
||
}
|
||
}}
|
||
>
|
||
{canUseAiEditor && !documentRewriteActive && (
|
||
<ToggleGroupItem
|
||
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
||
value="edit"
|
||
aria-label="AI 写作编辑"
|
||
>
|
||
<SparklesIcon />
|
||
</ToggleGroupItem>
|
||
)}
|
||
<ToggleGroupItem
|
||
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
||
value="code"
|
||
>
|
||
<Code2Icon />
|
||
</ToggleGroupItem>
|
||
<ToggleGroupItem
|
||
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
||
value="preview"
|
||
>
|
||
<EyeIcon />
|
||
</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
{isPreviewStreaming && (
|
||
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
|
||
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
|
||
正在生成,完成后自动预览
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex min-w-0 w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
|
||
<ArtifactActions className="bg-muted/45 min-w-0 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
|
||
{canPublishToKnowledge && (
|
||
<ArtifactAction
|
||
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
|
||
label="发布入库"
|
||
tooltip={`发布到知识库(模板:${ingestTemplateName})`}
|
||
disabled={isPublishingToKnowledge || !displayContent}
|
||
onClick={() => void handlePublishToKnowledge()}
|
||
/>
|
||
)}
|
||
{canEditArtifact && (
|
||
<ArtifactAction
|
||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||
label="保存 Markdown"
|
||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||
disabled={!hasLocalEdits || updateArtifact.isPending || documentRewriteActive}
|
||
onClick={handleSaveArtifact}
|
||
/>
|
||
)}
|
||
{toolbarActions}
|
||
{canEditArtifact && (
|
||
<ArtifactAction
|
||
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||
tooltip={
|
||
documentRewriteActive
|
||
? "正在生成临时草稿,原文件尚未被覆盖"
|
||
: hasLocalEdits
|
||
? "将先保存当前 Markdown 修改,再开始改写"
|
||
: "使用 AI 重写当前完整 Markdown"
|
||
}
|
||
// Keep the entry available after a completed rewrite and when
|
||
// the user has local edits. Starting with local edits saves
|
||
// them automatically in handleStartDocumentRewrite.
|
||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||
/>
|
||
)}
|
||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||
<ArtifactAction
|
||
icon={PackageIcon}
|
||
label={t.common.install}
|
||
tooltip={t.common.install}
|
||
disabled={
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||
}
|
||
onClick={() => setInstallDialogOpen(true)}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
{isCodeFile && (
|
||
<ArtifactAction
|
||
icon={CopyIcon}
|
||
label={t.clipboard.copyToClipboard}
|
||
disabled={!content}
|
||
onClick={async () => {
|
||
try {
|
||
await copyTextToClipboard(displayContent ?? "");
|
||
toast.success(t.clipboard.copiedToClipboard);
|
||
} catch (error) {
|
||
toast.error("Failed to copy to clipboard");
|
||
console.error(error);
|
||
}
|
||
}}
|
||
tooltip={t.clipboard.copyToClipboard}
|
||
/>
|
||
)}
|
||
{language === "markdown" && !onDownloadClick && (
|
||
<ArtifactAction
|
||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||
label="另存为 Word"
|
||
tooltip="另存为 Word (.docx)"
|
||
disabled={isExportingWord || !displayContent}
|
||
onClick={handleDownloadAsWord}
|
||
/>
|
||
)}
|
||
{language === "html" && (
|
||
<ArtifactAction
|
||
icon={ExternalLinkIcon}
|
||
label="新窗口打开"
|
||
tooltip="在新窗口全屏打开 HTML"
|
||
disabled={!displayContent?.trim()}
|
||
onClick={handleOpenInNewWindow}
|
||
/>
|
||
)}
|
||
<ArtifactAction
|
||
icon={DownloadIcon}
|
||
label={t.common.download}
|
||
tooltip={t.common.download}
|
||
onClick={async () => {
|
||
if (onDownloadClick) {
|
||
onDownloadClick();
|
||
return;
|
||
}
|
||
if (useWriteFileStreaming) {
|
||
handleDownloadWriteFile();
|
||
return;
|
||
}
|
||
if (language === "mermaid") {
|
||
try {
|
||
await downloadMermaidSvgFile(displayContent ?? "", filepath);
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Download failed",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
await downloadArtifactFile({
|
||
filepath,
|
||
threadId,
|
||
isMock,
|
||
publicShareCode,
|
||
});
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Download failed",
|
||
);
|
||
}
|
||
}}
|
||
/>
|
||
</ArtifactActions>
|
||
</div>
|
||
{/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的
|
||
动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */}
|
||
<ArtifactAction
|
||
className="bg-background/90 hover:bg-accent absolute top-3 right-3 z-10 border shadow-sm"
|
||
icon={XIcon}
|
||
label="收起文件面板"
|
||
onClick={() => (onClose ? onClose() : setOpen(false))}
|
||
tooltip="收起文件面板"
|
||
/>
|
||
</ArtifactHeader>
|
||
<ArtifactContent
|
||
ref={artifactContentRef}
|
||
className="relative p-0"
|
||
onWheelCapture={handlePauseAutoScroll}
|
||
>
|
||
{isApplyingEdit && (
|
||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||
正在改写 Markdown
|
||
</div>
|
||
)}
|
||
{documentRewriteActive && (
|
||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||
<LoaderIcon className="size-3.5 animate-spin" />
|
||
{documentRewrite.run.stage === "requirements"
|
||
? "正在理解改写需求,原文件未被修改"
|
||
: documentRewrite.run.stage === "validation"
|
||
? "正在校验 Markdown"
|
||
: documentRewrite.run.stage === "committing"
|
||
? "正在安全替换原文件"
|
||
: "正在写入临时草稿 · " +
|
||
documentRewrite.run.writtenCharCount.toLocaleString() +
|
||
" 字"}
|
||
<button
|
||
type="button"
|
||
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
|
||
onClick={handleCancelDocumentRewrite}
|
||
>
|
||
停止
|
||
</button>
|
||
</div>
|
||
)}
|
||
{showResumeAutoScroll && (
|
||
<button
|
||
type="button"
|
||
onClick={handleResumeAutoScroll}
|
||
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
|
||
>
|
||
<ArrowDownIcon className="size-4" />
|
||
滑动到底部并继续自动下滑
|
||
</button>
|
||
)}
|
||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||
<MarkdownAiEditor
|
||
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
|
||
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
|
||
markdown={(hasLocalEdits || canEditArtifact ? draftContent : content) ?? ""}
|
||
onChange={(value) => {
|
||
setDraftContent(value);
|
||
setHasLocalEdits(true);
|
||
}}
|
||
threadId={threadId}
|
||
/>
|
||
)}
|
||
{isSupportPreview &&
|
||
viewMode === "preview" &&
|
||
(language === "markdown" ||
|
||
language === "html" ||
|
||
language === "mermaid") && (
|
||
<ArtifactFilePreview
|
||
content={sandboxDisplayText}
|
||
language={language ?? "text"}
|
||
threadId={threadId}
|
||
publicShareCode={publicShareCode}
|
||
researchSessionId={researchSessionId}
|
||
animated={showPreviewSwitchPulse}
|
||
activeCursor={isApplyingEdit}
|
||
/>
|
||
)}
|
||
{artifactContentPending && 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" />
|
||
正在加载文档内容…
|
||
</div>
|
||
)}
|
||
{artifactContentUnavailable && viewMode === "code" && (
|
||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 px-6 text-center text-sm">
|
||
<FileType2Icon aria-hidden="true" className="size-6" />
|
||
<p>{artifactContentError ? "暂时无法读取文档内容。" : "当前文档没有可显示的内容。"}</p>
|
||
<button
|
||
className="text-primary rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||
onClick={() => {
|
||
void refetchArtifactContent();
|
||
}}
|
||
type="button"
|
||
>
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
|
||
<div className="size-full">
|
||
<CodeEditor
|
||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||
value={
|
||
canEditArtifact || disableDesensitize
|
||
? (displayContent ?? "")
|
||
: sandboxDisplayText
|
||
}
|
||
readonly={!canEditArtifact || documentRewriteActive}
|
||
onChange={
|
||
canEditArtifact && !documentRewriteActive
|
||
? (value) => {
|
||
setDraftContent(value);
|
||
setHasLocalEdits(true);
|
||
}
|
||
: undefined
|
||
}
|
||
isStreaming={writeFileInFlight || documentRewriteActive}
|
||
streamingReadonlyFallback={!canEditArtifact}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!isCodeFile && isSpreadsheetFile && (
|
||
spreadsheetData ? (
|
||
<SpreadsheetPreview data={spreadsheetData} />
|
||
) : (
|
||
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
|
||
<LoaderIcon className="size-5 animate-spin" />
|
||
{t.common.loading}
|
||
</div>
|
||
)
|
||
)}
|
||
{!isCodeFile &&
|
||
!isSpreadsheetFile &&
|
||
(binaryPreviewUrl ? (
|
||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||
) : (
|
||
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
|
||
<LoaderIcon className="size-5 animate-spin" />
|
||
{t.common.loading}
|
||
</div>
|
||
))}
|
||
</ArtifactContent>
|
||
<SkillInstallDialog
|
||
open={installDialogOpen}
|
||
onOpenChange={setInstallDialogOpen}
|
||
threadId={threadId}
|
||
filepath={filepath}
|
||
/>
|
||
<DocumentRewriteDialog
|
||
open={documentRewriteDialogOpen}
|
||
onOpenChange={setDocumentRewriteDialogOpen}
|
||
fileName={getFileName(filepath)}
|
||
charCount={(displayContent ?? "").replace(/\s/g, "").length}
|
||
imageGenerationAvailable={imageGenerationAvailable}
|
||
maxGeneratedImages={maxGeneratedImages}
|
||
onStart={handleStartDocumentRewrite}
|
||
/>
|
||
</Artifact>
|
||
);
|
||
}
|
||
|
||
function SandboxPreviewImage({
|
||
src,
|
||
alt,
|
||
threadId,
|
||
publicShareCode,
|
||
researchSessionId,
|
||
...props
|
||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||
threadId: string;
|
||
publicShareCode?: string;
|
||
researchSessionId?: string;
|
||
}) {
|
||
if (!src || typeof src !== "string") {
|
||
return null;
|
||
}
|
||
|
||
if (src.startsWith("deep-research://")) {
|
||
return researchSessionId ? (
|
||
<ProtectedResearchReportImage
|
||
{...props}
|
||
alt={alt}
|
||
sessionId={researchSessionId}
|
||
src={src}
|
||
/>
|
||
) : null;
|
||
}
|
||
|
||
let url = src;
|
||
if (src.startsWith("/mnt/")) {
|
||
if (publicShareCode) {
|
||
const normalized = src.startsWith("/") ? src : `/${src}`;
|
||
url = `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||
publicShareCode,
|
||
)}/files${normalized}`;
|
||
} else {
|
||
url = resolveArtifactURL(src, threadId);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<img
|
||
{...props}
|
||
className={cn(
|
||
"my-3 max-w-full overflow-hidden rounded-lg",
|
||
props.className,
|
||
)}
|
||
src={url}
|
||
alt={alt}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** Fetches a private deep-research image with the normal auth client. */
|
||
function ProtectedResearchReportImage({
|
||
sessionId,
|
||
src,
|
||
alt,
|
||
...props
|
||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||
sessionId: string;
|
||
}) {
|
||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||
const [loadFailed, setLoadFailed] = useState(false);
|
||
const [retryKey, setRetryKey] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setObjectUrl(null);
|
||
setLoadFailed(false);
|
||
if (!src?.startsWith("deep-research://")) {
|
||
return;
|
||
}
|
||
const name = src.slice("deep-research://".length);
|
||
if (!name) {
|
||
setLoadFailed(true);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
let nextUrl: string | null = null;
|
||
let disposed = false;
|
||
void (async () => {
|
||
try {
|
||
const response = await apiFetch(
|
||
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
|
||
{ method: "GET", signal: controller.signal },
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(`Unable to load report image (${response.status})`);
|
||
}
|
||
const blob = await response.blob();
|
||
if (!blob.size) {
|
||
throw new Error("Report image is empty");
|
||
}
|
||
nextUrl = URL.createObjectURL(blob);
|
||
if (disposed) return;
|
||
setObjectUrl(nextUrl);
|
||
} catch (error) {
|
||
if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) {
|
||
// Never let an optional illustration break Markdown rendering, but don't
|
||
// leave the user with a misleading infinite loading label either.
|
||
setLoadFailed(true);
|
||
}
|
||
}
|
||
})();
|
||
return () => {
|
||
disposed = true;
|
||
controller.abort();
|
||
if (nextUrl) URL.revokeObjectURL(nextUrl);
|
||
};
|
||
}, [retryKey, sessionId, src]);
|
||
|
||
if (!objectUrl) {
|
||
if (loadFailed) {
|
||
return (
|
||
<span className="my-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
|
||
报告配图加载失败
|
||
<button
|
||
type="button"
|
||
className="font-medium underline underline-offset-2 hover:opacity-80"
|
||
onClick={() => setRetryKey((value) => value + 1)}
|
||
>
|
||
重试
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
|
||
}
|
||
return (
|
||
<img
|
||
{...props}
|
||
alt={alt || "研究报告配图"}
|
||
className={cn("my-3 max-w-full overflow-hidden rounded-lg border", props.className)}
|
||
src={objectUrl}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function ArtifactFilePreview({
|
||
content,
|
||
language,
|
||
threadId,
|
||
publicShareCode,
|
||
researchSessionId,
|
||
animated = false,
|
||
activeCursor = false,
|
||
}: {
|
||
content: string;
|
||
language: string;
|
||
threadId: string;
|
||
publicShareCode?: string;
|
||
researchSessionId?: string;
|
||
animated?: boolean;
|
||
activeCursor?: boolean;
|
||
}) {
|
||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||
const researchSourcesQuery = useResearchSources(
|
||
language === "markdown" ? (researchSessionId ?? null) : null,
|
||
);
|
||
const previewContent = useMemo(
|
||
() => researchSessionId
|
||
? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? [])
|
||
: preserveMarkdownStandaloneLines(content ?? ""),
|
||
[content, researchSessionId, researchSourcesQuery.data?.items],
|
||
);
|
||
const markdownComponents = useMemo(() => {
|
||
const anchorResolver = createMarkdownAnchorResolver(previewContent);
|
||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||
const Tag = `h${level}` as const;
|
||
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
|
||
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
|
||
{children}
|
||
</Tag>
|
||
);
|
||
};
|
||
|
||
return {
|
||
h1: heading(1),
|
||
h2: heading(2),
|
||
h3: heading(3),
|
||
h4: heading(4),
|
||
h5: heading(5),
|
||
h6: heading(6),
|
||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
|
||
return <ArtifactLink {...props} />;
|
||
}
|
||
const { className, ...rest } = props;
|
||
const resolvedHref = anchorResolver.resolveHref(
|
||
props.href,
|
||
extractTextFromChildren(props.children),
|
||
);
|
||
if (resolvedHref === null) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
|
||
className,
|
||
)}
|
||
title="未在当前 Markdown 中找到对应章节"
|
||
>
|
||
{props.children}
|
||
</span>
|
||
);
|
||
}
|
||
const finalHref = resolvedHref ?? props.href;
|
||
return (
|
||
<ArtifactLink
|
||
{...rest}
|
||
className={className}
|
||
href={finalHref}
|
||
onClick={(event) => {
|
||
if (isInPageAnchor(finalHref)) {
|
||
event.preventDefault();
|
||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||
}
|
||
rest.onClick?.(event);
|
||
}}
|
||
/>
|
||
);
|
||
},
|
||
};
|
||
}, [previewContent, publicShareCode, researchSessionId, threadId]);
|
||
|
||
useEffect(() => {
|
||
if (language !== "html") {
|
||
setHtmlPreviewUrl(undefined);
|
||
return;
|
||
}
|
||
|
||
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
|
||
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
|
||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||
// of navigating the sandboxed blob: document (which blanks the frame).
|
||
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||
type: "text/html;charset=utf-8",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
setHtmlPreviewUrl(url);
|
||
|
||
return () => {
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
}, [content, language]);
|
||
|
||
useEffect(() => {
|
||
if (language !== "mermaid") {
|
||
setMermaidSvg(null);
|
||
setMermaidError(null);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
setMermaidSvg(null);
|
||
setMermaidError(null);
|
||
|
||
void renderMermaidToSvg(content ?? "")
|
||
.then((svg) => {
|
||
if (!cancelled) {
|
||
setMermaidSvg(svg);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to render mermaid preview:", error);
|
||
if (!cancelled) {
|
||
setMermaidError(
|
||
error instanceof Error ? error.message : "Mermaid 渲染失败",
|
||
);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [content, language]);
|
||
|
||
if (language === "markdown") {
|
||
return (
|
||
<div
|
||
data-artifact-preview-scroll
|
||
data-markdown-anchor-root
|
||
className={cn(
|
||
"size-full overflow-auto px-5 py-4",
|
||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||
)}
|
||
>
|
||
<Streamdown
|
||
className="size-full"
|
||
{...streamdownPlugins}
|
||
// react-markdown's safe default strips unknown protocols before the
|
||
// custom `img` renderer sees them. Deep-research images use this
|
||
// private pseudo URL and are then fetched through the authenticated
|
||
// artifact endpoint in `SandboxPreviewImage`.
|
||
urlTransform={(url) =>
|
||
url.startsWith("deep-research://")
|
||
? url
|
||
: defaultUrlTransform(url)
|
||
}
|
||
components={{
|
||
...markdownComponents,
|
||
img: (props) => (
|
||
<SandboxPreviewImage
|
||
{...props}
|
||
threadId={threadId}
|
||
publicShareCode={publicShareCode}
|
||
researchSessionId={researchSessionId}
|
||
/>
|
||
),
|
||
}}
|
||
>
|
||
{previewContent}
|
||
</Streamdown>
|
||
{activeCursor && (
|
||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (language === "html") {
|
||
return (
|
||
<iframe
|
||
className={cn(
|
||
"size-full bg-white",
|
||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||
)}
|
||
title="Artifact preview"
|
||
sandbox="allow-scripts allow-forms"
|
||
src={htmlPreviewUrl}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (language === "mermaid") {
|
||
return (
|
||
<div
|
||
data-artifact-preview-scroll
|
||
className={cn(
|
||
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
|
||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||
)}
|
||
>
|
||
{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 }}
|
||
/>
|
||
) : 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">
|
||
{mermaidError}
|
||
</div>
|
||
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
|
||
{content ?? ""}
|
||
</pre>
|
||
</div>
|
||
) : (
|
||
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
|
||
<LoaderIcon className="size-5 animate-spin" />
|
||
正在生成图表预览
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|