This commit is contained in:
董宏凯 committed 2026-06-02 21:22:31 +08:00
1 parent 7efedc7b44
commit b257963551
6 files changed
+44 -15

No files matched your search

@@ -30,7 +30,7 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() { export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext(); const { artifact } = useCanvasGraphContext();
const { isDraftStreaming, draftStreamingMarkdown } = useCanvasUIContext(); const { isDraftStreaming, draftStreamingMarkdown, isDraftEditable } = useCanvasUIContext();
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器, // 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。 // 彻底隔绝高频内容刷新触发的编辑器更新风暴。
@@ -61,6 +61,7 @@ export function AIWritingDraftPanel() {
} }
const content = getCurrentArtifactContent(artifact); const content = getCurrentArtifactContent(artifact);
const isText = content && !isCodeArtifact(content);
return ( return (
<div className="h-full flex flex-col overflow-hidden border-l"> <div className="h-full flex flex-col overflow-hidden border-l">
@@ -73,9 +74,14 @@ export function AIWritingDraftPanel() {
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */} {isText ? (
{content && !isCodeArtifact(content) ? ( // 仅"生成完成的稳定态"挂载带编辑器的渲染器(润色 / 滑词);
<TextArtifactRenderer content={content as CanvasArtifactText} /> // 审核中等其它进行中状态用只读预览,避免编辑器在活跃流中挂载导致更新风暴。
isDraftEditable ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : (
<DraftStreamingPreview markdown={(content as CanvasArtifactText).fullMarkdown} showCursor={false} />
)
) : null} ) : null}
</div> </div>
</div> </div>
@@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
interface DraftStreamingPreviewProps { interface DraftStreamingPreviewProps {
/** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */ /** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */
markdown: string; 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<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true); const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环 // 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
@@ -126,7 +128,9 @@ export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps)
> >
{markdown} {markdown}
</ReactMarkdown> </ReactMarkdown>
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" /> {showCursor && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div> </div>
</div> </div>
@@ -21,6 +21,8 @@ export interface CanvasUIContextValue {
isDraftStreaming: boolean; isDraftStreaming: boolean;
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */ /** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
draftStreamingMarkdown: string; draftStreamingMarkdown: string;
/** AI 写作:草稿是否进入"生成完成的稳定态"(可编辑)。仅此时才挂载带编辑器的渲染器;生成/审核中一律只读 */
isDraftEditable: boolean;
selection: CanvasSelectionState; selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */ /** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null; writingSelection: WritingSelection | null;
@@ -29,6 +31,7 @@ export interface CanvasUIContextValue {
setIsWebSearchOpen: (v: boolean) => void; setIsWebSearchOpen: (v: boolean) => void;
setIsDraftStreaming: (v: boolean) => void; setIsDraftStreaming: (v: boolean) => void;
setDraftStreamingMarkdown: (v: string) => void; setDraftStreamingMarkdown: (v: string) => void;
setIsDraftEditable: (v: boolean) => void;
/** Set text selection; clears any existing code selection. */ /** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void; setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */ /** Set code selection; clears any existing text selection. */
@@ -50,6 +53,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false); const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [isDraftStreaming, setIsDraftStreaming] = useState(false); const [isDraftStreaming, setIsDraftStreaming] = useState(false);
const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState(''); const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState('');
const [isDraftEditable, setIsDraftEditable] = useState(false);
const [selection, setSelection] = const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION); useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] = const [writingSelection, setWritingSelection] =
@@ -71,6 +75,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isWebSearchOpen, isWebSearchOpen,
isDraftStreaming, isDraftStreaming,
draftStreamingMarkdown, draftStreamingMarkdown,
isDraftEditable,
selection, selection,
writingSelection, writingSelection,
setIsChatCollapsed, setIsChatCollapsed,
@@ -78,6 +83,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
setIsWebSearchOpen, setIsWebSearchOpen,
setIsDraftStreaming, setIsDraftStreaming,
setDraftStreamingMarkdown, setDraftStreamingMarkdown,
setIsDraftEditable,
setTextSelection, setTextSelection,
setCodeSelection, setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION), clearSelection: () => setSelection(EMPTY_SELECTION),
@@ -1,15 +1,15 @@
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown"; import { Markdown } from "tiptap-markdown";
import { AiHighlightExtension } from "./AiHighlightExtension"; import { AiHighlightExtension } from "./AiHighlightExtension";
// 注意:@tiptap v3 的 StarterKit 已内置 underline,不要再单独引入 @tiptap/extension-underline,
// 否则会触发 "Duplicate extension names ['underline']" 警告并导致 useEditor schema 解析异常。
export const buildExtensions = (placeholder = "开始写作…") => [ export const buildExtensions = (placeholder = "开始写作…") => [
StarterKit.configure({ StarterKit.configure({
// Disable hard break so Enter creates new paragraph // Disable hard break so Enter creates new paragraph
hardBreak: false, hardBreak: false,
}), }),
Underline,
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
emptyEditorClass: "is-editor-empty", emptyEditorClass: "is-editor-empty",
@@ -641,7 +641,7 @@ type AIWritingValues = {
export function useAIWriting() { export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext(); const { setArtifact } = useCanvasGraphContext();
const { setIsDraftStreaming, setDraftStreamingMarkdown } = useCanvasUIContext(); const { setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable } = useCanvasUIContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE); const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌 // useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
@@ -741,7 +741,11 @@ export function useAIWriting() {
// - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。 // - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。
useEffect(() => { useEffect(() => {
const streaming = state.status === 'writing_draft' || state.status === 'revising'; const streaming = state.status === 'writing_draft' || state.status === 'revising';
// 仅"暂停等待用户 / 完成"这类已停止生成的稳定态才允许编辑(挂载编辑器)——此时没有事件流,
// 编辑器稳定;研究 / 大纲 / 写作 / 修改 / 审核等任一进行中状态都只读,杜绝编辑器在活跃流中挂载。
const editable = state.status.startsWith('awaiting_') || state.status === 'done';
setIsDraftStreaming(streaming); setIsDraftStreaming(streaming);
setIsDraftEditable(editable);
if (streaming) { if (streaming) {
setDraftStreamingMarkdown(state.currentDraftMarkdown); setDraftStreamingMarkdown(state.currentDraftMarkdown);
return; 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 时全量上传) // transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => { useEffect(() => {
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useEditor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { buildExtensions } from "../editor/TiptapExtensions"; import { buildExtensions } from "../editor/TiptapExtensions";
@@ -17,17 +17,26 @@ export function useWritingEditor({
}: UseWritingEditorOptions) { }: UseWritingEditorOptions) {
const prevMarkdownRef = useRef<string>(markdown); const prevMarkdownRef = useRef<string>(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({ const editor = useEditor({
extensions: buildExtensions(placeholder), extensions,
editable, editable,
content: markdown, content: markdown,
onUpdate({ editor }) { onUpdate({ editor }) {
if (!onChange) return; const fn = onChangeRef.current;
if (!fn) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText(); const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
onChange(md); fn(md);
}, },
}); }, []);
// Sync external markdown changes (e.g. after streaming completes) // Sync external markdown changes (e.g. after streaming completes)
useEffect(() => { useEffect(() => {