102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Sparkles, ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
|
|
|
interface IntentStreamingViewProps {
|
|
text: string;
|
|
thinkingText?: string;
|
|
}
|
|
|
|
/**
|
|
* 意图解析流式视图:与 OutlineStreamingView / DraftStreamingView 同样的打字机机制,
|
|
* 颜色用蓝色主题区分意图理解阶段。展示 LLM 边吐 JSON 边滚动的过程,结果由
|
|
* IntentResultCard 接力展示结构化字段。
|
|
*/
|
|
export function IntentStreamingView({ text, thinkingText }: IntentStreamingViewProps) {
|
|
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-blue-200 dark:border-blue-900 bg-blue-50/50 dark:bg-blue-950/20 overflow-hidden shadow-sm">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
|
|
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
|
|
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
|
意图解析中…
|
|
</span>
|
|
<span className="ml-auto inline-flex gap-1">
|
|
{[0, 1, 2].map((i) => (
|
|
<span
|
|
key={i}
|
|
className="size-1 rounded-full bg-blue-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-blue-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>
|
|
);
|
|
}
|