AstroResearch/dashboard/src/features/reader/AIAssistantPanel.tsx
Asfmq b1fb884f21 feat: ReAct Agent 自主科研引擎、LLM 流式工具调用、论文搜索服务化
核心新增 —— 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 工作流,含工具调用步骤展示与引用溯源
 - 侧边栏新增"智能科研"导航入口
2026-06-15 17:30:45 +08:00

619 lines
26 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.

// 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>
);
}