137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
import { useState } from 'react';
|
||
|
||
interface AIPanelProps {
|
||
text: string;
|
||
loading: boolean;
|
||
error: string | null;
|
||
isFallback: boolean;
|
||
question: string;
|
||
onCopy: (text: string) => void;
|
||
onStop: () => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export default function AIPanel({
|
||
text, loading, error, isFallback, question, onCopy, onStop, onClose,
|
||
}: AIPanelProps) {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleCopy = () => {
|
||
onCopy(text);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1000);
|
||
};
|
||
|
||
return (
|
||
<div className="flex-shrink-0 px-4 pb-4 animate-fade-in">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-sm">✨</span>
|
||
<span className="text-[12px] font-semibold text-accent-light">
|
||
DeepSeek RAG
|
||
</span>
|
||
|
||
{/* Animated loading dots */}
|
||
{loading && (
|
||
<div className="flex gap-1 ml-1">
|
||
{[0, 1, 2].map(i => (
|
||
<span
|
||
key={i}
|
||
className="w-1 h-1 rounded-full bg-accent-light"
|
||
style={{ animation: `pulseDot 1.4s ease-in-out ${i * 0.2}s infinite` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Fallback badge */}
|
||
{isFallback && !loading && (
|
||
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30 ml-1">
|
||
本地降级
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
{/* Stop button — only visible while streaming */}
|
||
{loading && (
|
||
<button
|
||
id="btn-ai-stop"
|
||
onClick={onStop}
|
||
title="停止生成"
|
||
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-md
|
||
bg-red-500/15 text-red-400 border border-red-500/25
|
||
hover:bg-red-500/25 transition-all"
|
||
>
|
||
<span>⬛</span> 停止
|
||
</button>
|
||
)}
|
||
<button
|
||
id="btn-ai-close"
|
||
onClick={onClose}
|
||
className="text-white/30 hover:text-white/70 text-xs transition-colors ml-1"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Question context */}
|
||
{question && (
|
||
<p className="text-[10px] text-white/35 mb-2 line-clamp-1">
|
||
针对:{question}
|
||
</p>
|
||
)}
|
||
|
||
{/* Fallback notice */}
|
||
{isFallback && (
|
||
<div className="mb-2 px-2 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/20
|
||
text-amber-400 text-[10px] flex items-center gap-1.5">
|
||
<span>⚠️</span>
|
||
<span>DeepSeek 暂时不可用,已显示知识库原始答案</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error state */}
|
||
{error && !isFallback ? (
|
||
<div className="ai-panel border-red-500/30 bg-red-500/5">
|
||
<p className="text-red-400 text-[12px] font-medium mb-1">⚠️ AI 服务暂时不可用</p>
|
||
<p className="text-white/50 text-[11px]">{error}</p>
|
||
<p className="text-white/30 text-[11px] mt-2">请直接使用上方知识库原始答案。</p>
|
||
</div>
|
||
) : (
|
||
/* Content panel with typewriter effect */
|
||
<div className={`ai-panel ${isFallback ? 'border-amber-500/20 bg-amber-500/5' : ''}`}>
|
||
{loading && !text ? (
|
||
<p className="text-white/30 text-[12px] animate-pulse">
|
||
正在检索知识库并调用 DeepSeek…
|
||
</p>
|
||
) : (
|
||
<p className="text-[13px] leading-relaxed whitespace-pre-wrap">
|
||
{text}
|
||
{/* Blinking cursor while streaming */}
|
||
{loading && (
|
||
<span className="inline-block w-0.5 h-3.5 bg-accent ml-0.5 align-middle"
|
||
style={{ animation: 'pulseDot 0.8s ease-in-out infinite' }} />
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Copy button */}
|
||
{text && !error && (
|
||
<button
|
||
id="btn-ai-copy"
|
||
onClick={handleCopy}
|
||
className="mt-2 w-full text-[11px] py-2 rounded-lg bg-accent/20 text-accent-light
|
||
border border-accent/30 hover:bg-accent/35 transition-all font-medium"
|
||
>
|
||
{copied ? '✓ 已复制' : '📋 复制此话术'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|