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[] = [
|
const SSE_EVENT_TYPES: ProgressEventType[] = [
|
||||||
'connected', 'step_start', 'step_done', 'materials_ready', 'outline_ready',
|
'connected', 'step_start', 'step_done',
|
||||||
'draft_chunk', 'draft_ready', 'review_ready', 'await_user', 'resumed', 'done', 'error',
|
'materials_ready', 'outline_chunk', 'outline_ready',
|
||||||
|
'draft_chunk', 'draft_ready',
|
||||||
|
'review_ready', 'await_user', 'resumed', 'done', 'error',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
|
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useAIWriting } from '../../hooks/useAIWriting';
|
|||||||
import { AIWritingForm } from './AIWritingForm';
|
import { AIWritingForm } from './AIWritingForm';
|
||||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||||
import { InterventionCard } from './InterventionCard';
|
import { InterventionCard } from './InterventionCard';
|
||||||
|
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||||
|
import { DraftStreamingView } from './DraftStreamingView';
|
||||||
|
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
idle: '就绪',
|
idle: '就绪',
|
||||||
@@ -30,6 +33,8 @@ export function AIWritingPanel() {
|
|||||||
const isDone = state.status === 'done';
|
const isDone = state.status === 'done';
|
||||||
const isError = state.status === 'error';
|
const isError = state.status === 'error';
|
||||||
const isRunning = !isIdle && !isDone && !isError;
|
const isRunning = !isIdle && !isDone && !isError;
|
||||||
|
// 有干预卡时不显示骨架屏(干预卡本身就是内容)
|
||||||
|
const showSkeleton = isRunning && !state.currentPause;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
<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 && (
|
{isError && state.error && (
|
||||||
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
<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;
|
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 === 'await_user') return 'paused';
|
||||||
if (event.type === 'step_start') return 'running';
|
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
|
||||||
|
if (event.type === 'step_start') return isLast ? 'running' : 'done';
|
||||||
return 'done';
|
return 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ const AGENT_COLOR: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
|
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
|
||||||
const status = statusOf(event);
|
const status = statusOf(event, isLast ?? true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<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,
|
AIOutline,
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
ReviewResult,
|
ReviewResult,
|
||||||
|
StreamingDraftSection,
|
||||||
UserInterventionPayload,
|
UserInterventionPayload,
|
||||||
} from '../types/ai-writing';
|
} from '../types/ai-writing';
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ const INITIAL_STATE: AIWritingState = {
|
|||||||
revisionCount: 0,
|
revisionCount: 0,
|
||||||
currentPause: null,
|
currentPause: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
streamingDraftTitle: '',
|
||||||
|
streamingDraftSections: [],
|
||||||
|
outlineStreamingText: '',
|
||||||
|
outlineThinkingText: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useAIWriting() {
|
export function useAIWriting() {
|
||||||
@@ -53,21 +58,64 @@ export function useAIWriting() {
|
|||||||
materials: (event.materials ?? []) as AIMaterial[],
|
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':
|
case 'outline_ready':
|
||||||
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹)
|
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
outline: (event.outline ?? null) as AIOutline | null,
|
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':
|
case 'draft_ready':
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
currentDraftMarkdown: event.draftMarkdown ?? '',
|
currentDraftMarkdown: event.draftMarkdown ?? '',
|
||||||
currentDraftTitle: event.draftTitle ?? '',
|
currentDraftTitle: event.draftTitle ?? '',
|
||||||
revisionCount: event.revisionCount ?? 0,
|
revisionCount: event.revisionCount ?? 0,
|
||||||
status: 'awaiting_draft_confirm',
|
// 草稿完成后清空流式缓冲
|
||||||
currentPause: event,
|
streamingDraftSections: [],
|
||||||
|
streamingDraftTitle: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'review_ready':
|
case 'review_ready':
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export type ProgressEventType =
|
|||||||
| 'step_start'
|
| 'step_start'
|
||||||
| 'step_done'
|
| 'step_done'
|
||||||
| 'materials_ready'
|
| 'materials_ready'
|
||||||
|
| 'outline_chunk'
|
||||||
| 'outline_ready'
|
| 'outline_ready'
|
||||||
| 'draft_chunk'
|
| 'draft_chunk'
|
||||||
| 'draft_ready'
|
| 'draft_ready'
|
||||||
@@ -121,6 +122,16 @@ export interface AIWritingRequest {
|
|||||||
modelName?: string;
|
modelName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StreamingDraftSection {
|
||||||
|
sectionIndex: number;
|
||||||
|
sectionTitle: string;
|
||||||
|
text: string; // 正文内容(非思考)
|
||||||
|
thinkingText: string; // 思考内容(<think>...</think> 内)
|
||||||
|
isDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
|
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AIWritingState {
|
export interface AIWritingState {
|
||||||
@@ -135,4 +146,10 @@ export interface AIWritingState {
|
|||||||
revisionCount: number;
|
revisionCount: number;
|
||||||
currentPause: ProgressEvent | null;
|
currentPause: ProgressEvent | null;
|
||||||
error: string | 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,
|
"thread_id": session_id,
|
||||||
"model_name": model_name or _session_model_names.get(session_id, ""),
|
"model_name": model_name or _session_model_names.get(session_id, ""),
|
||||||
"ai_writing_config": {},
|
"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:
|
def _event_type(evt: dict) -> str:
|
||||||
t = evt.get("type", "progress")
|
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 "step_done"
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|||||||
+67
@@ -134,6 +134,73 @@ def _content_to_text(content) -> str:
|
|||||||
return str(content)
|
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:
|
async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig) -> dict:
|
||||||
"""调用 LLM,解析 JSON 输出,失败用 fallback。"""
|
"""调用 LLM,解析 JSON 输出,失败用 fallback。"""
|
||||||
model = get_model(config)
|
model = get_model(config)
|
||||||
|
|||||||
+43
-11
@@ -1,6 +1,7 @@
|
|||||||
"""作家节点 Phase 2:按大纲逐段写正文。"""
|
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
@@ -8,7 +9,7 @@ from langchain_core.runnables import RunnableConfig
|
|||||||
|
|
||||||
from ..state import AIWritingState, DraftArticle
|
from ..state import AIWritingState, DraftArticle
|
||||||
from ..prompts.writer_prompts import DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,7 +22,6 @@ def _get_section_materials(material_ids: list[str], pkg: dict) -> str:
|
|||||||
m = mat_map[mid]
|
m = mat_map[mid]
|
||||||
lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}")
|
lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}")
|
||||||
if not lines:
|
if not lines:
|
||||||
# 没有指定素材时,使用前两条
|
|
||||||
for m in pkg.get("materials", [])[:2]:
|
for m in pkg.get("materials", [])[:2]:
|
||||||
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}")
|
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}")
|
||||||
return "\n".join(lines) if lines else "(无相关素材)"
|
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 {}
|
intervention = state.get("last_user_intervention") or {}
|
||||||
revision_count = state.get("revision_count", 0)
|
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 = ""
|
revision_notes = ""
|
||||||
if intervention.get("action") == "user_revise":
|
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,
|
target_words=words_per_section,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
|
||||||
|
parser = ThinkTagParser()
|
||||||
try:
|
try:
|
||||||
resp = await model.ainvoke([
|
async for chunk in model.astream([
|
||||||
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
||||||
HumanMessage(content=user_msg),
|
HumanMessage(content=user_msg),
|
||||||
])
|
]):
|
||||||
# 部分模型(如 DeepSeek thinking 模式)返回列表结构的 content,统一转为字符串
|
delta = _content_to_text(chunk.content)
|
||||||
text = _content_to_text(resp.content).strip()
|
if not delta:
|
||||||
section_texts.append(f"## {sec_title}\n\n{text}")
|
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:
|
except Exception as e:
|
||||||
logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True)
|
logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True)
|
||||||
section_texts.append(f"## {sec_title}\n\n(内容生成失败,请重试)")
|
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 <= '鿿'])
|
word_count = len([c for c in full_markdown if '一' <= c <= '鿿'])
|
||||||
|
|
||||||
draft = DraftArticle(
|
draft = DraftArticle(
|
||||||
title=outline["title"],
|
title=outline.get("title", "文章"),
|
||||||
outline=outline,
|
outline=outline,
|
||||||
full_markdown=full_markdown,
|
full_markdown=full_markdown,
|
||||||
word_count=word_count,
|
word_count=word_count,
|
||||||
@@ -115,7 +147,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
"type": "draft_ready",
|
"type": "draft_ready",
|
||||||
"agent_name": "作家",
|
"agent_name": "作家",
|
||||||
"message": f"草稿完成,共 {word_count} 字",
|
"message": f"草稿完成,共 {word_count} 字",
|
||||||
"draft_title": outline["title"],
|
"draft_title": outline.get("title", "文章"),
|
||||||
"draft_markdown": full_markdown,
|
"draft_markdown": full_markdown,
|
||||||
"revision_count": revision_count,
|
"revision_count": revision_count,
|
||||||
},
|
},
|
||||||
|
|||||||
+53
-23
@@ -1,12 +1,17 @@
|
|||||||
"""作家节点 Phase 1:生成文章大纲。"""
|
"""作家节点 Phase 1:生成文章大纲,支持流式 chunk 输出。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
|
||||||
from ..state import AIWritingState, Outline, OutlineSection
|
from ..state import AIWritingState, Outline, OutlineSection
|
||||||
from ..prompts.writer_prompts import OUTLINE_SYSTEM, OUTLINE_USER
|
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 溢出。
|
# 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。
|
||||||
_MAX_OUTLINE_MATERIALS = 12
|
_MAX_OUTLINE_MATERIALS = 12
|
||||||
@@ -43,33 +48,58 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
|||||||
elif intervention.get("outline_feedback"):
|
elif intervention.get("outline_feedback"):
|
||||||
feedback_section = f"\n重新规划要求:{intervention['outline_feedback']}"
|
feedback_section = f"\n重新规划要求:{intervention['outline_feedback']}"
|
||||||
|
|
||||||
result = await llm_json(
|
user_prompt = OUTLINE_USER.format(
|
||||||
OUTLINE_SYSTEM,
|
user_intent=state["user_intent"],
|
||||||
OUTLINE_USER.format(
|
article_type=state.get("article_type", "analysis"),
|
||||||
user_intent=state["user_intent"],
|
target_audience=state.get("target_audience", "普通读者"),
|
||||||
article_type=state.get("article_type", "analysis"),
|
word_count_target=state.get("word_count_target", 800),
|
||||||
target_audience=state.get("target_audience", "普通读者"),
|
materials_summary=_materials_summary(pkg),
|
||||||
word_count_target=state.get("word_count_target", 800),
|
feedback_section=feedback_section,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
"status": "error",
|
"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)
|
outline = _parse_outline(result)
|
||||||
section_count = len(outline["sections"])
|
section_count = len(outline["sections"])
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user