This commit is contained in:
董宏凯 committed 2026-05-17 17:39:41 +08:00
1 parent 772e75f04d
commit 016ec1877a
5 files changed
+144 -40

No files matched your search

@@ -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',
];
@@ -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<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [item.streamingText, item.thinkingText]);
const hasContent = item.streamingText || item.thinkingText;
if (!hasContent) {
return (
<p className="text-[11px] text-muted-foreground pl-5 italic">正在审阅…</p>
);
}
return (
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed mt-1 p-2 rounded bg-muted/40 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto"
>
{item.thinkingText && (
<span className="italic text-amber-600/70 dark:text-amber-400/60">
{item.thinkingText}
</span>
)}
{item.streamingText}
<span className="inline-block w-0.5 h-3 bg-orange-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
);
}
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) {
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
编辑正在审核…
</span>
{totalDone < 3 && (
{!allDone && (
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
@@ -81,20 +115,21 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
))}
</span>
)}
{totalDone === 3 && (
{allDone && (
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
综合 {overallScore}
</span>
)}
</div>
{/* 逐条审核结果 */}
{/* 逐项审核结果 */}
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
{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 (
<div key={cat} className="px-3 py-2 space-y-1.5">
@@ -102,24 +137,27 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : isNext ? (
) : isStreaming ? (
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
) : (
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span className={cn(
'text-xs font-medium',
isDone ? 'text-foreground' : 'text-muted-foreground',
isPending ? 'text-muted-foreground' : 'text-foreground',
)}>
{meta.label}
</span>
</div>
{/* 得分 + 问题列表 */}
{/* 审核进行中:流式打字机文本 */}
{isStreaming && item && <StreamingText item={item} />}
{/* 审核完成:得分 + 问题列表 */}
{isDone && item && (
<>
<ScoreBar score={item.score} />
{item.issues.length > 0 && (
{item.issues.length > 0 ? (
<div className="space-y-1 mt-1">
{item.issues.slice(0, 3).map((iss, i) => (
<IssueRow key={i} issue={iss} />
@@ -130,8 +168,7 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
</p>
)}
</div>
)}
{item.issues.length === 0 && (
) : (
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
无问题
</p>
@@ -143,6 +180,13 @@ export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
})}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -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': {
@@ -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[];
}
@@ -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", {