diff --git a/frontend-web/src/open-canvas/api/ai-writing.ts b/frontend-web/src/open-canvas/api/ai-writing.ts index 2ffaadc9..ac475dc2 100644 --- a/frontend-web/src/open-canvas/api/ai-writing.ts +++ b/frontend-web/src/open-canvas/api/ai-writing.ts @@ -32,7 +32,7 @@ const SSE_EVENT_TYPES: ProgressEventType[] = [ 'connected', 'step_start', 'step_done', 'materials_ready', 'outline_chunk', 'outline_ready', 'draft_chunk', 'draft_ready', - 'review_item_done', 'review_ready', + 'review_chunk', 'review_item_done', 'review_ready', 'await_user', 'resumed', 'done', 'error', ]; diff --git a/frontend-web/src/open-canvas/components/ai-writing/ReviewStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/ReviewStreamingView.tsx index 6cf19e38..8019d069 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/ReviewStreamingView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/ReviewStreamingView.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { CheckCircle2, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing'; @@ -48,6 +49,39 @@ function IssueRow({ issue }: { issue: ReviewIssue }) { ); } +// 流式打字机文本块(审核进行中的分类显示) +function StreamingText({ item }: { item: StreamingReviewItem }) { + const preRef = useRef(null); + + useEffect(() => { + if (preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }, [item.streamingText, item.thinkingText]); + + const hasContent = item.streamingText || item.thinkingText; + if (!hasContent) { + return ( +

正在审阅…

+ ); + } + + return ( +
+      {item.thinkingText && (
+        
+          {item.thinkingText}
+        
+      )}
+      {item.streamingText}
+      
+    
+ ); +} + interface ReviewStreamingViewProps { items: StreamingReviewItem[]; } @@ -55,11 +89,11 @@ interface ReviewStreamingViewProps { export function ReviewStreamingView({ items }: ReviewStreamingViewProps) { if (items.length === 0) return null; - const doneSet = new Set(items.map((it) => it.category)); - const totalDone = items.length; + const doneItems = items.filter((it) => it.isDone); + const allDone = doneItems.length === 3; const overallScore = - totalDone > 0 - ? Math.round(items.reduce((s, it) => s + it.score, 0) / totalDone) + doneItems.length > 0 + ? Math.round(doneItems.reduce((s, it) => s + it.score, 0) / doneItems.length) : 0; return ( @@ -70,7 +104,7 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) { 编辑正在审核… - {totalDone < 3 && ( + {!allDone && ( {[0, 1, 2].map((i) => ( )} - {totalDone === 3 && ( + {allDone && ( 综合 {overallScore} )} - {/* 逐条审核结果 */} + {/* 逐项审核结果 */}
{CATEGORIES.map((cat) => { const meta = CATEGORY_META[cat]; const item = items.find((it) => it.category === cat); - const isDone = doneSet.has(cat); - const isNext = !isDone && CATEGORIES.slice(0, meta.order).every((c) => doneSet.has(c)); + const isDone = item?.isDone ?? false; + const isStreaming = item != null && !item.isDone; + const isPending = item == null; return (
@@ -102,24 +137,27 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
{isDone ? ( - ) : isNext ? ( + ) : isStreaming ? ( ) : ( )} {meta.label}
- {/* 得分 + 问题列表 */} + {/* 审核进行中:流式打字机文本 */} + {isStreaming && item && } + + {/* 审核完成:得分 + 问题列表 */} {isDone && item && ( <> - {item.issues.length > 0 && ( + {item.issues.length > 0 ? (
{item.issues.slice(0, 3).map((iss, i) => ( @@ -130,8 +168,7 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {

)}
- )} - {item.issues.length === 0 && ( + ) : (

无问题

@@ -143,6 +180,13 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) { })}
+ + ); } diff --git a/frontend-web/src/open-canvas/hooks/useAIWriting.ts b/frontend-web/src/open-canvas/hooks/useAIWriting.ts index edb09c4a..996b16f6 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWriting.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWriting.ts @@ -121,21 +121,45 @@ export function useAIWriting() { }; } - case 'review_item_done': { - const newItem: StreamingReviewItem = { - category: event.reviewItemCategory ?? 'fact', - score: event.reviewItemScore ?? 80, - issues: event.reviewItemIssues ?? [], - }; - // 首条 review_item_done 即进入审核态;新一轮审核先清空上一轮残留 + case 'review_chunk': { + const category = (event as unknown as { category: StreamingReviewItem['category'] }).category; + const text = (event as unknown as { text: string }).text ?? ''; + const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; + // 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留 const isNewCycle = prev.status !== 'reviewing'; - return { - ...prev, - status: 'reviewing', - streamingReviewItems: isNewCycle - ? [newItem] - : [...prev.streamingReviewItems, newItem], - }; + const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it })); + let item = items.find((it) => it.category === category); + if (!item) { + item = { + category, isDone: false, + streamingText: '', thinkingText: '', + score: 0, issues: [], + }; + items.push(item); + } + if (isThinking) item.thinkingText += text; + else item.streamingText += text; + return { ...prev, status: 'reviewing', streamingReviewItems: items }; + } + + case 'review_item_done': { + const category = event.reviewItemCategory ?? 'fact'; + const items = prev.streamingReviewItems.map((it) => ({ ...it })); + const item = items.find((it) => it.category === category); + if (item) { + item.isDone = true; + item.score = event.reviewItemScore ?? 80; + item.issues = event.reviewItemIssues ?? []; + } else { + // 兜底:没收到任何 chunk 直接来 done + items.push({ + category, isDone: true, + streamingText: '', thinkingText: '', + score: event.reviewItemScore ?? 80, + issues: event.reviewItemIssues ?? [], + }); + } + return { ...prev, status: 'reviewing', streamingReviewItems: items }; } case 'draft_ready': { diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 0d43104c..fccb2092 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -72,6 +72,7 @@ export type ProgressEventType = | 'outline_ready' | 'draft_chunk' | 'draft_ready' + | 'review_chunk' | 'review_item_done' | 'review_ready' | 'await_user' @@ -133,6 +134,12 @@ export interface AIWritingRequest { export interface StreamingReviewItem { category: 'fact' | 'logic' | 'language'; + // 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本) + isDone: boolean; + // 流式过程文本(正文 / 思考分开) + streamingText: string; + thinkingText: string; + // 完成后的结构化结果 score: number; issues: ReviewIssue[]; } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index b01a7086..c5bc044c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -1,16 +1,17 @@ -"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言),顺序执行以支持流式推送。""" +"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言),逐 token 流式推送以支持打字机效果。""" 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, ReviewResult, ReviewIssue from ..prompts.editor_prompts import ( EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER, ) -from ._utils import llm_json +from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser logger = logging.getLogger(__name__) @@ -37,6 +38,33 @@ def _parse_issues(raw_issues: list, category: str) -> list[ReviewIssue]: return issues +async def _stream_one_review( + model, user_prompt: str, category: str, + sse_queue: asyncio.Queue | None, session_id: str, +) -> str: + """流式执行单项审查,逐 chunk 推送 review_chunk 事件,返回累积的正文文本(用于 JSON 解析)。""" + full_text = "" # 仅累积正文(非思考),供后续 JSON 解析 + parser = ThinkTagParser() + async for chunk in model.astream([ + SystemMessage(content=EDITOR_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(("review_chunk", { + "session_id": session_id, + "category": category, + "text": text, + "is_thinking": is_thinking, + })) + return full_text + + async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: draft = state.get("current_draft") if not draft: @@ -56,25 +84,26 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD)) REVIEW_TASKS = [ - ("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article), {"score": 80, "issues": []}), - ("logic", LOGIC_REVIEW_USER.format(article=article), {"score": 80, "issues": []}), - ("language", LANGUAGE_REVIEW_USER.format(article=article), {"score": 80, "issues": []}), + ("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article)), + ("logic", LOGIC_REVIEW_USER.format(article=article)), + ("language", LANGUAGE_REVIEW_USER.format(article=article)), ] - CATEGORY_LABEL = {"fact": "事实准确性", "logic": "逻辑结构", "language": "语言文字"} - + model = get_model(config) results: dict[str, dict] = {} - for category, user_prompt, fallback in REVIEW_TASKS: + for category, user_prompt in REVIEW_TASKS: + fallback = {"score": 80, "issues": []} try: - res = await llm_json(EDITOR_SYSTEM, user_prompt, fallback, config) + full_text = await _stream_one_review(model, user_prompt, category, sse_queue, session_id) + res = parse_json(full_text, fallback) except Exception as e: logger.warning("editor review [%s] failed: %s", category, e) res = fallback results[category] = res - # 实时推送这一项审查结果 + # 这一项审查完成 —— 推送结构化结果(前端据此把流式文本替换为评分/意见) if sse_queue is not None: issues = _parse_issues(res.get("issues", []), category) await sse_queue.put(("review_item_done", {