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