diff --git a/frontend-web/src/open-canvas/api/ai-writing.ts b/frontend-web/src/open-canvas/api/ai-writing.ts index f8fafe98..a4dea3e2 100644 --- a/frontend-web/src/open-canvas/api/ai-writing.ts +++ b/frontend-web/src/open-canvas/api/ai-writing.ts @@ -29,8 +29,10 @@ export async function startWritingSession(req: AIWritingRequest): Promise = { idle: '就绪', @@ -30,6 +33,8 @@ export function AIWritingPanel() { const isDone = state.status === 'done'; const isError = state.status === 'error'; const isRunning = !isIdle && !isDone && !isError; + // 有干预卡时不显示骨架屏(干预卡本身就是内容) + const showSkeleton = isRunning && !state.currentPause; return (
@@ -80,6 +85,32 @@ export function AIWritingPanel() { /> )} + {/* 大纲流式思考框 */} + {state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && ( + + )} + + {/* 草稿流式打字机视图 */} + {state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && ( + + )} + + {/* 等待骨架屏:模型正在计算时显示,有干预卡则隐藏,写作时由流式视图代替 */} + {showSkeleton && state.status !== 'writing_draft' && ( +
+ +
+ )} + {/* 草稿阶段:流式数据还未到达前显示骨架 */} + {state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && ( +
+ +
+ )} + {/* 出错提示 */} {isError && state.error && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx new file mode 100644 index 00000000..11fb4704 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; +import { Brain, ChevronDown, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { StreamingDraftSection } from '../../types/ai-writing'; + +interface DraftStreamingViewProps { + title: string; + sections: StreamingDraftSection[]; +} + +function TypewriterCursor() { + return ( + + ); +} + +function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) { + const [open, setOpen] = useState(true); + const preRef = useRef(null); + + useEffect(() => { + if (open && preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }, [thinkingText, open]); + + if (!thinkingText) return null; + + return ( +
+ + {open && ( +
+          {thinkingText}
+          {!isDone && (
+            
+          )}
+        
+ )} +
+ ); +} + +export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [sections]); + + const activeSectionIdx = sections.reduce( + (max, s) => (!s.isDone ? Math.max(max, s.sectionIndex) : max), + -1, + ); + + if (sections.length === 0) return null; + + return ( +
+
+ {title && ( +

{title}

+ )} + + {sections.map((sec) => ( +
+

+ {sec.sectionTitle} +

+ + {/* 该章节的思考过程(可折叠) */} + + + {/* 正文内容 */} +

+ {sec.text} + {sec.sectionIndex === activeSectionIdx && !sec.isDone && } +

+
+ ))} + +
+
+ + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx new file mode 100644 index 00000000..4ac3de84 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from 'react'; +import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react'; + +interface OutlineStreamingViewProps { + text: string; + thinkingText?: string; +} + +export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) { + const preRef = useRef(null); + const thinkRef = useRef(null); + const [thinkOpen, setThinkOpen] = useState(true); + + useEffect(() => { + if (preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }, [text]); + + useEffect(() => { + if (thinkOpen && thinkRef.current) { + thinkRef.current.scrollTop = thinkRef.current.scrollHeight; + } + }, [thinkingText, thinkOpen]); + + if (!text && !thinkingText) return null; + + return ( +
+ {/* 思考过程区(仅在有思考内容时展示) */} + {thinkingText && ( +
+ + {thinkOpen && ( +
+              {thinkingText}
+              {/* 思考仍在进行时显示光标 */}
+              {!text && (
+                
+              )}
+            
+ )} +
+ )} + + {/* 正文流式区 */} + {text && ( +
+
+ + + 作家正在规划大纲… + + + {[0, 1, 2].map((i) => ( + + ))} + +
+
+            {text}
+            
+          
+
+ )} + + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx index a16d994a..0e653e2b 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx @@ -10,9 +10,10 @@ interface TimelineStepProps { children?: React.ReactNode; } -function statusOf(event: ProgressEvent): StepStatus { +function statusOf(event: ProgressEvent, isLast: boolean): StepStatus { if (event.type === 'await_user') return 'paused'; - if (event.type === 'step_start') return 'running'; + // step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成 + if (event.type === 'step_start') return isLast ? 'running' : 'done'; return 'done'; } @@ -24,7 +25,7 @@ const AGENT_COLOR: Record = { }; export function TimelineStep({ event, isLast, children }: TimelineStepProps) { - const status = statusOf(event); + const status = statusOf(event, isLast ?? true); return (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx new file mode 100644 index 00000000..99586b30 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx @@ -0,0 +1,156 @@ +import { cn } from '@/lib/utils'; +import type { WritingStatus } from '../../types/ai-writing'; + +interface WritingSkeletonCardProps { + status: WritingStatus; +} + +function SkeletonLine({ className }: { className?: string }) { + return ( +
+ ); +} + +function ThinkingDots() { + return ( + + + + + + ); +} + +function MaterialsSkeleton() { + return ( +
+
+ + 素材收集专家搜索中… +
+ {[0.85, 0.6, 0.7].map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} + +function OutlineSkeleton() { + return ( +
+
+ + 作家规划大纲中… +
+ {/* 文章标题占位 */} + + {/* 章节 */} + {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ ); +} + +function DraftSkeleton() { + return ( +
+
+ + 作家写作中… +
+ {/* 文章标题 */} + + {/* 3 个章节 */} + {[0, 1, 2].map((i) => ( +
+ + + + + + +
+ ))} +
+ ); +} + +function ReviewSkeleton() { + return ( +
+
+ + 编辑审核中… +
+ {/* 分数条 */} + {['事实准确度', '逻辑结构', '语言质量'].map((label) => ( +
+
+ {label} + +
+
+
+
+
+ ))} + {/* 问题列表占位 */} +
+ + +
+
+ ); +} + +function RevisingSkeletonInner() { + return ( +
+
+ + 作家按意见修改中… +
+ + {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ ); +} + +const SKELETON_MAP: Partial> = { + researching: MaterialsSkeleton, + writing_outline: OutlineSkeleton, + writing_draft: DraftSkeleton, + reviewing: ReviewSkeleton, + revising: RevisingSkeletonInner, +}; + +export function WritingSkeletonCard({ status }: WritingSkeletonCardProps) { + const Skeleton = SKELETON_MAP[status]; + if (!Skeleton) return null; + return ( +
+ +
+ ); +} diff --git a/frontend-web/src/open-canvas/hooks/useAIWriting.ts b/frontend-web/src/open-canvas/hooks/useAIWriting.ts index fe62de70..0a4831df 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWriting.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWriting.ts @@ -9,6 +9,7 @@ import type { AIOutline, ProgressEvent, ReviewResult, + StreamingDraftSection, UserInterventionPayload, } from '../types/ai-writing'; @@ -24,6 +25,10 @@ const INITIAL_STATE: AIWritingState = { revisionCount: 0, currentPause: null, error: null, + streamingDraftTitle: '', + streamingDraftSections: [], + outlineStreamingText: '', + outlineThinkingText: '', }; export function useAIWriting() { @@ -53,21 +58,64 @@ export function useAIWriting() { materials: (event.materials ?? []) as AIMaterial[], }; + case 'outline_chunk': { + const chunkText = (event as unknown as { text: string }).text ?? ''; + const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; + return { + ...prev, + status: 'writing_outline', + outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText, + outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText, + }; + } + case 'outline_ready': - // 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹) + // 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本 return { ...base, outline: (event.outline ?? null) as AIOutline | null, + outlineStreamingText: '', + outlineThinkingText: '', }; + case 'draft_chunk': { + const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0; + const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? ''; + const text = (event as unknown as { text: string }).text ?? ''; + const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; + const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false; + const sections = prev.streamingDraftSections.map((s) => ({ ...s })); + const existing = sections.find((s) => s.sectionIndex === idx); + if (existing) { + if (isThinking) existing.thinkingText += text; + else existing.text += text; + existing.isDone = done; + } else { + sections.push({ + sectionIndex: idx, + sectionTitle: title, + text: isThinking ? '' : text, + thinkingText: isThinking ? text : '', + isDone: done, + }); + } + return { + ...prev, + status: 'writing_draft', + streamingDraftSections: sections, + streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '', + }; + } + case 'draft_ready': return { ...base, currentDraftMarkdown: event.draftMarkdown ?? '', currentDraftTitle: event.draftTitle ?? '', revisionCount: event.revisionCount ?? 0, - status: 'awaiting_draft_confirm', - currentPause: event, + // 草稿完成后清空流式缓冲 + streamingDraftSections: [], + streamingDraftTitle: '', }; case 'review_ready': diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index dbf0c14b..e1683540 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -68,6 +68,7 @@ export type ProgressEventType = | 'step_start' | 'step_done' | 'materials_ready' + | 'outline_chunk' | 'outline_ready' | 'draft_chunk' | 'draft_ready' @@ -121,6 +122,16 @@ export interface AIWritingRequest { modelName?: string; } +// ── 流式草稿章节 ────────────────────────────────────────────────────────────── + +export interface StreamingDraftSection { + sectionIndex: number; + sectionTitle: string; + text: string; // 正文内容(非思考) + thinkingText: string; // 思考内容(... 内) + isDone: boolean; +} + // ── Hook 状态 ───────────────────────────────────────────────────────────────── export interface AIWritingState { @@ -135,4 +146,10 @@ export interface AIWritingState { revisionCount: number; currentPause: ProgressEvent | null; error: string | null; + // 流式草稿 + streamingDraftTitle: string; + streamingDraftSections: StreamingDraftSection[]; + // 流式大纲(正文 / 思考分开) + outlineStreamingText: string; + outlineThinkingText: string; } diff --git a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py index 9c06f61a..4d4c159a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -69,6 +69,9 @@ def _make_config(session_id: str, model_name: str = "") -> dict: "thread_id": session_id, "model_name": model_name or _session_model_names.get(session_id, ""), "ai_writing_config": {}, + # 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk + "sse_queue": _event_queues.get(session_id), + "sse_session_id": session_id, } } @@ -240,7 +243,8 @@ async def _stream_graph_segment( def _event_type(evt: dict) -> str: t = evt.get("type", "progress") # 过滤掉已在上方单独推送的事件类型,避免重复 - if t in ("materials_ready", "outline_ready", "draft_ready", "review_ready", "await_user"): + if t in ("materials_ready", "outline_ready", "draft_ready", "review_ready", "await_user", + "draft_chunk", "outline_chunk"): return "step_done" return t diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py index 426aac43..6a57aa3b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py @@ -134,6 +134,73 @@ def _content_to_text(content) -> str: return str(content) +class ThinkTagParser: + """流式 ... 标签解析器。 + + 模型可能在任意 chunk 边界截断标签,通过尾部缓冲处理跨 chunk 边界情况。 + 调用 feed(chunk) 返回 list[tuple[bool, str]],每项为 (is_thinking, text)。 + """ + + _OPEN_TAG = "" + _CLOSE_TAG = "" + + def __init__(self) -> None: + self._in_think = False + self._buf = "" # 潜在标签的部分匹配缓冲 + + def feed(self, chunk: str) -> list[tuple[bool, str]]: + """处理一个流式 chunk,返回 (is_thinking, text) 片段列表。""" + self._buf += chunk + result: list[tuple[bool, str]] = [] + + while self._buf: + if self._in_think: + tag = self._CLOSE_TAG + idx = self._buf.find(tag) + if idx != -1: + if idx > 0: + result.append((True, self._buf[:idx])) + self._buf = self._buf[idx + len(tag):] + self._in_think = False + else: + partial = self._partial_suffix(self._buf, tag) + if partial: + result.append((True, self._buf[:-partial])) + self._buf = self._buf[-partial:] + else: + result.append((True, self._buf)) + self._buf = "" + break + else: + tag = self._OPEN_TAG + idx = self._buf.find(tag) + if idx != -1: + if idx > 0: + result.append((False, self._buf[:idx])) + self._buf = self._buf[idx + len(tag):] + self._in_think = True + else: + partial = self._partial_suffix(self._buf, tag) + if partial: + result.append((False, self._buf[:-partial])) + self._buf = self._buf[-partial:] + else: + result.append((False, self._buf)) + self._buf = "" + break + + return [(is_t, t) for (is_t, t) in result if t] + + @staticmethod + def _partial_suffix(text: str, tag: str) -> int: + """返回 text 尾部与 tag 前缀匹配的最长长度(>0 说明可能是部分标签)。""" + max_check = min(len(tag) - 1, len(text)) + for length in range(max_check, 0, -1): + if tag.startswith(text[-length:]): + return length + return 0 + + async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig) -> dict: """调用 LLM,解析 JSON 输出,失败用 fallback。""" model = get_model(config) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index 5be0cdb4..1636e6c0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -1,6 +1,7 @@ -"""作家节点 Phase 2:按大纲逐段写正文。""" +"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。""" from __future__ import annotations +import asyncio import logging from langchain_core.messages import HumanMessage, SystemMessage @@ -8,7 +9,7 @@ from langchain_core.runnables import RunnableConfig from ..state import AIWritingState, DraftArticle from ..prompts.writer_prompts import DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER -from ._utils import get_model, _content_to_text +from ._utils import get_model, _content_to_text, ThinkTagParser logger = logging.getLogger(__name__) @@ -21,7 +22,6 @@ def _get_section_materials(material_ids: list[str], pkg: dict) -> str: m = mat_map[mid] lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}") if not lines: - # 没有指定素材时,使用前两条 for m in pkg.get("materials", [])[:2]: lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}") return "\n".join(lines) if lines else "(无相关素材)" @@ -39,6 +39,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di intervention = state.get("last_user_intervention") or {} revision_count = state.get("revision_count", 0) + # SSE 流式推送所需 + cfg = config.get("configurable", {}) + sse_queue: asyncio.Queue | None = cfg.get("sse_queue") + session_id: str = cfg.get("sse_session_id", "") + # 修改意见(来自用户或编辑) revision_notes = "" if intervention.get("action") == "user_revise": @@ -82,23 +87,50 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di target_words=words_per_section, ) + section_buf = "" # 只累积正文(非思考)用于拼接 Markdown + parser = ThinkTagParser() try: - resp = await model.ainvoke([ + async for chunk in model.astream([ SystemMessage(content=DRAFT_SECTION_SYSTEM), HumanMessage(content=user_msg), - ]) - # 部分模型(如 DeepSeek thinking 模式)返回列表结构的 content,统一转为字符串 - text = _content_to_text(resp.content).strip() - section_texts.append(f"## {sec_title}\n\n{text}") + ]): + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + section_buf += text + if sse_queue is not None: + await sse_queue.put(("draft_chunk", { + "session_id": session_id, + "section_index": i, + "section_title": sec_title, + "text": text, + "is_thinking": is_thinking, + "is_section_done": False, + })) + + # 章节完成 + if sse_queue is not None: + await sse_queue.put(("draft_chunk", { + "session_id": session_id, + "section_index": i, + "section_title": sec_title, + "text": "", + "is_section_done": True, + })) + + section_texts.append(f"## {sec_title}\n\n{section_buf.strip()}") + except Exception as e: logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True) section_texts.append(f"## {sec_title}\n\n(内容生成失败,请重试)") - full_markdown = f"# {outline['title']}\n\n" + "\n\n".join(section_texts) + full_markdown = f"# {outline.get('title', '文章')}\n\n" + "\n\n".join(section_texts) word_count = len([c for c in full_markdown if '一' <= c <= '鿿']) draft = DraftArticle( - title=outline["title"], + title=outline.get("title", "文章"), outline=outline, full_markdown=full_markdown, word_count=word_count, @@ -115,7 +147,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di "type": "draft_ready", "agent_name": "作家", "message": f"草稿完成,共 {word_count} 字", - "draft_title": outline["title"], + "draft_title": outline.get("title", "文章"), "draft_markdown": full_markdown, "revision_count": revision_count, }, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py index 4187581d..e006c732 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py @@ -1,12 +1,17 @@ -"""作家节点 Phase 1:生成文章大纲。""" +"""作家节点 Phase 1:生成文章大纲,支持流式 chunk 输出。""" from __future__ import annotations +import asyncio +import logging + +from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from ..state import AIWritingState, Outline, OutlineSection from ..prompts.writer_prompts import OUTLINE_SYSTEM, OUTLINE_USER -from ._utils import llm_json +from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser +logger = logging.getLogger(__name__) # 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。 _MAX_OUTLINE_MATERIALS = 12 @@ -43,33 +48,58 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> elif intervention.get("outline_feedback"): feedback_section = f"\n重新规划要求:{intervention['outline_feedback']}" - result = await llm_json( - OUTLINE_SYSTEM, - OUTLINE_USER.format( - user_intent=state["user_intent"], - article_type=state.get("article_type", "analysis"), - target_audience=state.get("target_audience", "普通读者"), - word_count_target=state.get("word_count_target", 800), - materials_summary=_materials_summary(pkg), - feedback_section=feedback_section, - ), - fallback={ - "title": "未命名文章", - "sections": [ - {"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []}, - {"section_title": "正文", "key_points": ["核心内容"], "material_ids": []}, - {"section_title": "结论", "key_points": ["总结"], "material_ids": []}, - ], - }, - config=config, + user_prompt = OUTLINE_USER.format( + user_intent=state["user_intent"], + article_type=state.get("article_type", "analysis"), + target_audience=state.get("target_audience", "普通读者"), + word_count_target=state.get("word_count_target", 800), + materials_summary=_materials_summary(pkg), + feedback_section=feedback_section, ) - if llm_err := result.pop("_llm_error", None): + # SSE 流式推送 + cfg = config.get("configurable", {}) + sse_queue: asyncio.Queue | None = cfg.get("sse_queue") + session_id: str = cfg.get("sse_session_id", "") + + model = get_model(config) + full_text = "" # 只累积正文(非思考)用于 JSON 解析 + parser = ThinkTagParser() + + try: + async for chunk in model.astream([ + SystemMessage(content=OUTLINE_SYSTEM), + HumanMessage(content=user_prompt), + ]): + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + full_text += text + if sse_queue is not None: + await sse_queue.put(("outline_chunk", { + "session_id": session_id, + "text": text, + "is_thinking": is_thinking, + })) + except Exception as e: + logger.error("outline streaming failed: %s", e, exc_info=True) return { "status": "error", - "progress_events": [{"type": "error", "agent_name": "作家", "message": f"大纲生成失败:{llm_err}"}], + "progress_events": [{"type": "error", "agent_name": "作家", "message": f"大纲生成失败:{e}"}], } + fallback = { + "title": "未命名文章", + "sections": [ + {"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []}, + {"section_title": "正文", "key_points": ["核心内容"], "material_ids": []}, + {"section_title": "结论", "key_points": ["总结"], "material_ids": []}, + ], + } + result = parse_json(full_text, fallback) + outline = _parse_outline(result) section_count = len(outline["sections"])