Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/DraftGeneratingView.tsx
T
2026-06-02 22:55:22 +08:00

120 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from 'react';
import { Brain, PenLine } from 'lucide-react';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftGeneratingViewProps {
sections: StreamingDraftSection[];
}
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
</div>
<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-40 overflow-y-auto italic"
>
{text}
{!done && (
<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>
);
}
/** 「内容生成中」骨架屏:流光扫过的占位行 + 写字笔抖动 + 跳动圆点,提示正文正在右侧实时生成 */
const SKELETON_WIDTHS = ['92%', '100%', '85%', '96%', '70%'];
function GeneratingSkeleton() {
return (
<div className="group relative overflow-hidden rounded-xl border bg-card shadow-sm p-4">
{/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */}
<div className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-primary/[0.07] to-transparent animate-[card-sheen_2.8s_ease-in-out_infinite]" />
<div className="relative flex items-center gap-2 mb-3">
<PenLine className="size-3.5 text-primary animate-[pen-write_1.2s_ease-in-out_infinite] origin-bottom-left" />
<span className="text-xs font-medium text-foreground">内容生成中</span>
<span className="flex items-end gap-0.5">
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_infinite]" />
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.2s_infinite]" />
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.4s_infinite]" />
</span>
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
</div>
<div className="relative space-y-2.5">
{SKELETON_WIDTHS.map((w, i) => (
<div
key={i}
className="relative h-3 overflow-hidden rounded-full bg-muted/60"
style={{ width: w }}
>
<div
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/45 to-transparent dark:via-white/10 animate-[shimmer-sweep_1.5s_ease-in-out_infinite]"
style={{ animationDelay: `${i * 0.15}s` }}
/>
</div>
))}
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 骨架行流光:高光从左扫到右 */
@keyframes shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(220%); }
}
/* 卡片整体微高光呼吸 */
@keyframes card-sheen {
0%, 60%, 100% { transform: translateX(-100%); }
80% { transform: translateX(100%); }
}
/* 写字笔:轻微上下 + 摆动,像在书写 */
@keyframes pen-write {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-1.5px) rotate(-12deg); }
}
/* 跳动圆点 */
@keyframes dot-bounce {
0%, 100% { opacity: .35; transform: translateY(0); }
50% { opacity: 1; transform: translateY(-2px); }
}
`}</style>
</div>
);
}
/**
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
*
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
*/
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
return (
<div className="px-4 pb-4 space-y-3">
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
<GeneratingSkeleton />
</div>
);
}