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;
}