核心新增 —— ReAct 智能体引擎 (src/agent/): - ReAct 主循环:自主推理→工具调用→观察→迭代,支持流式 SSE 推送、 上下文自动压缩、重复调用死循环检测、手动取消与超时控制 - 7 个内置工具:ADS/arXiv 论文搜索、元数据查询、全文读取、PDF/HTML 下载解析、RAG 向量语义检索、CDS Sesame 天体目标查询 LLM 客户端增强 (src/clients/llm.rs): - 新增流式/非流式 Function Calling (tool calling) 支持 - 原生 reasoning_content 推理链字段 (DeepSeek/QwQ) - 多模态图片+文本对话、TokenUsage 用量统计 搜索服务重构 (src/services/search.rs): - 抽取 ADS/arXiv 搜索为统一 service,跨平台去重、本地状态合并 - API handler 瘦身至薄封装层 下载器增强 (src/services/download.rs): - Obscura 无头浏览器反爬下载通道 (进程内/CLI 双模式) - arXiv/Springer 专用策略、ADS Scan PDF 兜底、CAPTCHA 绕过 解析器增强 (src/services/parser/mod.rs): - Markdown 输出自动附加 YAML 前端元数据头 (标题/作者/来源) 新增 API (src/api/agent.rs): - POST /api/chat/agent — SSE 流式智能体对话 - GET/DELETE /api/chat/sessions — 会话管理 CRUD - POST /api/chat/sessions/:id/stop — 紧急停止 数据库: agent_sessions + agent_messages 表 前端 (dashboard/): - 全新"智能科研"标签页 (ResearchAgentPanel) — 会话列表、ReAct 步骤时间线可视化、SSE 实时流式渲染、Markdown+KaTeX 答案展示 - Reader AI 助手集成 Agent 工作流,含工具调用步骤展示与引用溯源 - 侧边栏新增"智能科研"导航入口
619 lines
26 KiB
TypeScript
619 lines
26 KiB
TypeScript
// dashboard/src/features/reader/AIAssistantPanel.tsx
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkMath from 'remark-math';
|
||
import remarkGfm from 'remark-gfm';
|
||
import rehypeRaw from 'rehype-raw';
|
||
import rehypeKatex from 'rehype-katex';
|
||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||
import 'katex/dist/katex.min.css';
|
||
import {
|
||
Send, Loader, Sparkles, X, BookOpen, AlertCircle, Compass,
|
||
Brain, Settings, ChevronDown, ChevronUp, AlertTriangle, Square
|
||
} from 'lucide-react';
|
||
import axios from 'axios';
|
||
|
||
interface RetrievalSource {
|
||
bibcode: string;
|
||
paragraph_index: number;
|
||
content: string;
|
||
distance: number;
|
||
}
|
||
|
||
interface AgentStep {
|
||
step: number;
|
||
type: 'thought' | 'tool_call' | 'tool_result' | 'error';
|
||
label: string;
|
||
detail?: string;
|
||
isError?: boolean;
|
||
isFinished?: boolean;
|
||
}
|
||
|
||
interface Message {
|
||
sender: 'user' | 'ai';
|
||
text: string;
|
||
sources?: RetrievalSource[];
|
||
imageUrl?: string;
|
||
steps?: AgentStep[];
|
||
}
|
||
|
||
interface AIAssistantPanelProps {
|
||
bibcode: string;
|
||
onClose: () => void;
|
||
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
|
||
pendingFigure?: { path: string; url: string } | null;
|
||
onClearPendingFigure?: () => void;
|
||
}
|
||
|
||
const safeSchema = {
|
||
...defaultSchema,
|
||
attributes: {
|
||
...defaultSchema.attributes,
|
||
'*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display']),
|
||
},
|
||
tagNames: (defaultSchema.tagNames || []).concat([
|
||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation'
|
||
]),
|
||
};
|
||
|
||
const SUGGESTED_QUESTIONS = [
|
||
"对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。",
|
||
"有哪些文献提及了脉动白矮星的非径向振动模?",
|
||
"简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。",
|
||
"文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?"
|
||
];
|
||
|
||
function getToolDisplayName(name: string): string {
|
||
switch (name) {
|
||
case 'search_papers': return '检索 ADS/arXiv';
|
||
case 'get_paper_metadata': return '获取文献详细元数据';
|
||
case 'download_paper': return '下载文献全文资源';
|
||
case 'parse_paper': return '结构化解析文献内容';
|
||
case 'get_paper_content': return '获取文献全文内容';
|
||
case 'rag_search': return '语义库检索 (RAG)';
|
||
case 'query_target': return '查询天体物理参数';
|
||
default: return name;
|
||
}
|
||
}
|
||
|
||
export function AIAssistantPanel({
|
||
bibcode,
|
||
onClose,
|
||
onJumpToSource,
|
||
pendingFigure,
|
||
onClearPendingFigure
|
||
}: AIAssistantPanelProps) {
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [input, setInput] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||
|
||
// 步骤展开状态
|
||
const [stepsExpanded, setStepsExpanded] = useState<Record<number, boolean>>({});
|
||
|
||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||
|
||
const scrollToBottom = () => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (shouldAutoScroll) {
|
||
scrollToBottom();
|
||
}
|
||
}, [messages, loading, shouldAutoScroll]);
|
||
|
||
const handleScroll = () => {
|
||
if (!scrollContainerRef.current) return;
|
||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||
// 距离底部 50 像素以内视为贴紧底部
|
||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||
setShouldAutoScroll(isAtBottom);
|
||
};
|
||
|
||
// 重置会话(当 bibcode 切换时)
|
||
useEffect(() => {
|
||
setMessages([]);
|
||
setSessionId(null);
|
||
setError(null);
|
||
setStepsExpanded({});
|
||
}, [bibcode]);
|
||
|
||
// 手动停止智能体执行
|
||
const handleStop = async () => {
|
||
if (!sessionId) return;
|
||
try {
|
||
await axios.post(`/api/chat/sessions/${sessionId}/stop`);
|
||
} catch (e) {
|
||
console.error('停止智能体执行失败:', e);
|
||
}
|
||
};
|
||
|
||
const handleSend = async (questionText: string) => {
|
||
if (!questionText.trim() || loading) return;
|
||
|
||
setError(null);
|
||
const userMsg: Message = {
|
||
sender: 'user',
|
||
text: questionText,
|
||
imageUrl: pendingFigure?.url
|
||
};
|
||
setMessages(prev => [...prev, userMsg]);
|
||
setInput('');
|
||
setLoading(true);
|
||
setShouldAutoScroll(true);
|
||
|
||
const isFigureQuery = !!pendingFigure;
|
||
const currentFigurePath = pendingFigure?.path;
|
||
|
||
if (onClearPendingFigure) {
|
||
onClearPendingFigure();
|
||
}
|
||
|
||
// AI 初始消息占位符
|
||
const aiMsgPlaceholder: Message = {
|
||
sender: 'ai',
|
||
text: '',
|
||
steps: [],
|
||
sources: []
|
||
};
|
||
setMessages(prev => [...prev, aiMsgPlaceholder]);
|
||
|
||
try {
|
||
if (isFigureQuery && currentFigurePath) {
|
||
// 图表多模态分析依然调用特定接口
|
||
const res = await axios.post<{ answer: string }>('/api/chat/figure', {
|
||
bibcode,
|
||
image_path: currentFigurePath,
|
||
question: questionText
|
||
});
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
last.text = res.data.answer;
|
||
}
|
||
return next;
|
||
});
|
||
setLoading(false);
|
||
} else {
|
||
// 调用智能体流式对话接口(带文献上下文提示,确保 Agent 优先分析当前阅读的文献)
|
||
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
|
||
const requestQuery = questionText.includes(bibcode) ? questionText : `${contextPrefix}${questionText}`;
|
||
|
||
try {
|
||
const response = await fetch('/api/chat/agent', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
question: requestQuery,
|
||
session_id: sessionId,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error('ReadableStream not supported');
|
||
}
|
||
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
|
||
if (trimmed.startsWith('data:')) {
|
||
const dataStr = trimmed.slice(5).trim();
|
||
if (!dataStr) continue;
|
||
|
||
try {
|
||
const event = JSON.parse(dataStr);
|
||
|
||
switch (event.type) {
|
||
case 'session':
|
||
if (!sessionId) {
|
||
setSessionId(event.session_id);
|
||
}
|
||
break;
|
||
case 'thought':
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
if (!last.steps) last.steps = [];
|
||
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'thought');
|
||
if (!stepObj) {
|
||
stepObj = { step: event.step, type: 'thought', label: '思考推理中' };
|
||
last.steps.push(stepObj);
|
||
}
|
||
stepObj.detail = event.content;
|
||
}
|
||
return next;
|
||
});
|
||
break;
|
||
case 'tool_call':
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
if (!last.steps) last.steps = [];
|
||
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'tool_call');
|
||
if (!stepObj) {
|
||
stepObj = { step: event.step, type: 'tool_call', label: `调用: ${getToolDisplayName(event.name)}`, isFinished: false };
|
||
last.steps.push(stepObj);
|
||
}
|
||
stepObj.detail = typeof event.arguments === 'string' ? event.arguments : JSON.stringify(event.arguments);
|
||
}
|
||
return next;
|
||
});
|
||
break;
|
||
case 'tool_result':
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
if (!last.steps) last.steps = [];
|
||
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'tool_call');
|
||
if (stepObj) {
|
||
stepObj.label = `调用: ${getToolDisplayName(event.name)} (已获取观测数据)`;
|
||
stepObj.isError = event.is_error;
|
||
stepObj.isFinished = true;
|
||
}
|
||
|
||
// 提取 RAG 检索到的参考来源文献,并在 sources 中进行去重和追加
|
||
if (event.name === 'rag_search' && event.metadata && event.metadata.sources) {
|
||
const newSources: RetrievalSource[] = event.metadata.sources.map((s: any) => ({
|
||
bibcode: s.bibcode,
|
||
paragraph_index: s.paragraph_index,
|
||
content: s.preview || '',
|
||
distance: s.distance,
|
||
}));
|
||
|
||
const existing = last.sources || [];
|
||
const combined = [...existing];
|
||
|
||
for (const src of newSources) {
|
||
if (!combined.some(c => c.bibcode === src.bibcode && c.paragraph_index === src.paragraph_index)) {
|
||
combined.push(src);
|
||
}
|
||
}
|
||
last.sources = combined;
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
break;
|
||
case 'text_delta':
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
last.text += event.content;
|
||
}
|
||
return next;
|
||
});
|
||
break;
|
||
case 'error':
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
if (!last.steps) last.steps = [];
|
||
last.steps.push({
|
||
step: 99,
|
||
type: 'error',
|
||
label: `错误返回`,
|
||
detail: event.message
|
||
});
|
||
}
|
||
return next;
|
||
});
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.error('解析流式 SSE 失败:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
setLoading(false);
|
||
|
||
} catch (streamErr) {
|
||
console.warn('智能体流式对话失败,正在尝试回退至旧版本 RAG 单次问答...', streamErr);
|
||
|
||
// 降级回退 (Deprecated Fallback) —— 调用原始单次 /api/chat/rag
|
||
const res = await axios.post<{ answer: string; sources: RetrievalSource[] }>('/api/chat/rag', {
|
||
question: questionText,
|
||
top_k: 5
|
||
});
|
||
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
const last = next[next.length - 1];
|
||
if (last && last.sender === 'ai') {
|
||
last.text = res.data.answer;
|
||
last.sources = res.data.sources;
|
||
}
|
||
return next;
|
||
});
|
||
setLoading(false);
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
console.error('问答请求失败:', err);
|
||
setError(err.response?.data?.error || err.message || '网络请求错误,请稍后重试');
|
||
// 如果报错,清空多余的消息占位符
|
||
setMessages(prev => {
|
||
const next = [...prev];
|
||
if (next.length > 0 && next[next.length - 1].sender === 'ai' && !next[next.length - 1].text) {
|
||
next.pop();
|
||
}
|
||
return next;
|
||
});
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const toggleSteps = (msgIdx: number) => {
|
||
setStepsExpanded(prev => ({
|
||
...prev,
|
||
[msgIdx]: !prev[msgIdx]
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
|
||
{/* 头部面板 */}
|
||
<div className="px-4 py-3.5 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
|
||
<div className="flex items-center gap-1.5 select-none">
|
||
<Sparkles className="w-4 h-4 text-sky-600 animate-pulse" />
|
||
<span className="text-xs font-bold text-slate-800">科研智能问答助手</span>
|
||
</div>
|
||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 对话消息区 */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
onScroll={handleScroll}
|
||
className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"
|
||
>
|
||
{messages.length === 0 ? (
|
||
<div className="py-6 space-y-6">
|
||
<div className="text-center space-y-2 max-w-sm mx-auto">
|
||
<Compass className="w-10 h-10 mx-auto text-sky-500 opacity-60" />
|
||
<h3 className="text-xs font-bold text-slate-800">探索馆藏文献知识库</h3>
|
||
<p className="text-[11px] text-slate-500 leading-relaxed font-semibold">
|
||
基于 ReAct 框架的智能科研体。支持自动读取已解析的 Markdown 文献,在向量库进行 RAG 查询以及天体参数检索。
|
||
</p>
|
||
</div>
|
||
|
||
{/* 推荐提示词 */}
|
||
<div className="space-y-2">
|
||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">推荐学术提问:</span>
|
||
<div className="space-y-2">
|
||
{SUGGESTED_QUESTIONS.map((q, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => handleSend(q)}
|
||
className="w-full text-left bg-white hover:bg-slate-100 border border-slate-200 rounded-lg p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-sm cursor-pointer hover:border-sky-300"
|
||
>
|
||
{q}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
messages.map((msg, index) => (
|
||
<div key={index} className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5`}>
|
||
<span className="text-[10px] font-bold text-slate-400 px-1">
|
||
{msg.sender === 'user' ? '我' : '智能科研助手'}
|
||
</span>
|
||
<div
|
||
className={`max-w-[95%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${
|
||
msg.sender === 'user'
|
||
? 'bg-sky-600 text-white border-sky-600 select-text'
|
||
: 'bg-white text-slate-800 border-slate-200 select-text'
|
||
}`}
|
||
>
|
||
{msg.sender === 'user' ? (
|
||
<div className="space-y-2">
|
||
{msg.imageUrl && (
|
||
<img src={msg.imageUrl} alt="Attached Figure" className="max-w-40 max-h-40 object-contain rounded-md border border-sky-500/30" />
|
||
)}
|
||
<p className="whitespace-pre-wrap">{msg.text}</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{/* Collapsible Steps list */}
|
||
{msg.steps && msg.steps.length > 0 && (
|
||
<div className="border border-slate-100 bg-slate-50/50 rounded-lg p-2 select-none">
|
||
<button
|
||
onClick={() => toggleSteps(index)}
|
||
className="flex items-center justify-between w-full text-left text-[10px] font-bold text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
|
||
>
|
||
<span className="flex items-center gap-1">
|
||
<Brain className="w-3.5 h-3.5 text-purple-500" />
|
||
<span>推理与工具链展开 ({msg.steps.length} 步)</span>
|
||
</span>
|
||
{stepsExpanded[index] ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||
</button>
|
||
|
||
{stepsExpanded[index] && (
|
||
<div className="mt-2 pl-2.5 border-l border-slate-200 space-y-1.5 transition-all">
|
||
{msg.steps.map((step, sIdx) => (
|
||
<div key={sIdx} className="text-[9px] text-slate-655 flex flex-col gap-0.5">
|
||
<span className="font-bold flex items-center gap-1">
|
||
{step.type === 'thought' ? (
|
||
<Brain className="w-3 h-3 text-purple-500 shrink-0" />
|
||
) : step.type === 'error' ? (
|
||
<AlertTriangle className="w-3 h-3 text-rose-500 shrink-0" />
|
||
) : (
|
||
<Settings className={`w-3 h-3 text-sky-600 shrink-0 ${step.isFinished ? '' : 'animate-spin'}`} />
|
||
)}
|
||
<span>{step.label}</span>
|
||
</span>
|
||
{step.detail && (
|
||
<span className="font-mono text-[9px] opacity-75 whitespace-pre-wrap block bg-white px-1.5 py-0.5 rounded border border-slate-100 max-h-24 overflow-y-auto">
|
||
{step.detail}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Final output text */}
|
||
{msg.text ? (
|
||
<div className="prose prose-sm max-w-none text-slate-800 leading-relaxed prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkMath, remarkGfm]}
|
||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
||
>
|
||
{msg.text}
|
||
</ReactMarkdown>
|
||
</div>
|
||
) : (
|
||
!loading && <span className="text-slate-400 italic">正在构建最终结论...</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 关联来源展示 */}
|
||
{msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && (
|
||
<div className="w-full mt-2 pl-2 space-y-1.5 border-l-2 border-slate-200">
|
||
<span className="text-[9px] font-bold text-slate-400 select-none">参考来源文献(点击跳转):</span>
|
||
<div className="grid grid-cols-1 gap-1.5 w-[95%]">
|
||
{msg.sources.map((src, sIdx) => (
|
||
<button
|
||
key={sIdx}
|
||
onClick={() => onJumpToSource(src.bibcode, src.paragraph_index)}
|
||
className="flex items-center justify-between text-left bg-white hover:bg-slate-100 border border-slate-200 rounded-md px-2.5 py-1.5 text-[10px] text-slate-655 font-semibold transition-all shadow-sm cursor-pointer hover:border-sky-300"
|
||
title={src.content}
|
||
>
|
||
<div className="flex items-center gap-1.5 truncate mr-2">
|
||
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
|
||
<span className="font-bold text-slate-700 truncate">{src.bibcode}</span>
|
||
<span className="text-slate-400 text-[9px] font-bold">§{src.paragraph_index + 1}</span>
|
||
</div>
|
||
<span className="text-slate-400 text-[9px] shrink-0 font-medium">
|
||
距: {src.distance.toFixed(3)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
{loading && messages.length > 0 && !messages[messages.length - 1].text && (
|
||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||
<Loader className="w-4 h-4 animate-spin text-sky-600" />
|
||
<span className="text-[10px] font-bold">文献检索及推理结论构建中...</span>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs w-[95%] font-semibold">
|
||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<div className="font-bold">查询发生错误</div>
|
||
<div className="text-[10px] opacity-80 mt-0.5">{error}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={chatEndRef} />
|
||
</div>
|
||
|
||
{/* 待提问图片预览 */}
|
||
{pendingFigure && (
|
||
<div className="px-3 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between shrink-0 select-none">
|
||
<div className="flex items-center gap-2">
|
||
<img
|
||
src={pendingFigure.url}
|
||
alt="Preview"
|
||
className="w-10 h-10 object-cover rounded-md border border-slate-300"
|
||
/>
|
||
<div className="text-[10px] text-slate-550">
|
||
<span className="font-bold text-slate-700 block">已选中图表插图</span>
|
||
<span className="font-mono text-slate-400 truncate max-w-48 block">{pendingFigure.path.split('/').pop()}</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClearPendingFigure}
|
||
className="text-slate-400 hover:text-slate-655 p-1 cursor-pointer"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 底栏输入区 */}
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
handleSend(input);
|
||
}}
|
||
className="p-3 border-t border-slate-200 bg-white shrink-0"
|
||
>
|
||
<div className="flex gap-2 relative items-center">
|
||
<input
|
||
type="text"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
disabled={loading}
|
||
placeholder={pendingFigure ? "针对选中图表提问..." : "向 AI 馆藏助手提问..."}
|
||
className="flex-1 bg-slate-50 border border-slate-200 rounded-xl text-xs text-slate-900 placeholder-slate-400 pl-3 pr-10 py-2.5 focus:outline-none focus:bg-white focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed font-semibold transition-all disabled:opacity-60"
|
||
/>
|
||
{loading ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleStop}
|
||
className="absolute right-1.5 p-1.5 rounded-lg bg-rose-600 hover:bg-rose-700 text-white transition-colors cursor-pointer flex items-center justify-center"
|
||
title="手动停止执行"
|
||
>
|
||
<Square className="w-3.5 h-3.5 fill-white" />
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="submit"
|
||
disabled={!input.trim()}
|
||
className="absolute right-1.5 p-1.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-200 disabled:text-slate-400 transition-colors cursor-pointer flex items-center justify-center"
|
||
>
|
||
<Send className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|