102 lines
4.0 KiB
TypeScript
102 lines
4.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||
|
||
interface OutlineStreamingViewProps {
|
||
text: string;
|
||
thinkingText?: string;
|
||
/** 用户自带大纲时为 true:文案显示「整理你的大纲」而非「规划大纲」。 */
|
||
organizing?: boolean;
|
||
}
|
||
|
||
export function OutlineStreamingView({ text, thinkingText, organizing }: 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">
|
||
{organizing ? '作家正在整理你的大纲…' : '作家正在规划大纲…'}
|
||
</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>
|
||
);
|
||
}
|