diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx
index ddcfbb9e..6c0792fa 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx
@@ -30,7 +30,7 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
- const { isDraftStreaming, draftStreamingMarkdown } = useCanvasUIContext();
+ const { isDraftStreaming, draftStreamingMarkdown, isDraftEditable } = useCanvasUIContext();
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。
@@ -61,6 +61,7 @@ export function AIWritingDraftPanel() {
}
const content = getCurrentArtifactContent(artifact);
+ const isText = content && !isCodeArtifact(content);
return (
@@ -73,9 +74,14 @@ export function AIWritingDraftPanel() {
- {/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */}
- {content && !isCodeArtifact(content) ? (
-
+ {isText ? (
+ // 仅"生成完成的稳定态"挂载带编辑器的渲染器(润色 / 滑词);
+ // 审核中等其它进行中状态用只读预览,避免编辑器在活跃流中挂载导致更新风暴。
+ isDraftEditable ? (
+
+ ) : (
+
+ )
) : null}
diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx
index 960f7881..c4eb1eaa 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx
@@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
interface DraftStreamingPreviewProps {
/** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */
markdown: string;
+ /** 是否显示流式光标(生成中 true;静态只读预览传 false) */
+ showCursor?: boolean;
}
/**
@@ -21,7 +23,7 @@ interface DraftStreamingPreviewProps {
* 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮,
* 点击后滚到底部并恢复自动下滑。
*/
-export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps) {
+export function DraftStreamingPreview({ markdown, showCursor = true }: DraftStreamingPreviewProps) {
const scrollRef = useRef(null);
const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
@@ -126,7 +128,9 @@ export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps)
>
{markdown}
-
+ {showCursor && (
+
+ )}
diff --git a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx
index acff3bea..5463e532 100644
--- a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx
+++ b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx
@@ -21,6 +21,8 @@ export interface CanvasUIContextValue {
isDraftStreaming: boolean;
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
draftStreamingMarkdown: string;
+ /** AI 写作:草稿是否进入"生成完成的稳定态"(可编辑)。仅此时才挂载带编辑器的渲染器;生成/审核中一律只读 */
+ isDraftEditable: boolean;
selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
@@ -29,6 +31,7 @@ export interface CanvasUIContextValue {
setIsWebSearchOpen: (v: boolean) => void;
setIsDraftStreaming: (v: boolean) => void;
setDraftStreamingMarkdown: (v: string) => void;
+ setIsDraftEditable: (v: boolean) => void;
/** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */
@@ -50,6 +53,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [isDraftStreaming, setIsDraftStreaming] = useState(false);
const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState('');
+ const [isDraftEditable, setIsDraftEditable] = useState(false);
const [selection, setSelection] =
useState(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
@@ -71,6 +75,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isWebSearchOpen,
isDraftStreaming,
draftStreamingMarkdown,
+ isDraftEditable,
selection,
writingSelection,
setIsChatCollapsed,
@@ -78,6 +83,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
setIsWebSearchOpen,
setIsDraftStreaming,
setDraftStreamingMarkdown,
+ setIsDraftEditable,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
diff --git a/frontend-web/src/open-canvas/editor/TiptapExtensions.ts b/frontend-web/src/open-canvas/editor/TiptapExtensions.ts
index 69e56948..157668c5 100644
--- a/frontend-web/src/open-canvas/editor/TiptapExtensions.ts
+++ b/frontend-web/src/open-canvas/editor/TiptapExtensions.ts
@@ -1,15 +1,15 @@
import StarterKit from "@tiptap/starter-kit";
-import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { AiHighlightExtension } from "./AiHighlightExtension";
+// 注意:@tiptap v3 的 StarterKit 已内置 underline,不要再单独引入 @tiptap/extension-underline,
+// 否则会触发 "Duplicate extension names ['underline']" 警告并导致 useEditor schema 解析异常。
export const buildExtensions = (placeholder = "开始写作…") => [
StarterKit.configure({
// Disable hard break so Enter creates new paragraph
hardBreak: false,
}),
- Underline,
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty",
diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
index 088bc819..144b96a3 100644
--- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
+++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
@@ -641,7 +641,7 @@ type AIWritingValues = {
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
- const { setIsDraftStreaming, setDraftStreamingMarkdown } = useCanvasUIContext();
+ const { setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable } = useCanvasUIContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
@@ -741,7 +741,11 @@ export function useAIWriting() {
// - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。
useEffect(() => {
const streaming = state.status === 'writing_draft' || state.status === 'revising';
+ // 仅"暂停等待用户 / 完成"这类已停止生成的稳定态才允许编辑(挂载编辑器)——此时没有事件流,
+ // 编辑器稳定;研究 / 大纲 / 写作 / 修改 / 审核等任一进行中状态都只读,杜绝编辑器在活跃流中挂载。
+ const editable = state.status.startsWith('awaiting_') || state.status === 'done';
setIsDraftStreaming(streaming);
+ setIsDraftEditable(editable);
if (streaming) {
setDraftStreamingMarkdown(state.currentDraftMarkdown);
return;
@@ -758,7 +762,7 @@ export function useAIWriting() {
}],
});
}
- }, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown]);
+ }, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable]);
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => {
diff --git a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts
index f15f666c..f9afc30a 100644
--- a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts
+++ b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { useEditor } from "@tiptap/react";
import { buildExtensions } from "../editor/TiptapExtensions";
@@ -17,17 +17,26 @@ export function useWritingEditor({
}: UseWritingEditorOptions) {
const prevMarkdownRef = useRef(markdown);
+ // 扩展集合 memo 一次,避免每次渲染生成新数组导致 useEditor 反复重建(v3 下会触发
+ // 重复 schema 解析 / "Duplicate extension names" 警告,极端情况引发更新风暴)。
+ const extensions = useMemo(() => buildExtensions(placeholder), [placeholder]);
+
+ // onChange 用 ref 持有最新值,让 onUpdate 闭包保持稳定,不随父组件回调身份变化而重建编辑器。
+ const onChangeRef = useRef(onChange);
+ useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
+
const editor = useEditor({
- extensions: buildExtensions(placeholder),
+ extensions,
editable,
content: markdown,
onUpdate({ editor }) {
- if (!onChange) return;
+ const fn = onChangeRef.current;
+ if (!fn) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
- onChange(md);
+ fn(md);
},
- });
+ }, []);
// Sync external markdown changes (e.g. after streaming completes)
useEffect(() => {