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() {
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 (
<div className="h-full flex flex-col overflow-hidden border-l">
@@ -73,9 +74,14 @@ export function AIWritingDraftPanel() {
</div>
<div className="flex-1 overflow-hidden">
{/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */}
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
{isText ? (
// 仅"生成完成的稳定态"挂载带编辑器的渲染器(润色 / 滑词);
// 审核中等其它进行中状态用只读预览,避免编辑器在活跃流中挂载导致更新风暴。
isDraftEditable ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : (
<DraftStreamingPreview markdown={(content as CanvasArtifactText).fullMarkdown} showCursor={false} />
)
) : null}
</div>
</div>
@@ -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<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
@@ -126,7 +128,9 @@ export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps)
>
{markdown}
</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>
@@ -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<CanvasSelectionState>(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),
@@ -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",
@@ -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(() => {
@@ -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<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({
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(() => {