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() {
|
||||
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(() => {
|
||||
|
||||
Reference in new issue
Block a user