-
-
内容生成中…
+
+ {/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */}
+
+
+
+
+
内容生成中
+
+
+
+
+
正文已在右侧实时显示
-
-
-
-
-
+
+
+ {SKELETON_WIDTHS.map((w, i) => (
+
+ ))}
+
);
diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftSync.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftSync.tsx
new file mode 100644
index 00000000..42f9d1d7
--- /dev/null
+++ b/frontend-web/src/open-canvas/components/ai-writing/DraftSync.tsx
@@ -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;
+}
diff --git a/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx b/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
index c70b3871..8c45edcf 100644
--- a/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
+++ b/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
@@ -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() {
- {leftMode === "chat" ?
:
}
+ {leftMode === "chat" ?
: (
+
+
+
+ )}
diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
new file mode 100644
index 00000000..9241bd54
--- /dev/null
+++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
@@ -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
([
+ 'draft_chunk',
+ 'outline_chunk',
+ 'intent_chunk',
+ 'skill_select_chunk',
+ 'review_chunk',
+]);
+
+function createProgressEventBatcher(dispatchRef: RefObject>) {
+ 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>,
+ 'submit' | 'stop' | 'interrupt'
+>;
+
+export type AIWritingApi = {
+ state: AIWritingState;
+ startWriting: (request: AIWritingRequest) => Promise;
+ submitIntervention: (payload: UserInterventionPayload) => Promise;
+ pauseWriting: () => void;
+ resumeWriting: () => void;
+ reset: () => void;
+};
+
+const AIWritingContext = createContext(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>;
+ sessionIdRef: RefObject;
+ streamRef: MutableRefObject;
+ seenInterruptRef: MutableRefObject;
+}) {
+ const eventBatcherRef = useRef | 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({
+ 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 | 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).__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(state.sessionId);
+ sessionIdRef.current = state.sessionId;
+ const streamRef = useRef(null);
+ const seenInterruptRef = useRef(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,
+ {
+ config: { configurable: { model_name: request.modelName ?? '' } } as Record,
+ 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 = { 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 (
+
+
+ {children}
+
+ );
+}
diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
index 144b96a3..0f855879 100644
--- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
+++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
@@ -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, 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;
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;
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;
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(null);
-
- const stream = useStream({
- 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 | 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).__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,
- {
- config: { configurable: { model_name: request.modelName ?? '' } } as Record,
- 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 = {
- 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],
- );
-}
diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
index 954c89d0..716f4610 100644
--- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
+++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
@@ -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({
+
@@ -151,6 +153,7 @@ export default function AIWritingPage({
+