ai写作
This commit is contained in:
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
|
||||
|
||||
|
||||
+67
@@ -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)
|
||||
|
||||
+43
-11
@@ -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,
|
||||
},
|
||||
|
||||
+53
-23
@@ -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"])
|
||||
|
||||
|
||||
Reference in new issue
Block a user