ai写作修改

This commit is contained in:
董宏凯 committed 2026-06-02 20:36:22 +08:00
1 parent 30410b0a93
commit 7efedc7b44
23 files changed
+1355 -16

No files matched your search

@@ -1,8 +1,10 @@
import { FileText, PenLine } from 'lucide-react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import { DraftStreamingPreview } from './DraftStreamingPreview';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
@@ -28,6 +30,27 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
const { isDraftStreaming, draftStreamingMarkdown } = useCanvasUIContext();
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。
if (isDraftStreaming) {
return (
<div className="h-full flex flex-col overflow-hidden border-l">
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
</div>
<div className="flex-1 overflow-hidden">
{draftStreamingMarkdown ? (
<DraftStreamingPreview markdown={draftStreamingMarkdown} />
) : (
<DraftWaitingPlaceholder />
)}
</div>
</div>
);
}
if (!artifact) {
return (
@@ -50,6 +73,7 @@ export function AIWritingDraftPanel() {
</div>
<div className="flex-1 overflow-hidden">
{/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */}
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
@@ -41,7 +41,7 @@ import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { DraftGeneratingView } from './DraftGeneratingView';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
@@ -440,12 +440,9 @@ export function AIWritingPanel({
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
{/* 草稿生成中:左侧只展示思考过程(若有)+「内容生成中」骨架屏;正文实时显示在右侧画布 */}
{state.status === 'writing_draft' && (
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
@@ -485,12 +482,6 @@ export function AIWritingPanel({
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { Brain, PenLine } from 'lucide-react';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftGeneratingViewProps {
sections: StreamingDraftSection[];
}
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
</div>
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{text}
{!done && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
</div>
);
}
/** 「内容生成中」骨架屏:闪烁的占位行,提示正文正在右侧实时生成 */
function GeneratingSkeleton() {
return (
<div className="rounded-xl border bg-card shadow-sm p-4">
<div className="flex items-center gap-2 mb-3">
<PenLine className="size-3.5 text-primary animate-pulse" />
<span className="text-xs font-medium text-foreground">内容生成中…</span>
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
</div>
<div className="space-y-2.5">
<div className="h-3 rounded bg-muted/70 animate-pulse w-[92%]" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-full" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-[85%]" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-[70%]" />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
/**
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
*
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
*/
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
return (
<div className="px-4 pb-4 space-y-3">
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
<GeneratingSkeleton />
</div>
);
}
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
interface DraftStreamingPreviewProps {
/** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */
markdown: string;
}
/**
* AI 写作右侧「边生成边推送」的只读流式预览。
*
* 刻意不挂载 Tiptap 编辑器(useWritingEditor / useWritingRewrite):草稿是 token 级
* 高频 chunk,若每个 chunk 都灌进编辑器会触发 ProseMirror transaction 风暴 + 父子组件
* 反复重渲染,导致 React「Maximum update depth exceeded」。生成完成后才由
* TextArtifactRenderer 接管为可编辑文档(润色 / 滑词等)。
*
* 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮,
* 点击后滚到底部并恢复自动下滑。
*/
export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
const programmaticRef = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
// 内容增长时,若自动下滑开启则贴底
useEffect(() => {
if (!autoScrollRef.current) return;
const el = scrollRef.current;
if (!el) return;
programmaticRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { programmaticRef.current = false; });
}, [markdown]);
const handleScroll = useCallback(() => {
if (programmaticRef.current) return; // 忽略程序化滚动
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
setShowScrollBtn((prev) => (prev === !atBottom ? prev : !atBottom));
}, []);
const handleScrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) {
programmaticRef.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
requestAnimationFrame(() => { programmaticRef.current = false; });
}
autoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
return (
<div className="relative h-full flex flex-col overflow-hidden">
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
{children}
</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
return isBlock ? (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
}}
>
{markdown}
</ReactMarkdown>
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
</div>
</div>
{showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div>
);
}
@@ -17,12 +17,18 @@ export interface CanvasUIContextValue {
isChatCollapsed: boolean;
isEditing: boolean;
isWebSearchOpen: boolean;
/** AI 写作:右侧草稿是否正在流式生成。生成中右侧走只读流式视图(自动下滑、禁用编辑/润色/滑词),生成完才可编辑 */
isDraftStreaming: boolean;
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
draftStreamingMarkdown: string;
selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
setIsDraftStreaming: (v: boolean) => void;
setDraftStreamingMarkdown: (v: string) => void;
/** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */
@@ -42,6 +48,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [isDraftStreaming, setIsDraftStreaming] = useState(false);
const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState('');
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
@@ -61,11 +69,15 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isChatCollapsed,
isEditing,
isWebSearchOpen,
isDraftStreaming,
draftStreamingMarkdown,
selection,
writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setIsDraftStreaming,
setDraftStreamingMarkdown,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
@@ -27,6 +27,7 @@ import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../contexts/CanvasUIContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
@@ -140,6 +141,26 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
}
}
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
function assembleStreamingDraftMarkdown(
sections: AIWritingState['streamingDraftSections'],
title: string,
): string {
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const body = ordered
.map((s) => {
const heading = s.sectionTitle ? `## ${s.sectionTitle}` : '';
const text = s.text ?? '';
return heading ? `${heading}\n\n${text}` : text;
})
.filter((part) => part.trim().length > 0)
.join('\n\n');
const head = title ? `# ${title}\n\n` : '';
return `${head}${body}`;
}
// 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用
// (副作用单独提到 hook 顶层 useEffect 里处理)。
function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState {
@@ -270,11 +291,16 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
isDone: done,
});
}
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
// 边写边推送:把已生成的章节实时拼成 Markdown 写进 currentDraftMarkdown,
// 触发顶层 useEffect 把内容同步到右侧画布(无需等 draft_ready)。
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
streamingDraftTitle: streamingTitle,
currentDraftMarkdown: assembleStreamingDraftMarkdown(sections, streamingTitle),
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
};
}
@@ -615,6 +641,7 @@ type AIWritingValues = {
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const { setIsDraftStreaming, setDraftStreamingMarkdown } = useCanvasUIContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
@@ -706,8 +733,20 @@ export function useAIWriting() {
if (ev) dispatch({ type: 'handle', event: ev });
}, [stream.interrupt, state.sessionId]);
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
// 草稿同步到右侧面板:流式生成中与生成完成,走两条互斥通道,三个状态在同一 effect 内
// 原子更新(同一次 commit,React 18 批处理),杜绝"artifact 已刷新但 isDraftStreaming 仍滞后"
// 的时序窗口——那会让带编辑器的 TextArtifactRenderer 在高频 artifact 变化下挂载并触发更新风暴。
// - 生成中(writing_draft / revising):只更新轻量字符串通道 draftStreamingMarkdown,
// **完全不动全局 artifact**,右侧用纯只读组件流式显示,全程不挂载编辑器。
// - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。
useEffect(() => {
const streaming = state.status === 'writing_draft' || state.status === 'revising';
setIsDraftStreaming(streaming);
if (streaming) {
setDraftStreamingMarkdown(state.currentDraftMarkdown);
return;
}
setDraftStreamingMarkdown('');
if (state.currentDraftMarkdown) {
setArtifact({
currentIndex: 0,
@@ -719,7 +758,7 @@ export function useAIWriting() {
}],
});
}
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
}, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown]);
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => {