This commit is contained in:
董宏凯 committed 2026-05-17 14:22:25 +08:00
1 parent cc8703577d
commit 10d7190ea4
12 files changed
+643 -43

No files matched your search

@@ -29,8 +29,10 @@ export async function startWritingSession(req: AIWritingRequest): Promise<string
}
const SSE_EVENT_TYPES: ProgressEventType[] = [
'connected', 'step_start', 'step_done', 'materials_ready', 'outline_ready',
'draft_chunk', 'draft_ready', 'review_ready', 'await_user', 'resumed', 'done', 'error',
'connected', 'step_start', 'step_done',
'materials_ready', 'outline_chunk', 'outline_ready',
'draft_chunk', 'draft_ready',
'review_ready', 'await_user', 'resumed', 'done', 'error',
];
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
@@ -7,6 +7,9 @@ import { useAIWriting } from '../../hooks/useAIWriting';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
const STATUS_LABEL: Record<string, string> = {
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 (
<div className="flex flex-col h-full overflow-hidden bg-background">
@@ -80,6 +85,32 @@ export function AIWritingPanel() {
/>
)}
{/* 大纲流式思考框 */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
)}
{/* 等待骨架屏:模型正在计算时显示,有干预卡则隐藏,写作时由流式视图代替 */}
{showSkeleton && state.status !== 'writing_draft' && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} />
</div>
)}
{/* 草稿阶段:流式数据还未到达前显示骨架 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{/* 出错提示 */}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -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 (
<span
aria-hidden
className="inline-block w-0.5 h-4 bg-foreground align-middle ml-0.5 animate-[blink_1s_step-end_infinite]"
style={{ verticalAlign: 'text-bottom' }}
/>
);
}
function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) {
const [open, setOpen] = useState(true);
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (open && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [thinkingText, open]);
if (!thinkingText) return null;
return (
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
<span className="ml-auto text-amber-400">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
</button>
{open && (
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
>
{thinkingText}
{!isDone && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
);
}
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
const bottomRef = useRef<HTMLDivElement>(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 (
<div className="px-4 pb-4">
<div className="rounded-xl border bg-card shadow-sm p-4 prose prose-sm dark:prose-invert max-w-none">
{title && (
<h1 className="text-base font-bold text-center mb-4 text-foreground">{title}</h1>
)}
{sections.map((sec) => (
<div key={sec.sectionIndex} className="mb-4">
<h2 className={cn(
'text-sm font-semibold mb-1.5',
sec.isDone ? 'text-foreground' : 'text-purple-500',
)}>
{sec.sectionTitle}
</h2>
{/* 该章节的思考过程(可折叠) */}
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
{/* 正文内容 */}
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
{sec.text}
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -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<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(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 (
<div className="px-4 pb-2 space-y-2">
{/* 思考过程区(仅在有思考内容时展示) */}
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{/* 思考仍在进行时显示光标 */}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{/* 正文流式区 */}
{text && (
<div className="rounded-xl border border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
<FileText className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
作家正在规划大纲…
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-purple-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-purple-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -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<string, string> = {
};
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
const status = statusOf(event);
const status = statusOf(event, isLast ?? true);
return (
<div className="flex gap-3">
@@ -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 (
<div
className={cn(
'h-3 rounded-md bg-muted animate-pulse',
className,
)}
/>
);
}
function ThinkingDots() {
return (
<span className="inline-flex gap-1 items-center">
<span className="size-1.5 rounded-full bg-current animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-current animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-current animate-bounce [animation-delay:300ms]" />
</span>
);
}
function MaterialsSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
<ThinkingDots />
<span>素材收集专家搜索中…</span>
</div>
{[0.85, 0.6, 0.7].map((_, i) => (
<div key={i} className="rounded-lg border p-3 space-y-2">
<SkeletonLine className="w-3/4" />
<SkeletonLine className="w-1/3 h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
</div>
))}
</div>
);
}
function OutlineSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots />
<span>作家规划大纲中…</span>
</div>
{/* 文章标题占位 */}
<SkeletonLine className="w-1/2 h-4" />
{/* 章节 */}
{[0, 1, 2].map((i) => (
<div key={i} className="pl-3 border-l-2 border-muted space-y-1.5">
<SkeletonLine className="w-2/5 h-3" />
<SkeletonLine className="w-3/5 h-2" />
<SkeletonLine className="w-1/2 h-2" />
</div>
))}
</div>
);
}
function DraftSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots />
<span>作家写作中…</span>
</div>
{/* 文章标题 */}
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{/* 3 个章节 */}
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-3/4 h-2" />
</div>
))}
</div>
);
}
function ReviewSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
<ThinkingDots />
<span>编辑审核中…</span>
</div>
{/* 分数条 */}
{['事实准确度', '逻辑结构', '语言质量'].map((label) => (
<div key={label} className="space-y-1">
<div className="flex justify-between">
<span className="text-xs text-muted-foreground">{label}</span>
<SkeletonLine className="w-8 h-3" />
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full w-2/3 rounded-full bg-muted-foreground/30 animate-pulse" />
</div>
</div>
))}
{/* 问题列表占位 */}
<div className="space-y-2 pt-2">
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
</div>
</div>
);
}
function RevisingSkeletonInner() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots />
<span>作家按意见修改中…</span>
</div>
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
</div>
))}
</div>
);
}
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
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 (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<Skeleton />
</div>
);
}
@@ -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':
@@ -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; // 思考内容(<think>...</think> 内)
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;
}
@@ -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
@@ -134,6 +134,73 @@ def _content_to_text(content) -> str:
return str(content)
class ThinkTagParser:
"""流式 <think>...</think> 标签解析器。
模型可能在任意 chunk 边界截断标签,通过尾部缓冲处理跨 chunk 边界情况。
调用 feed(chunk) 返回 list[tuple[bool, str]],每项为 (is_thinking, text)。
"""
_OPEN_TAG = "<think>"
_CLOSE_TAG = "</think>"
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)
@@ -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,
},
@@ -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"])