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 工作流,含工具调用步骤展示与引用溯源 - 侧边栏新增"智能科研"导航入口
This commit is contained in:
+10
-2
@@ -8,10 +8,11 @@ import { LibraryPanel } from './features/library/LibraryPanel';
|
||||
import { ReaderPanel } from './features/reader/ReaderPanel';
|
||||
import { CitationPanel } from './features/citation/CitationPanel';
|
||||
import { SyncPanel } from './features/sync/SyncPanel';
|
||||
import { ResearchAgentPanel } from './features/agent/ResearchAgentPanel';
|
||||
import type { StandardPaper, CitationNetwork, NoteRecord } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync'>(() => {
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => {
|
||||
const saved = localStorage.getItem('astro_active_tab');
|
||||
return (saved as any) || 'search';
|
||||
});
|
||||
@@ -544,7 +545,7 @@ export default function App() {
|
||||
{/* 选项卡容器 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 md:p-8 relative z-10 w-full flex flex-col">
|
||||
<div className={`w-full flex-1 flex flex-col min-h-0 ${
|
||||
(activeTab === 'reader' || activeTab === 'citation') ? 'max-w-none' : 'max-w-7xl mx-auto'
|
||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
|
||||
}`}>
|
||||
{activeTab === 'search' && (
|
||||
<SearchPanel
|
||||
@@ -675,6 +676,13 @@ export default function App() {
|
||||
{activeTab === 'sync' && (
|
||||
<SyncPanel />
|
||||
)}
|
||||
|
||||
{activeTab === 'agent' && (
|
||||
<ResearchAgentPanel
|
||||
showConfirm={showConfirm}
|
||||
showAlert={showAlert}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// dashboard/src/components/layout/Sidebar.tsx
|
||||
import { useState } from 'react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles } from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: 'search' | 'library' | 'reader' | 'citation' | 'sync';
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
|
||||
activeTab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
|
||||
selectedPaper: StandardPaper | null;
|
||||
loadCitations: (bibcode: string) => void;
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
|
||||
{ id: 'library', label: '馆藏管理', icon: Library },
|
||||
{ id: 'sync', label: '批量任务', icon: RefreshCw },
|
||||
{ id: 'reader', label: '双语阅读', icon: BookOpen },
|
||||
{ id: 'agent', label: '智能科研', icon: Sparkles },
|
||||
{ id: 'citation', label: '引用星系', icon: GitFork },
|
||||
].map((tab: { id: string; label: string; icon: any; disabled?: boolean }) => {
|
||||
const Icon = tab.icon;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,10 @@ 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 } from 'lucide-react';
|
||||
import {
|
||||
Send, Loader, Sparkles, X, BookOpen, AlertCircle, Compass,
|
||||
Brain, Settings, ChevronDown, ChevronUp, AlertTriangle, Square
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface RetrievalSource {
|
||||
@@ -17,11 +20,21 @@ interface RetrievalSource {
|
||||
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 {
|
||||
@@ -50,6 +63,19 @@ const SUGGESTED_QUESTIONS = [
|
||||
"文献库中关于双星合并前奏(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,
|
||||
@@ -61,16 +87,50 @@ export function AIAssistantPanel({
|
||||
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(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading]);
|
||||
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;
|
||||
@@ -84,6 +144,7 @@ export function AIAssistantPanel({
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
setShouldAutoScroll(true);
|
||||
|
||||
const isFigureQuery = !!pendingFigure;
|
||||
const currentFigurePath = pendingFigure?.path;
|
||||
@@ -92,61 +153,258 @@ export function AIAssistantPanel({
|
||||
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
|
||||
});
|
||||
const aiMsg: Message = {
|
||||
sender: 'ai',
|
||||
text: res.data.answer
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const aiMsg: Message = {
|
||||
sender: 'ai',
|
||||
text: res.data.answer,
|
||||
sources: res.data.sources
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
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 || '网络请求错误,请稍后重试');
|
||||
} finally {
|
||||
// 如果报错,清空多余的消息占位符
|
||||
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">
|
||||
<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">Astro RAG 学术助手</span>
|
||||
<span className="text-xs font-bold text-slate-800">科研智能问答助手</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 对话消息区 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
|
||||
<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">
|
||||
基于向量检索(SQLite-Vec)与学术大语言模型,跨越所有已解析的 Markdown 文献为您提供深度知识整合与溯源解答。
|
||||
基于 ReAct 框架的智能科研体。支持自动读取已解析的 Markdown 文献,在向量库进行 RAG 查询以及天体参数检索。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -158,7 +416,7 @@ export function AIAssistantPanel({
|
||||
<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-colors shadow-sm cursor-pointer"
|
||||
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>
|
||||
@@ -168,15 +426,15 @@ export function AIAssistantPanel({
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, index) => (
|
||||
<div key={index} className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1`}>
|
||||
<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' ? '我' : 'Astro AI'}
|
||||
{msg.sender === 'user' ? '我' : '智能科研助手'}
|
||||
</span>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${
|
||||
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'
|
||||
: 'bg-white text-slate-800 border-slate-200'
|
||||
? 'bg-sky-600 text-white border-sky-600 select-text'
|
||||
: 'bg-white text-slate-800 border-slate-200 select-text'
|
||||
}`}
|
||||
>
|
||||
{msg.sender === 'user' ? (
|
||||
@@ -187,13 +445,60 @@ export function AIAssistantPanel({
|
||||
<p className="whitespace-pre-wrap">{msg.text}</p>
|
||||
</div>
|
||||
) : (
|
||||
<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 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>
|
||||
@@ -201,13 +506,13 @@ export function AIAssistantPanel({
|
||||
{/* 关联来源展示 */}
|
||||
{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">参考来源文献(点击跳转):</span>
|
||||
<div className="grid grid-cols-1 gap-1.5 w-[90%]">
|
||||
<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"
|
||||
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">
|
||||
@@ -227,15 +532,15 @@ export function AIAssistantPanel({
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
{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>
|
||||
<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-[90%] font-semibold">
|
||||
<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>
|
||||
@@ -249,7 +554,7 @@ export function AIAssistantPanel({
|
||||
|
||||
{/* 待提问图片预览 */}
|
||||
{pendingFigure && (
|
||||
<div className="px-3 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between shrink-0">
|
||||
<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}
|
||||
@@ -288,13 +593,24 @@ export function AIAssistantPanel({
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || loading}
|
||||
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"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user