From 3b5525a0a5cc6240d2874654f1f7f0561d07db91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 2 Jun 2026 22:55:22 +0800 Subject: [PATCH] =?UTF-8?q?ai=E5=86=99=E4=BD=9C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-writing/AIWritingDraftPanel.tsx | 86 ++-- .../components/ai-writing/AIWritingPanel.tsx | 5 +- .../ai-writing/DraftGeneratingView.tsx | 60 ++- .../components/ai-writing/DraftSync.tsx | 46 ++ .../layout/CanvasResizableLayout.tsx | 7 +- .../open-canvas/contexts/AIWritingContext.tsx | 405 ++++++++++++++++++ .../open-canvas/hooks/useAIWritingStream.ts | 327 +------------- .../src/open-canvas/pages/AIWritingPage.tsx | 3 + 8 files changed, 583 insertions(+), 356 deletions(-) create mode 100644 frontend-web/src/open-canvas/components/ai-writing/DraftSync.tsx create mode 100644 frontend-web/src/open-canvas/contexts/AIWritingContext.tsx diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx index 6c0792fa..da07a55f 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx @@ -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 (
@@ -42,8 +57,8 @@ export function AIWritingDraftPanel() { 草稿生成中…
- {draftStreamingMarkdown ? ( - + {streamingMarkdown ? ( + ) : ( )} @@ -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 ( -
- +
+
+ + + {textContent.title || '草稿预览'} + +
+
+ +
); } - const content = getCurrentArtifactContent(artifact); - const isText = content && !isCodeArtifact(content); - return ( -
- {/* 标题栏 */} -
- - - {content?.title ?? '草稿预览'} - -
- -
- {isText ? ( - // 仅"生成完成的稳定态"挂载带编辑器的渲染器(润色 / 滑词); - // 审核中等其它进行中状态用只读预览,避免编辑器在活跃流中挂载导致更新风暴。 - isDraftEditable ? ( - - ) : ( - - ) - ) : null} -
+
+
); } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 6cf45208..183cf4d9 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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 (
+
{historySession.draft_title ?? historySession.title} @@ -287,6 +289,7 @@ export function AIWritingPanel({ return (
+ {/* 头部 */}
-
- - 内容生成中… +
+ {/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */} +
+ +
+ + 内容生成中 + + + + + 正文已在右侧实时显示
-
-
-
-
-
+ +
+ {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({
+