ai写作修改

This commit is contained in:
董宏凯 committed 2026-06-02 22:55:22 +08:00
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 { useAIWritingOptional } from '../../contexts/AIWritingContext';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import { DraftStreamingPreview } from './DraftStreamingPreview';
@@ -29,12 +31,25 @@ function DraftWaitingPlaceholder() {
}
export function AIWritingDraftPanel() {
const writing = useAIWritingOptional();
const { artifact } = useCanvasGraphContext();
const { isDraftStreaming, draftStreamingMarkdown, isDraftEditable } = useCanvasUIContext();
const { isDraftEditable } = useCanvasUIContext();
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。
if (isDraftStreaming) {
const writingStatus = writing?.state.status;
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
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 (
<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">
@@ -42,8 +57,8 @@ export function AIWritingDraftPanel() {
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
</div>
<div className="flex-1 overflow-hidden">
{draftStreamingMarkdown ? (
<DraftStreamingPreview markdown={draftStreamingMarkdown} />
{streamingMarkdown ? (
<DraftStreamingPreview markdown={streamingMarkdown} />
) : (
<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 (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
<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">
{textContent.title || '草稿预览'}
</span>
</div>
<div className="flex-1 overflow-hidden">
<TextArtifactRenderer
content={textContent}
isStreaming={!isDraftEditable}
/>
</div>
</div>
);
}
const content = getCurrentArtifactContent(artifact);
const isText = content && !isCodeArtifact(content);
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">
{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 className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
@@ -14,7 +14,7 @@ import {
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWritingStream';
import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
@@ -42,6 +42,7 @@ import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftGeneratingView } from './DraftGeneratingView';
import { DraftSync } from './DraftSync';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
@@ -251,6 +252,7 @@ export function AIWritingPanel({
if (historySession) {
return (
<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">
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
{historySession.draft_title ?? historySession.title}
@@ -287,6 +289,7 @@ export function AIWritingPanel({
return (
<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">
<span
@@ -32,26 +32,66 @@ function ThinkingBox({ text, done }: { text: string; done: boolean }) {
);
}
/** 「内容生成中」骨架屏:闪烁的占位行,提示正文正在右侧实时生成 */
/** 「内容生成中」骨架屏:流光扫过的占位行 + 写字笔抖动 + 跳动圆点,提示正文正在右侧实时生成 */
const SKELETON_WIDTHS = ['92%', '100%', '85%', '96%', '70%'];
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>
<div className="group relative overflow-hidden rounded-xl border bg-card shadow-sm p-4">
{/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */}
<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]" />
<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>
</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 className="relative space-y-2.5">
{SKELETON_WIDTHS.map((w, i) => (
<div
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>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
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>
</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 { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
import { AIWritingProvider } from "../../contexts/AIWritingContext";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
@@ -65,7 +66,11 @@ export function CanvasResizableLayout() {
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
{leftMode === "chat" ? <CanvasChatPanel /> : (
<AIWritingProvider>
<AIWritingPanel />
</AIWritingProvider>
)}
</div>
</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 共享同一份代码,
* 只换"事件源"。阶段 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 type {
AIWritingRequest,
@@ -48,8 +40,6 @@ import type {
WritingStatus,
} from '../types/ai-writing';
// ── INITIAL_STATE 与 reducer 与老 hook 共享 ────────────────────────────────────
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
@@ -82,10 +72,13 @@ const INITIAL_STATE: AIWritingState = {
pendingRevisionContext: null,
};
export { INITIAL_STATE };
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
type Action =
export type Action =
| { type: 'handle'; event: ProgressEvent }
| { type: 'handle_batch'; events: ProgressEvent[] }
| { type: 'start'; request: AIWritingRequest }
| { type: 'set_session'; sessionId: string }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
@@ -94,11 +87,14 @@ type Action =
| { type: 'reset' }
| { type: 'set_error'; message: string };
function reducer(prev: AIWritingState, action: Action): AIWritingState {
export function reducer(prev: AIWritingState, action: Action): AIWritingState {
switch (action.type) {
case 'handle':
return handleEvent(prev, action.event);
case 'handle_batch':
return action.events.reduce(handleEvent, prev);
case 'start':
return { ...INITIAL_STATE, status: 'researching', request: action.request };
@@ -144,7 +140,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
function assembleStreamingDraftMarkdown(
export function assembleStreamingDraftMarkdown(
sections: AIWritingState['streamingDraftSections'],
title: string,
): string {
@@ -292,14 +288,13 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
});
}
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
// 边写边推送:把已生成的章节实时拼成 Markdown 写进 currentDraftMarkdown,
// 触发顶层 useEffect 把内容同步到右侧画布(无需等 draft_ready)。
// 流式阶段只累积章节片段;右侧 Markdown 由面板按需拼装,避免每 chunk 改 currentDraftMarkdown
// 触发 DraftSync / 全树重渲染导致更新风暴。
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: streamingTitle,
currentDraftMarkdown: assembleStreamingDraftMarkdown(sections, streamingTitle),
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
};
}
@@ -508,7 +503,7 @@ function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: strin
}
/** 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;
const o = raw as Record<string, unknown>;
const type = String(o.type ?? '');
@@ -541,7 +536,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
}
/** 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 [];
const out = nodeOutput as Record<string, unknown>;
const events: ProgressEvent[] = [];
@@ -605,7 +600,7 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
}
/** ``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;
const v = value as Record<string, unknown>;
return {
@@ -626,9 +621,7 @@ function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | nu
};
}
// ── 主 hook ───────────────────────────────────────────────────────────────────
type AIWritingValues = {
export type AIWritingValues = {
user_intent?: string;
article_type?: string;
status?: string;
@@ -638,293 +631,3 @@ type AIWritingValues = {
review_result?: 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 { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { AIWritingProvider } from '../contexts/AIWritingContext';
import {
ResizableHandle,
ResizablePanel,
@@ -114,6 +115,7 @@ export default function AIWritingPage({
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
@@ -151,6 +153,7 @@ export default function AIWritingPage({
</div>
</div>
</TooltipProvider>
</AIWritingProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>