ai写作
This commit is contained in:
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[];
|
||||
}
|
||||
|
||||
+39
-10
@@ -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", {
|
||||
|
||||
Reference in new issue
Block a user