From b25796355187d3769e38a58efd21e3311aa44e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 2 Jun 2026 21:22:31 +0800 Subject: [PATCH] =?UTF-8?q?ai=E5=86=99=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-writing/AIWritingDraftPanel.tsx | 14 ++++++++++---- .../ai-writing/DraftStreamingPreview.tsx | 8 ++++++-- .../open-canvas/contexts/CanvasUIContext.tsx | 6 ++++++ .../open-canvas/editor/TiptapExtensions.ts | 4 ++-- .../open-canvas/hooks/useAIWritingStream.ts | 8 ++++++-- .../src/open-canvas/hooks/useWritingEditor.ts | 19 ++++++++++++++----- 6 files changed, 44 insertions(+), 15 deletions(-) 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(() => {