ai写作修改
This commit is contained in:
1 parent
fdfb888be9
commit
3b5525a0a5
8 files changed
+583
-356
No files matched your search
@@ -1,7 +1,9 @@
|
|||||||
import { FileText, PenLine } from 'lucide-react';
|
import { FileText, PenLine } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
|
||||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||||
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
||||||
|
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
|
||||||
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
|
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
|
||||||
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
|
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
|
||||||
import { DraftStreamingPreview } from './DraftStreamingPreview';
|
import { DraftStreamingPreview } from './DraftStreamingPreview';
|
||||||
@@ -29,12 +31,25 @@ function DraftWaitingPlaceholder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AIWritingDraftPanel() {
|
export function AIWritingDraftPanel() {
|
||||||
|
const writing = useAIWritingOptional();
|
||||||
const { artifact } = useCanvasGraphContext();
|
const { artifact } = useCanvasGraphContext();
|
||||||
const { isDraftStreaming, draftStreamingMarkdown, isDraftEditable } = useCanvasUIContext();
|
const { isDraftEditable } = useCanvasUIContext();
|
||||||
|
|
||||||
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
|
const writingStatus = writing?.state.status;
|
||||||
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。
|
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
|
||||||
if (isDraftStreaming) {
|
const isActivelyStreamingDraft =
|
||||||
|
(writingStatus === 'writing_draft' || writingStatus === 'revising') &&
|
||||||
|
(writing?.state.streamingDraftSections.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const streamingMarkdown = isActivelyStreamingDraft && writing
|
||||||
|
? assembleStreamingDraftMarkdown(
|
||||||
|
writing.state.streamingDraftSections,
|
||||||
|
writing.state.streamingDraftTitle || writing.state.outline?.title || '',
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// 流式生成中:只读 Markdown 预览
|
||||||
|
if (isActivelyStreamingDraft) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
<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">
|
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||||
@@ -42,8 +57,8 @@ export function AIWritingDraftPanel() {
|
|||||||
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
|
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{draftStreamingMarkdown ? (
|
{streamingMarkdown ? (
|
||||||
<DraftStreamingPreview markdown={draftStreamingMarkdown} />
|
<DraftStreamingPreview markdown={streamingMarkdown} />
|
||||||
) : (
|
) : (
|
||||||
<DraftWaitingPlaceholder />
|
<DraftWaitingPlaceholder />
|
||||||
)}
|
)}
|
||||||
@@ -52,38 +67,45 @@ export function AIWritingDraftPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!artifact) {
|
const artifactContent = artifact ? getCurrentArtifactContent(artifact) : null;
|
||||||
|
const textFromArtifact =
|
||||||
|
artifactContent && !isCodeArtifact(artifactContent)
|
||||||
|
? (artifactContent as CanvasArtifactText)
|
||||||
|
: null;
|
||||||
|
// draft_ready 后 DraftSync 写 artifact 可能晚一帧;先用 context 里的成稿兜底,避免闪占位符
|
||||||
|
const textContent: CanvasArtifactText | null =
|
||||||
|
textFromArtifact ??
|
||||||
|
(writing?.state.currentDraftMarkdown
|
||||||
|
? {
|
||||||
|
index: 0,
|
||||||
|
type: 'text',
|
||||||
|
title: writing.state.currentDraftTitle || '草稿',
|
||||||
|
fullMarkdown: writing.state.currentDraftMarkdown,
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (textContent) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-muted/10 border-l">
|
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||||
<DraftWaitingPlaceholder />
|
<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">
|
||||||
|
{textContent.title || '草稿预览'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<TextArtifactRenderer
|
||||||
|
content={textContent}
|
||||||
|
isStreaming={!isDraftEditable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 bg-muted/10 border-l">
|
||||||
{/* 标题栏 */}
|
<DraftWaitingPlaceholder />
|
||||||
<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">
|
|
||||||
{content?.title ?? '草稿预览'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{isText ? (
|
|
||||||
// 仅"生成完成的稳定态"挂载带编辑器的渲染器(润色 / 滑词);
|
|
||||||
// 审核中等其它进行中状态用只读预览,避免编辑器在活跃流中挂载导致更新风暴。
|
|
||||||
isDraftEditable ? (
|
|
||||||
<TextArtifactRenderer content={content as CanvasArtifactText} />
|
|
||||||
) : (
|
|
||||||
<DraftStreamingPreview markdown={(content as CanvasArtifactText).fullMarkdown} showCursor={false} />
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
|
|
||||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||||
import { useAIWriting } from '../../hooks/useAIWritingStream';
|
import { useAIWriting } from '../../contexts/AIWritingContext';
|
||||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ import { SkillStepsCard } from './SkillStepsCard';
|
|||||||
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
|
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
|
||||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||||
import { DraftGeneratingView } from './DraftGeneratingView';
|
import { DraftGeneratingView } from './DraftGeneratingView';
|
||||||
|
import { DraftSync } from './DraftSync';
|
||||||
import { IntentResultCard } from './IntentResultCard';
|
import { IntentResultCard } from './IntentResultCard';
|
||||||
import { IntentStreamingView } from './IntentStreamingView';
|
import { IntentStreamingView } from './IntentStreamingView';
|
||||||
import { OutlineStreamingView } from './OutlineStreamingView';
|
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||||
@@ -251,6 +252,7 @@ export function AIWritingPanel({
|
|||||||
if (historySession) {
|
if (historySession) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||||
|
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||||
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
||||||
{historySession.draft_title ?? historySession.title}
|
{historySession.draft_title ?? historySession.title}
|
||||||
@@ -287,6 +289,7 @@ export function AIWritingPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||||
|
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -32,26 +32,66 @@ function ThinkingBox({ text, done }: { text: string; done: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「内容生成中」骨架屏:闪烁的占位行,提示正文正在右侧实时生成 */
|
/** 「内容生成中」骨架屏:流光扫过的占位行 + 写字笔抖动 + 跳动圆点,提示正文正在右侧实时生成 */
|
||||||
|
const SKELETON_WIDTHS = ['92%', '100%', '85%', '96%', '70%'];
|
||||||
|
|
||||||
function GeneratingSkeleton() {
|
function GeneratingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card shadow-sm p-4">
|
<div className="group relative overflow-hidden 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" />
|
<div className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-primary/[0.07] to-transparent animate-[card-sheen_2.8s_ease-in-out_infinite]" />
|
||||||
<span className="text-xs font-medium text-foreground">内容生成中…</span>
|
|
||||||
|
<div className="relative flex items-center gap-2 mb-3">
|
||||||
|
<PenLine className="size-3.5 text-primary animate-[pen-write_1.2s_ease-in-out_infinite] origin-bottom-left" />
|
||||||
|
<span className="text-xs font-medium text-foreground">内容生成中</span>
|
||||||
|
<span className="flex items-end gap-0.5">
|
||||||
|
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_infinite]" />
|
||||||
|
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.2s_infinite]" />
|
||||||
|
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.4s_infinite]" />
|
||||||
|
</span>
|
||||||
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
|
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
|
||||||
<div className="h-3 rounded bg-muted/70 animate-pulse w-[92%]" />
|
<div className="relative space-y-2.5">
|
||||||
<div className="h-3 rounded bg-muted/70 animate-pulse w-full" />
|
{SKELETON_WIDTHS.map((w, i) => (
|
||||||
<div className="h-3 rounded bg-muted/70 animate-pulse w-[85%]" />
|
<div
|
||||||
<div className="h-3 rounded bg-muted/70 animate-pulse w-[70%]" />
|
key={i}
|
||||||
|
className="relative h-3 overflow-hidden rounded-full bg-muted/60"
|
||||||
|
style={{ width: w }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/45 to-transparent dark:via-white/10 animate-[shimmer-sweep_1.5s_ease-in-out_infinite]"
|
||||||
|
style={{ animationDelay: `${i * 0.15}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0; }
|
50% { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
/* 骨架行流光:高光从左扫到右 */
|
||||||
|
@keyframes shimmer-sweep {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(220%); }
|
||||||
|
}
|
||||||
|
/* 卡片整体微高光呼吸 */
|
||||||
|
@keyframes card-sheen {
|
||||||
|
0%, 60%, 100% { transform: translateX(-100%); }
|
||||||
|
80% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
/* 写字笔:轻微上下 + 摆动,像在书写 */
|
||||||
|
@keyframes pen-write {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-1.5px) rotate(-12deg); }
|
||||||
|
}
|
||||||
|
/* 跳动圆点 */
|
||||||
|
@keyframes dot-bounce {
|
||||||
|
0%, 100% { opacity: .35; transform: translateY(0); }
|
||||||
|
50% { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||||
|
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
||||||
|
import type { WritingStatus } from '../../types/ai-writing';
|
||||||
|
|
||||||
|
interface DraftSyncProps {
|
||||||
|
status: WritingStatus;
|
||||||
|
markdown: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 AI 写作草稿在「稳定态」同步到画布 artifact / 编辑权限。
|
||||||
|
*
|
||||||
|
* 流式生成中(writing_draft / revising)**不再**逐 chunk 写入 CanvasUIContext,
|
||||||
|
* 右侧预览改由 ``AIWritingDraftPanel`` 直接从 ``AIWritingContext`` 读章节并渲染 Markdown,
|
||||||
|
* 避免高频 setState 引发更新风暴。
|
||||||
|
*/
|
||||||
|
export function DraftSync({ status, markdown, title }: DraftSyncProps) {
|
||||||
|
const { setArtifact } = useCanvasGraphContext();
|
||||||
|
const { setIsDraftEditable } = useCanvasUIContext();
|
||||||
|
|
||||||
|
const streaming = status === 'writing_draft' || status === 'revising';
|
||||||
|
// 注意:reviewing(编辑审核)不算 generating —— 审核时草稿已定稿、内容稳定,
|
||||||
|
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
|
||||||
|
// writing_draft / revising 才只读。
|
||||||
|
const generating =
|
||||||
|
status === 'researching' ||
|
||||||
|
status === 'writing_outline' ||
|
||||||
|
streaming;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsDraftEditable(!generating);
|
||||||
|
}, [generating, setIsDraftEditable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streaming || !markdown) return;
|
||||||
|
setArtifact({
|
||||||
|
currentIndex: 0,
|
||||||
|
contents: [{ index: 0, type: 'text', title: title || '草稿', fullMarkdown: markdown }],
|
||||||
|
});
|
||||||
|
}, [streaming, markdown, title, setArtifact]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
|
|||||||
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
|
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
|
||||||
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
|
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
|
||||||
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
|
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
|
||||||
|
import { AIWritingProvider } from "../../contexts/AIWritingContext";
|
||||||
|
|
||||||
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
|
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
|
||||||
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
|
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
|
||||||
@@ -65,7 +66,11 @@ export function CanvasResizableLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
|
{leftMode === "chat" ? <CanvasChatPanel /> : (
|
||||||
|
<AIWritingProvider>
|
||||||
|
<AIWritingPanel />
|
||||||
|
</AIWritingProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
type Dispatch,
|
||||||
|
type MutableRefObject,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
} from 'react';
|
||||||
|
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||||
|
|
||||||
|
import { getAPIClient } from '@/core/api';
|
||||||
|
|
||||||
|
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||||
|
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||||
|
import { countWords } from '../utils/wordCount';
|
||||||
|
import {
|
||||||
|
INITIAL_STATE,
|
||||||
|
customToProgressEvent,
|
||||||
|
interruptToEvent,
|
||||||
|
nodeUpdatesToEvents,
|
||||||
|
reducer,
|
||||||
|
type Action,
|
||||||
|
type AIWritingValues,
|
||||||
|
} from '../hooks/useAIWritingStream';
|
||||||
|
import type {
|
||||||
|
AIWritingRequest,
|
||||||
|
AIWritingState,
|
||||||
|
CompletedIntervention,
|
||||||
|
ProgressEvent,
|
||||||
|
UserInterventionPayload,
|
||||||
|
WritingStatus,
|
||||||
|
} from '../types/ai-writing';
|
||||||
|
|
||||||
|
/** 高频 chunk 类型:合并到同一帧再 dispatch,避免同步嵌套 setState 触发 Maximum update depth。 */
|
||||||
|
const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
|
||||||
|
'draft_chunk',
|
||||||
|
'outline_chunk',
|
||||||
|
'intent_chunk',
|
||||||
|
'skill_select_chunk',
|
||||||
|
'review_chunk',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function createProgressEventBatcher(dispatchRef: RefObject<Dispatch<Action>>) {
|
||||||
|
const pending: ProgressEvent[] = [];
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
rafId = 0;
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
const events = pending.splice(0, pending.length);
|
||||||
|
if (events.length === 1) {
|
||||||
|
dispatchRef.current?.({ type: 'handle', event: events[0] });
|
||||||
|
} else {
|
||||||
|
dispatchRef.current?.({ type: 'handle_batch', events });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
push(event: ProgressEvent) {
|
||||||
|
pending.push(event);
|
||||||
|
if (!rafId) rafId = requestAnimationFrame(flush);
|
||||||
|
},
|
||||||
|
pushImmediate(event: ProgressEvent) {
|
||||||
|
// 关键:先把缓冲里的 chunk 同步 flush,再派发立即事件,保证事件顺序。
|
||||||
|
// 否则 *_ready / await_user / step_* 会插队到尚在 rAF 缓冲里的 *_chunk 之前——
|
||||||
|
// ready 先清空流式文本 / 切状态,随后残留 chunk 又被回灌,导致:
|
||||||
|
// 意图/大纲/审核的流式视图清不掉(残留 JSON 尾巴)、切不回结果卡、右侧 markdown 切不回编辑器。
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
flush();
|
||||||
|
dispatchRef.current?.({ type: 'handle', event });
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = 0;
|
||||||
|
pending.length = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIWritingStreamApi = Pick<
|
||||||
|
ReturnType<typeof useStream<AIWritingValues>>,
|
||||||
|
'submit' | 'stop' | 'interrupt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AIWritingApi = {
|
||||||
|
state: AIWritingState;
|
||||||
|
startWriting: (request: AIWritingRequest) => Promise<void>;
|
||||||
|
submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
|
||||||
|
pauseWriting: () => void;
|
||||||
|
resumeWriting: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIWritingContext = createContext<AIWritingApi | null>(null);
|
||||||
|
|
||||||
|
/** 可选读取:不在 Provider 内时返回 null(如 Canvas 布局右侧 artifact 面板)。 */
|
||||||
|
export function useAIWritingOptional(): AIWritingApi | null {
|
||||||
|
return useContext(AIWritingContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在 ``AIWritingProvider`` 内读取写作状态与操作 API。 */
|
||||||
|
export function useAIWriting(): AIWritingApi {
|
||||||
|
const ctx = useContext(AIWritingContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAIWriting must be used within AIWritingProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 承载 ``useStream`` 的独立组件(render null)。
|
||||||
|
*
|
||||||
|
* 用 ``memo`` 且仅随 ``threadId`` 重渲染,这样 ``draft_chunk`` 等高频 custom 事件
|
||||||
|
* 更新 reducer 时不会重跑 ``useStream``,避免 onCustomEvent → dispatch → 重渲染
|
||||||
|
* → useStream 重入 的更新风暴(Maximum update depth exceeded)。
|
||||||
|
*/
|
||||||
|
const AIWritingStreamHost = memo(function AIWritingStreamHost({
|
||||||
|
threadId,
|
||||||
|
dispatchRef,
|
||||||
|
sessionIdRef,
|
||||||
|
streamRef,
|
||||||
|
seenInterruptRef,
|
||||||
|
}: {
|
||||||
|
threadId: string | undefined;
|
||||||
|
dispatchRef: RefObject<Dispatch<Action>>;
|
||||||
|
sessionIdRef: RefObject<string | null>;
|
||||||
|
streamRef: MutableRefObject<AIWritingStreamApi | null>;
|
||||||
|
seenInterruptRef: MutableRefObject<string | null>;
|
||||||
|
}) {
|
||||||
|
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
|
||||||
|
if (!eventBatcherRef.current) {
|
||||||
|
eventBatcherRef.current = createProgressEventBatcher(dispatchRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitProgressEvent = useCallback((event: ProgressEvent) => {
|
||||||
|
if (BATCHED_CHUNK_TYPES.has(event.type)) {
|
||||||
|
eventBatcherRef.current!.push(event);
|
||||||
|
} else {
|
||||||
|
eventBatcherRef.current!.pushImmediate(event);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stream = useStream<AIWritingValues>({
|
||||||
|
client: getAPIClient(),
|
||||||
|
assistantId: 'ai_writing',
|
||||||
|
threadId,
|
||||||
|
reconnectOnMount: true,
|
||||||
|
onCreated(meta) {
|
||||||
|
dispatchRef.current?.({ type: 'set_session', sessionId: meta.thread_id });
|
||||||
|
},
|
||||||
|
onCustomEvent(event: unknown) {
|
||||||
|
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||||
|
const t = (event as { type?: string } | null)?.type;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
|
||||||
|
}
|
||||||
|
const pe = customToProgressEvent(event, sessionIdRef.current ?? '');
|
||||||
|
if (pe) emitProgressEvent(pe);
|
||||||
|
},
|
||||||
|
onUpdateEvent(data: Record<string, unknown> | undefined) {
|
||||||
|
if (!data) return;
|
||||||
|
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
|
||||||
|
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
|
||||||
|
for (const intr of interrupts) {
|
||||||
|
const value = (intr && typeof intr === 'object' && 'value' in intr)
|
||||||
|
? (intr as { value: unknown }).value : intr;
|
||||||
|
const ev = interruptToEvent(value, sessionIdRef.current ?? '');
|
||||||
|
if (ev) emitProgressEvent(ev);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const nodeOutput of Object.values(data)) {
|
||||||
|
const events = nodeUpdatesToEvents(nodeOutput, sessionIdRef.current ?? '');
|
||||||
|
for (const ev of events) emitProgressEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(err: unknown) {
|
||||||
|
eventBatcherRef.current?.cancel();
|
||||||
|
dispatchRef.current?.({
|
||||||
|
type: 'set_error',
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onFinish(finishState) {
|
||||||
|
eventBatcherRef.current?.cancel();
|
||||||
|
const finalStatus = (finishState.values as AIWritingValues | undefined)?.status;
|
||||||
|
if (finalStatus === 'done' || finalStatus === 'error') {
|
||||||
|
dispatchRef.current?.({
|
||||||
|
type: 'handle',
|
||||||
|
event: {
|
||||||
|
type: finalStatus === 'error' ? 'error' : 'done',
|
||||||
|
sessionId: finishState.values?.sessionId as string ?? '',
|
||||||
|
message: finalStatus === 'error' ? '写作出错' : '写作完成',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intr = stream.interrupt;
|
||||||
|
if (!intr) {
|
||||||
|
seenInterruptRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = intr.id ?? JSON.stringify(intr.value);
|
||||||
|
if (seenInterruptRef.current === key) return;
|
||||||
|
seenInterruptRef.current = key;
|
||||||
|
const ev = interruptToEvent(intr.value, sessionIdRef.current ?? '');
|
||||||
|
if (ev) emitProgressEvent(ev);
|
||||||
|
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef]);
|
||||||
|
|
||||||
|
useEffect(() => () => { eventBatcherRef.current?.cancel(); }, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, (prev, next) => prev.threadId === next.threadId);
|
||||||
|
|
||||||
|
export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { setArtifact } = useCanvasGraphContext();
|
||||||
|
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||||
|
const dispatchRef = useRef(dispatch);
|
||||||
|
dispatchRef.current = dispatch;
|
||||||
|
const sessionIdRef = useRef<string | null>(state.sessionId);
|
||||||
|
sessionIdRef.current = state.sessionId;
|
||||||
|
const streamRef = useRef<AIWritingStreamApi | null>(null);
|
||||||
|
const seenInterruptRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const threadId = state.sessionId ?? undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = state.status;
|
||||||
|
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||||
|
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
||||||
|
saveAIWritingTranscript(state.sessionId, {
|
||||||
|
progressEvents: state.progressEvents,
|
||||||
|
completedInterventions: state.completedInterventions,
|
||||||
|
}).catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.status, state.sessionId]);
|
||||||
|
|
||||||
|
const startWriting = useCallback(async (request: AIWritingRequest) => {
|
||||||
|
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||||
|
dispatch({ type: 'start', request });
|
||||||
|
const stream = streamRef.current;
|
||||||
|
if (!stream) return;
|
||||||
|
try {
|
||||||
|
await stream.submit(
|
||||||
|
{
|
||||||
|
user_intent: request.userIntent,
|
||||||
|
article_type: request.articleType,
|
||||||
|
target_audience: request.targetAudience,
|
||||||
|
word_count_target: request.wordCountTarget,
|
||||||
|
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||||
|
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||||
|
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||||
|
revision_count: 0,
|
||||||
|
max_revisions: 3,
|
||||||
|
status: 'researching',
|
||||||
|
drafts: [],
|
||||||
|
progress_events: [],
|
||||||
|
material_package: null,
|
||||||
|
current_outline: null,
|
||||||
|
current_draft: null,
|
||||||
|
review_result: null,
|
||||||
|
last_user_intervention: null,
|
||||||
|
} as Partial<AIWritingValues>,
|
||||||
|
{
|
||||||
|
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
||||||
|
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}, [state.status]);
|
||||||
|
|
||||||
|
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
||||||
|
if (!state.sessionId || !state.currentPause) return;
|
||||||
|
|
||||||
|
const stream = streamRef.current;
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
const pausePoint = state.currentPause.pausePoint;
|
||||||
|
const record: CompletedIntervention = {
|
||||||
|
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||||
|
action: payload.action,
|
||||||
|
};
|
||||||
|
if (pausePoint === 'material_confirm') record.materials = state.materials;
|
||||||
|
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||||
|
else if (pausePoint === 'draft_confirm') {
|
||||||
|
record.draftTitle = state.currentDraftTitle;
|
||||||
|
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||||
|
record.reviewResult = state.reviewResult ?? undefined;
|
||||||
|
} else if (pausePoint === 'review_confirm') {
|
||||||
|
record.reviewResult = state.reviewResult ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
||||||
|
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
|
||||||
|
if (isApplyingReview && state.reviewResult) {
|
||||||
|
const allIssues = state.reviewResult.revisionNotes;
|
||||||
|
const selectedSet = new Set(payload.selectedIssueIndices!);
|
||||||
|
pendingRevisionContext = {
|
||||||
|
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
||||||
|
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
|
||||||
|
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextStatus: WritingStatus = state.status;
|
||||||
|
if (pausePoint === 'material_confirm') {
|
||||||
|
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
|
||||||
|
? 'researching' : 'writing_outline';
|
||||||
|
} else if (pausePoint === 'outline_confirm') {
|
||||||
|
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||||
|
} else if (pausePoint === 'draft_confirm') {
|
||||||
|
if (payload.action === 'finalize') nextStatus = 'done';
|
||||||
|
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
||||||
|
else nextStatus = 'revising';
|
||||||
|
} else if (pausePoint === 'review_confirm') {
|
||||||
|
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
|
||||||
|
} else if (pausePoint === 'section_help') {
|
||||||
|
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
|
||||||
|
? 'researching' : 'writing_draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumePayload: Record<string, unknown> = { action: payload.action };
|
||||||
|
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
|
||||||
|
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
|
||||||
|
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
|
||||||
|
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
|
||||||
|
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
|
||||||
|
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
|
||||||
|
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
|
||||||
|
if (payload.sectionDecisions !== undefined) {
|
||||||
|
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
|
||||||
|
sectionTitle: d.sectionTitle, decision: d.decision,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const intr = stream.interrupt;
|
||||||
|
if (intr) {
|
||||||
|
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stream.submit(undefined, {
|
||||||
|
command: { resume: resumePayload },
|
||||||
|
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||||
|
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||||
|
state.status]);
|
||||||
|
|
||||||
|
const pauseWriting = useCallback(() => {
|
||||||
|
void streamRef.current?.stop();
|
||||||
|
dispatch({ type: 'pause' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resumeWriting = useCallback(() => {
|
||||||
|
dispatch({ type: 'resume_ui' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
void streamRef.current?.stop();
|
||||||
|
setArtifact(null);
|
||||||
|
seenInterruptRef.current = null;
|
||||||
|
dispatch({ type: 'reset' });
|
||||||
|
}, [setArtifact]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
|
||||||
|
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIWritingContext.Provider value={value}>
|
||||||
|
<AIWritingStreamHost
|
||||||
|
threadId={threadId}
|
||||||
|
dispatchRef={dispatchRef}
|
||||||
|
sessionIdRef={sessionIdRef}
|
||||||
|
streamRef={streamRef}
|
||||||
|
seenInterruptRef={seenInterruptRef}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</AIWritingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,14 +21,6 @@
|
|||||||
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
||||||
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
|
||||||
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 { countWords } from '../utils/wordCount';
|
||||||
import type {
|
import type {
|
||||||
AIWritingRequest,
|
AIWritingRequest,
|
||||||
@@ -48,8 +40,6 @@ import type {
|
|||||||
WritingStatus,
|
WritingStatus,
|
||||||
} from '../types/ai-writing';
|
} from '../types/ai-writing';
|
||||||
|
|
||||||
// ── INITIAL_STATE 与 reducer 与老 hook 共享 ────────────────────────────────────
|
|
||||||
|
|
||||||
const INITIAL_STATE: AIWritingState = {
|
const INITIAL_STATE: AIWritingState = {
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@@ -82,10 +72,13 @@ const INITIAL_STATE: AIWritingState = {
|
|||||||
pendingRevisionContext: null,
|
pendingRevisionContext: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { INITIAL_STATE };
|
||||||
|
|
||||||
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
|
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
|
||||||
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
|
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
|
||||||
type Action =
|
export type Action =
|
||||||
| { type: 'handle'; event: ProgressEvent }
|
| { type: 'handle'; event: ProgressEvent }
|
||||||
|
| { type: 'handle_batch'; events: ProgressEvent[] }
|
||||||
| { type: 'start'; request: AIWritingRequest }
|
| { type: 'start'; request: AIWritingRequest }
|
||||||
| { type: 'set_session'; sessionId: string }
|
| { type: 'set_session'; sessionId: string }
|
||||||
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
|
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
|
||||||
@@ -94,11 +87,14 @@ type Action =
|
|||||||
| { type: 'reset' }
|
| { type: 'reset' }
|
||||||
| { type: 'set_error'; message: string };
|
| { type: 'set_error'; message: string };
|
||||||
|
|
||||||
function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'handle':
|
case 'handle':
|
||||||
return handleEvent(prev, action.event);
|
return handleEvent(prev, action.event);
|
||||||
|
|
||||||
|
case 'handle_batch':
|
||||||
|
return action.events.reduce(handleEvent, prev);
|
||||||
|
|
||||||
case 'start':
|
case 'start':
|
||||||
return { ...INITIAL_STATE, status: 'researching', request: action.request };
|
return { ...INITIAL_STATE, status: 'researching', request: action.request };
|
||||||
|
|
||||||
@@ -144,7 +140,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
|||||||
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
|
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
|
||||||
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
|
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
|
||||||
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
|
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
|
||||||
function assembleStreamingDraftMarkdown(
|
export function assembleStreamingDraftMarkdown(
|
||||||
sections: AIWritingState['streamingDraftSections'],
|
sections: AIWritingState['streamingDraftSections'],
|
||||||
title: string,
|
title: string,
|
||||||
): string {
|
): string {
|
||||||
@@ -292,14 +288,13 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
|
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
|
||||||
// 边写边推送:把已生成的章节实时拼成 Markdown 写进 currentDraftMarkdown,
|
// 流式阶段只累积章节片段;右侧 Markdown 由面板按需拼装,避免每 chunk 改 currentDraftMarkdown
|
||||||
// 触发顶层 useEffect 把内容同步到右侧画布(无需等 draft_ready)。
|
// 触发 DraftSync / 全树重渲染导致更新风暴。
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
status: 'writing_draft',
|
status: 'writing_draft',
|
||||||
streamingDraftSections: sections,
|
streamingDraftSections: sections,
|
||||||
streamingDraftTitle: streamingTitle,
|
streamingDraftTitle: streamingTitle,
|
||||||
currentDraftMarkdown: assembleStreamingDraftMarkdown(sections, streamingTitle),
|
|
||||||
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
|
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -508,7 +503,7 @@ function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
|
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
|
||||||
function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
|
export function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
const o = raw as Record<string, unknown>;
|
const o = raw as Record<string, unknown>;
|
||||||
const type = String(o.type ?? '');
|
const type = String(o.type ?? '');
|
||||||
@@ -541,7 +536,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */
|
/** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */
|
||||||
function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
|
export function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
|
||||||
if (!nodeOutput || typeof nodeOutput !== 'object') return [];
|
if (!nodeOutput || typeof nodeOutput !== 'object') return [];
|
||||||
const out = nodeOutput as Record<string, unknown>;
|
const out = nodeOutput as Record<string, unknown>;
|
||||||
const events: ProgressEvent[] = [];
|
const events: ProgressEvent[] = [];
|
||||||
@@ -605,7 +600,7 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ``stream.interrupt.value`` → await_user ProgressEvent。 */
|
/** ``stream.interrupt.value`` → await_user ProgressEvent。 */
|
||||||
function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
|
export function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const v = value as Record<string, unknown>;
|
const v = value as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -626,9 +621,7 @@ function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | nu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 主 hook ───────────────────────────────────────────────────────────────────
|
export type AIWritingValues = {
|
||||||
|
|
||||||
type AIWritingValues = {
|
|
||||||
user_intent?: string;
|
user_intent?: string;
|
||||||
article_type?: string;
|
article_type?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -638,293 +631,3 @@ type AIWritingValues = {
|
|||||||
review_result?: unknown;
|
review_result?: unknown;
|
||||||
progress_events?: unknown[];
|
progress_events?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useAIWriting() {
|
|
||||||
const { setArtifact } = useCanvasGraphContext();
|
|
||||||
const { setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable } = useCanvasUIContext();
|
|
||||||
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
|
||||||
|
|
||||||
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
|
|
||||||
// 真实 thread_id,我们存到 state.sessionId。之后 reducer 直接读它。
|
|
||||||
const threadId = state.sessionId ?? undefined;
|
|
||||||
|
|
||||||
// 把已处理的 interrupt id 记下来,避免 useStream re-render 时重复 dispatch
|
|
||||||
const seenInterruptRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const stream = useStream<AIWritingValues>({
|
|
||||||
client: getAPIClient(),
|
|
||||||
assistantId: 'ai_writing',
|
|
||||||
threadId,
|
|
||||||
reconnectOnMount: true,
|
|
||||||
onCreated(meta) {
|
|
||||||
// 第一次提交时拿到的 thread_id == 业务表 ai_writing_sessions.id
|
|
||||||
dispatch({ type: 'set_session', sessionId: meta.thread_id });
|
|
||||||
},
|
|
||||||
onCustomEvent(event: unknown) {
|
|
||||||
// 技能检索时机 trace:开 window.__AI_WRITING_DEBUG__ = true 后,控制台按
|
|
||||||
// performance.now() 打印每条 custom 事件到达时刻 + type,用来钉死
|
|
||||||
// skill_search_step(技能检索过程卡)与 skill_select_chunk(筛选技能打字)
|
|
||||||
// 的实际到达顺序,区分"后端未实时 emit" vs "前端渲染滞后"。
|
|
||||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
|
||||||
const t = (event as { type?: string } | null)?.type;
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
|
|
||||||
}
|
|
||||||
const pe = customToProgressEvent(event, state.sessionId ?? '');
|
|
||||||
if (pe) dispatch({ type: 'handle', event: pe });
|
|
||||||
},
|
|
||||||
onUpdateEvent(data: Record<string, unknown> | undefined) {
|
|
||||||
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
|
|
||||||
if (!data) return;
|
|
||||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
|
|
||||||
}
|
|
||||||
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
|
|
||||||
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
|
|
||||||
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
|
|
||||||
// 这里直接合成 await_user 不至于卡死 UI。
|
|
||||||
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
|
|
||||||
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
|
|
||||||
for (const intr of interrupts) {
|
|
||||||
const value = (intr && typeof intr === 'object' && 'value' in intr)
|
|
||||||
? (intr as { value: unknown }).value : intr;
|
|
||||||
const ev = interruptToEvent(value, state.sessionId ?? '');
|
|
||||||
if (ev) dispatch({ type: 'handle', event: ev });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const nodeOutput of Object.values(data)) {
|
|
||||||
const events = nodeUpdatesToEvents(nodeOutput, state.sessionId ?? '');
|
|
||||||
for (const ev of events) dispatch({ type: 'handle', event: ev });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError(err: unknown) {
|
|
||||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
|
||||||
},
|
|
||||||
onFinish(state) {
|
|
||||||
// graph 自然终止(END)—— 合成一条 done 事件,UI 切到 done 状态
|
|
||||||
const finalStatus = (state.values as AIWritingValues | undefined)?.status;
|
|
||||||
if (finalStatus === 'done' || finalStatus === 'error') {
|
|
||||||
dispatch({
|
|
||||||
type: 'handle',
|
|
||||||
event: {
|
|
||||||
type: finalStatus === 'error' ? 'error' : 'done',
|
|
||||||
sessionId: state.values?.sessionId as string ?? '',
|
|
||||||
message: finalStatus === 'error' ? '写作出错' : '写作完成',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// interrupt → await_user:useStream 每次 re-render 都会暴露当前 interrupt,
|
|
||||||
// 我们用 id 去重;老 hook 的 reducer 本身对 pausePoint 也有去重,双保险。
|
|
||||||
useEffect(() => {
|
|
||||||
const intr = stream.interrupt;
|
|
||||||
if (!intr) {
|
|
||||||
seenInterruptRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const key = intr.id ?? JSON.stringify(intr.value);
|
|
||||||
if (seenInterruptRef.current === key) return;
|
|
||||||
seenInterruptRef.current = key;
|
|
||||||
const ev = interruptToEvent(intr.value, state.sessionId ?? '');
|
|
||||||
if (ev) dispatch({ type: 'handle', event: ev });
|
|
||||||
}, [stream.interrupt, state.sessionId]);
|
|
||||||
|
|
||||||
// 草稿同步到右侧面板:流式生成中与生成完成,走两条互斥通道,三个状态在同一 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';
|
|
||||||
// 仅"暂停等待用户 / 完成"这类已停止生成的稳定态才允许编辑(挂载编辑器)——此时没有事件流,
|
|
||||||
// 编辑器稳定;研究 / 大纲 / 写作 / 修改 / 审核等任一进行中状态都只读,杜绝编辑器在活跃流中挂载。
|
|
||||||
const editable = state.status.startsWith('awaiting_') || state.status === 'done';
|
|
||||||
setIsDraftStreaming(streaming);
|
|
||||||
setIsDraftEditable(editable);
|
|
||||||
if (streaming) {
|
|
||||||
setDraftStreamingMarkdown(state.currentDraftMarkdown);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraftStreamingMarkdown('');
|
|
||||||
if (state.currentDraftMarkdown) {
|
|
||||||
setArtifact({
|
|
||||||
currentIndex: 0,
|
|
||||||
contents: [{
|
|
||||||
index: 0,
|
|
||||||
type: 'text',
|
|
||||||
title: state.currentDraftTitle || '草稿',
|
|
||||||
fullMarkdown: state.currentDraftMarkdown,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable]);
|
|
||||||
|
|
||||||
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
|
|
||||||
useEffect(() => {
|
|
||||||
const s = state.status;
|
|
||||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
|
||||||
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
|
||||||
saveAIWritingTranscript(state.sessionId, {
|
|
||||||
progressEvents: state.progressEvents,
|
|
||||||
completedInterventions: state.completedInterventions,
|
|
||||||
}).catch(() => {});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [state.status, state.sessionId]);
|
|
||||||
|
|
||||||
// ── 操作 API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const startWriting = useCallback(async (request: AIWritingRequest) => {
|
|
||||||
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
|
||||||
dispatch({ type: 'start', request });
|
|
||||||
try {
|
|
||||||
await stream.submit(
|
|
||||||
{
|
|
||||||
user_intent: request.userIntent,
|
|
||||||
article_type: request.articleType,
|
|
||||||
target_audience: request.targetAudience,
|
|
||||||
word_count_target: request.wordCountTarget,
|
|
||||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
|
||||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
|
||||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
|
||||||
revision_count: 0,
|
|
||||||
max_revisions: 3,
|
|
||||||
status: 'researching',
|
|
||||||
drafts: [],
|
|
||||||
progress_events: [],
|
|
||||||
material_package: null,
|
|
||||||
current_outline: null,
|
|
||||||
current_draft: null,
|
|
||||||
review_result: null,
|
|
||||||
last_user_intervention: null,
|
|
||||||
} as Partial<AIWritingValues>,
|
|
||||||
{
|
|
||||||
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
|
||||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
|
||||||
}
|
|
||||||
}, [state.status, stream]);
|
|
||||||
|
|
||||||
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
|
||||||
if (!state.sessionId || !state.currentPause) return;
|
|
||||||
|
|
||||||
const pausePoint = state.currentPause.pausePoint;
|
|
||||||
const record: CompletedIntervention = {
|
|
||||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
|
||||||
action: payload.action,
|
|
||||||
};
|
|
||||||
if (pausePoint === 'material_confirm') record.materials = state.materials;
|
|
||||||
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
|
||||||
else if (pausePoint === 'draft_confirm') {
|
|
||||||
record.draftTitle = state.currentDraftTitle;
|
|
||||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
|
||||||
record.reviewResult = state.reviewResult ?? undefined;
|
|
||||||
} else if (pausePoint === 'review_confirm') {
|
|
||||||
record.reviewResult = state.reviewResult ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// pendingRevisionContext:编辑打回卡提交时记录本轮被选中 / 跳过的意见,draft_ready 后用于生成摘要
|
|
||||||
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
|
||||||
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
|
|
||||||
if (isApplyingReview && state.reviewResult) {
|
|
||||||
const allIssues = state.reviewResult.revisionNotes;
|
|
||||||
const selectedSet = new Set(payload.selectedIssueIndices!);
|
|
||||||
pendingRevisionContext = {
|
|
||||||
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
|
||||||
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
|
|
||||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 乐观更新到下一阶段,跟老 hook 行为对齐
|
|
||||||
let nextStatus: WritingStatus = state.status;
|
|
||||||
if (pausePoint === 'material_confirm') {
|
|
||||||
// re_search / enable_skill_search 都回到 researching;confirm 才进大纲
|
|
||||||
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
|
|
||||||
? 'researching' : 'writing_outline';
|
|
||||||
} else if (pausePoint === 'outline_confirm') {
|
|
||||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
|
||||||
} else if (pausePoint === 'draft_confirm') {
|
|
||||||
if (payload.action === 'finalize') nextStatus = 'done';
|
|
||||||
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
|
||||||
else nextStatus = 'revising';
|
|
||||||
} else if (pausePoint === 'review_confirm') {
|
|
||||||
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
|
|
||||||
} else if (pausePoint === 'section_help') {
|
|
||||||
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
|
|
||||||
? 'researching' : 'writing_draft';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 后端 pause 节点期望的 resume payload 字段名:保持 camelCase(节点里直接 user_input.get('action')
|
|
||||||
// / approvedMaterialIds 等),所以这里**不要**转 snake_case。
|
|
||||||
const resumePayload: Record<string, unknown> = {
|
|
||||||
action: payload.action,
|
|
||||||
};
|
|
||||||
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
|
|
||||||
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
|
|
||||||
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
|
|
||||||
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
|
|
||||||
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
|
|
||||||
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
|
|
||||||
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
|
|
||||||
if (payload.sectionDecisions !== undefined) {
|
|
||||||
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
|
|
||||||
sectionTitle: d.sectionTitle, decision: d.decision,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
seenInterruptRef.current = null; // 允许下一个 interrupt 被消费
|
|
||||||
|
|
||||||
// ⚠️ 乐观更新必须放在 await 之前 ——
|
|
||||||
// ``stream.submit(undefined, {command})`` 返回的 Promise **要等整条流跑完**
|
|
||||||
// (resume → 跑 writer_outline → 推 outline_chunk → 到达 pause_outline_confirm
|
|
||||||
// → 推新 interrupt → end) 才 resolve。这中间 useEffect(stream.interrupt) 已经
|
|
||||||
// 把 currentPause 更新到了 outline_confirm;若我们在 await 后才 dispatch
|
|
||||||
// ``intervention_submitted``(含 currentPause: null),就会把新弹的卡片擦掉,
|
|
||||||
// UI 永远卡在 writing_outline skeleton。
|
|
||||||
//
|
|
||||||
// 老 hook 没这个问题:``resumeWritingApi`` 是 POST /resume,几十毫秒就返回。
|
|
||||||
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stream.submit(undefined, {
|
|
||||||
command: { resume: resumePayload },
|
|
||||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
|
||||||
}
|
|
||||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
|
||||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
|
||||||
state.status, stream]);
|
|
||||||
|
|
||||||
const pauseWriting = useCallback(() => {
|
|
||||||
void stream.stop();
|
|
||||||
dispatch({ type: 'pause' });
|
|
||||||
}, [stream]);
|
|
||||||
|
|
||||||
const resumeWriting = useCallback(() => {
|
|
||||||
// useStream 在挂载 / threadId 变化时会自动 reconnect,不需要显式 attach。
|
|
||||||
dispatch({ type: 'resume_ui' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
void stream.stop();
|
|
||||||
setArtifact(null);
|
|
||||||
seenInterruptRef.current = null;
|
|
||||||
dispatch({ type: 'reset' });
|
|
||||||
}, [stream, setArtifact]);
|
|
||||||
|
|
||||||
// 与老 hook 同名导出对象
|
|
||||||
return useMemo(
|
|
||||||
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
|
|
||||||
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -90,6 +90,7 @@ import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
|
|||||||
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||||
|
import { AIWritingProvider } from '../contexts/AIWritingContext';
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -114,6 +115,7 @@ export default function AIWritingPage({
|
|||||||
<CanvasThreadProvider>
|
<CanvasThreadProvider>
|
||||||
<CanvasGraphProvider>
|
<CanvasGraphProvider>
|
||||||
<CanvasUIProvider>
|
<CanvasUIProvider>
|
||||||
|
<AIWritingProvider>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -151,6 +153,7 @@ export default function AIWritingPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</AIWritingProvider>
|
||||||
</CanvasUIProvider>
|
</CanvasUIProvider>
|
||||||
</CanvasGraphProvider>
|
</CanvasGraphProvider>
|
||||||
</CanvasThreadProvider>
|
</CanvasThreadProvider>
|
||||||
|
|||||||
Reference in new issue
Block a user