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 8c488573..de5a4eec 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -40,6 +40,8 @@ import { AIWritingTimeline } from './AIWritingTimeline'; import { InterventionCard } from './InterventionCard'; import { WritingSkeletonCard } from './WritingSkeletonCard'; import { DraftStreamingView } from './DraftStreamingView'; +import { IntentResultCard } from './IntentResultCard'; +import { IntentStreamingView } from './IntentStreamingView'; import { OutlineStreamingView } from './OutlineStreamingView'; import { ReviewStreamingView } from './ReviewStreamingView'; import { RevisionSummaryCard } from './RevisionSummaryCard'; @@ -343,6 +345,17 @@ export function AIWritingPanel() { )} + {/* 意图解析:流式 → 结果卡片承接。 + 放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长, + 如果把意图块塞在 timeline 下面就会随 timeline 增长被推走; + 放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */} + {(state.intentStreamingText || state.intentThinkingText) && ( + + )} + {state.parsedIntent && !state.intentStreamingText && ( + + )} + {/* 运行中或完成后显示时间线 */} {!isIdle && ( 0) && ( + !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && + /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ + !state.intentStreamingText && !state.intentThinkingText && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx new file mode 100644 index 00000000..316cb59d --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx @@ -0,0 +1,51 @@ +import { Sparkles } from 'lucide-react'; + +import type { ParsedIntent } from '../../types/ai-writing'; +import type { ArticleType } from '../../api/ai-writing-article-types'; + +interface IntentResultCardProps { + intent: ParsedIntent; + articleTypes: ArticleType[]; +} + +/** + * 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项: + * 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。 + */ +export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) { + // article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道") + const typeLabel = + articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType; + + return ( +
+
+
+ + + 意图解析完成 + +
+
+ + + + {intent.refinedIntent && ( + + )} +
+
+
+ ); +} + +function Row({ label, value, multiline }: { label: string; value: string; multiline?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/IntentStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/IntentStreamingView.tsx new file mode 100644 index 00000000..8be413d8 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/IntentStreamingView.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; +import { Sparkles, ChevronDown, ChevronRight, Brain } from 'lucide-react'; + +interface IntentStreamingViewProps { + text: string; + thinkingText?: string; +} + +/** + * 意图解析流式视图:与 OutlineStreamingView / DraftStreamingView 同样的打字机机制, + * 颜色用蓝色主题区分意图理解阶段。展示 LLM 边吐 JSON 边滚动的过程,结果由 + * IntentResultCard 接力展示结构化字段。 + */ +export function IntentStreamingView({ text, thinkingText }: IntentStreamingViewProps) { + 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/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index fcfe3614..1b21eb16 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -36,6 +36,7 @@ import type { AIOutline, BlockedSection, CompletedIntervention, + ParsedIntent, ProgressEvent, ProgressEventType, ReviewIssue, @@ -66,6 +67,9 @@ const INITIAL_STATE: AIWritingState = { streamingDraftSections: [], outlineStreamingText: '', outlineThinkingText: '', + intentStreamingText: '', + intentThinkingText: '', + parsedIntent: null, researchProgress: '', streamingReviewItems: [], revisionSummary: null, @@ -146,6 +150,41 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState case 'step_done': return base; + case 'intent_chunk': { + const text = (event as unknown as { text: string }).text ?? ''; + const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; + return { + ...prev, + intentStreamingText: isThinking ? prev.intentStreamingText : prev.intentStreamingText + text, + intentThinkingText: isThinking ? prev.intentThinkingText + text : prev.intentThinkingText, + }; + } + + case 'intent_ready': { + const ev = event as unknown as { + articleType?: string; + article_type?: string; + targetAudience?: string; + target_audience?: string; + wordCountTarget?: number; + word_count_target?: number; + refinedIntent?: string; + refined_intent?: string; + }; + const parsed: ParsedIntent = { + articleType: ev.articleType ?? ev.article_type ?? '', + targetAudience: ev.targetAudience ?? ev.target_audience ?? '', + wordCountTarget: Number(ev.wordCountTarget ?? ev.word_count_target ?? 800), + refinedIntent: ev.refinedIntent ?? ev.refined_intent ?? '', + }; + return { + ...base, + parsedIntent: parsed, + intentStreamingText: '', + intentThinkingText: '', + }; + } + case 'research_progress': return { ...base, researchProgress: event.message ?? '' }; @@ -403,6 +442,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | // 后端 _save_draft_to_repo 等 helper 不走 custom 通道,type 必为节点 emit 的 chunk 类型 if (!type) return null; const allowed: ProgressEventType[] = [ + 'intent_chunk', 'intent_ready', 'research_progress', 'outline_chunk', 'draft_chunk', 'review_chunk', 'review_item_done', ]; diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 93ca8760..339676eb 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -84,6 +84,8 @@ export type ProgressEventType = | 'connected' | 'step_start' | 'step_done' + | 'intent_chunk' + | 'intent_ready' | 'research_progress' | 'materials_ready' | 'outline_chunk' @@ -213,6 +215,14 @@ export interface CompletedIntervention { // ── Hook 状态 ───────────────────────────────────────────────────────────────── +/** 意图解析最终结构化字段,来自 intent_ready 事件。 */ +export interface ParsedIntent { + articleType: string; + targetAudience: string; + wordCountTarget: number; + refinedIntent: string; +} + export interface AIWritingState { sessionId: string | null; status: WritingStatus; @@ -234,6 +244,10 @@ export interface AIWritingState { // 流式大纲(正文 / 思考分开) outlineStreamingText: string; outlineThinkingText: string; + // 意图解析(流式 + 结果) + intentStreamingText: string; + intentThinkingText: string; + parsedIntent: ParsedIntent | null; // 素材收集实时进度(最新一条提示文案) researchProgress: string; // 流式审核(逐条到达) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index a8345734..e3845ce8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -6,8 +6,16 @@ import re from langchain_core.runnables import RunnableConfig +from langchain_core.messages import HumanMessage, SystemMessage + from ..state import AIWritingState -from ._utils import llm_json +from ._utils import ( + ThinkTagParser, + _content_to_text, + get_model, + parse_json, + safe_get_stream_writer, +) logger = logging.getLogger(__name__) @@ -138,42 +146,79 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d if fallback_type not in valid_keys: fallback_type = default_key - result = await llm_json( - system, - user, - fallback={ - "article_type": fallback_type, - "target_audience": state.get("target_audience", "普通读者"), - "word_count_target": state.get("word_count_target", 800), - "refined_intent": state["user_intent"], - }, - config=config, - ) + fallback_result = { + "article_type": fallback_type, + "target_audience": state.get("target_audience", "普通读者"), + "word_count_target": state.get("word_count_target", 800), + "refined_intent": state["user_intent"], + } - # 意图解析模型调用失败时以非致命 notice 提示用户(llm_json 已用 fallback 兜底, - # 流程仍可继续,但用户需知道结构化配置可能不准)。 + # 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON, + # 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果, + # 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。 + writer = safe_get_stream_writer() + llm_error: str | None = None + full_text = "" + parser = ThinkTagParser() + try: + model = get_model(config) + async for chunk in model.astream( + [SystemMessage(content=system), HumanMessage(content=user)] + ): + 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 + writer({ + "type": "intent_chunk", + "text": text, + "is_thinking": is_thinking, + }) + except Exception as exc: + # 模型 / 网络挂掉 —— 不阻断流程,用 fallback 继续。 + logger.error("intent_parser streaming failed: %s", exc, exc_info=True) + llm_error = f"{type(exc).__name__}: {exc}" + + result = parse_json(full_text, fallback_result) if full_text else fallback_result + + # 意图解析模型调用失败时以非致命 notice 提示用户(fallback 已兜底,流程仍可继续) progress_events: list[dict] = [] - if result.get("_llm_error"): + if llm_error: progress_events.append({ "type": "notice", "agent_name": "系统", "title": "模型错误", - "detail": f"意图解析失败,已按默认配置兜底。原因:{result['_llm_error']}", + "detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}", }) - progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) - - updates: dict = {"status": "researching", "progress_events": progress_events} # 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值 raw_type = result.get("article_type", "") - updates["article_type"] = raw_type if raw_type in valid_keys else fallback_type - - if result.get("target_audience"): - updates["target_audience"] = result["target_audience"] + final_article_type = raw_type if raw_type in valid_keys else fallback_type + final_target_audience = result.get("target_audience") or fallback_result["target_audience"] word_count = _coerce_word_count(result.get("word_count_target")) + final_word_count = word_count if word_count is not None else fallback_result["word_count_target"] + final_refined_intent = result.get("refined_intent") or state["user_intent"] + + # 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图 + writer({ + "type": "intent_ready", + "article_type": final_article_type, + "target_audience": final_target_audience, + "word_count_target": final_word_count, + "refined_intent": final_refined_intent, + }) + + progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) + + updates: dict = {"status": "researching", "progress_events": progress_events} + updates["article_type"] = final_article_type + if result.get("target_audience"): + updates["target_audience"] = final_target_audience if word_count is not None: - updates["word_count_target"] = word_count + updates["word_count_target"] = final_word_count if result.get("refined_intent"): - updates["user_intent"] = result["refined_intent"] + updates["user_intent"] = final_refined_intent return updates