ai写作
This commit is contained in:
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(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user