// dashboard/src/features/reader/AIAssistantPanel.tsx // 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件 import { useState, useRef, useEffect } from 'react'; import { Send, Loader, X, BookOpen, AlertCircle, Compass, Brain, Square, Paperclip, } from 'lucide-react'; import axios from 'axios'; import { AgentMarkdown, useAgentSSE, useAutoScroll, ThoughtCard, ToolCallCard, SubAgentContainer, PARENT_COLORS, routeThought, routeToolCall, routeToolResult, routeTextDelta, } from './agent'; import type { SSEEventHandlers, TimelineItem } from './agent'; interface RetrievalSource { bibcode: string; paragraph_index: number; content: string; distance: number; } interface Message { sender: 'user' | 'ai'; text: string; sources?: RetrievalSource[]; imageUrl?: string; timeline: TimelineItem[]; } interface AIAssistantPanelProps { bibcode: string; onJumpToSource: (bibcode: string, paragraphIndex: number) => void; } const SUGGESTED_QUESTIONS = [ '对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。', '有哪些文献提及了脉动白矮星的非径向振动模?', '简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。', '文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?', ]; export function AIAssistantPanel({ bibcode, onJumpToSource, }: AIAssistantPanelProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [error, setError] = useState(null); const [sessionId, setSessionId] = useState(null); // 折叠状态(共享 ThoughtCard / ToolCallCard / SubAgentContainer 的展开控制) const [expandedThoughts, setExpandedThoughts] = useState>({}); const [expandedArgs, setExpandedArgs] = useState>({}); const [expandedResults, setExpandedResults] = useState>({}); const [collapsedSubAgents, setCollapsedSubAgents] = useState>({}); const [pendingImage, setPendingImage] = useState<{ data?: string; path?: string; mime_type: string; name: string; } | null>(null); const fileInputRef = useRef(null); const { streaming, send, stop: sseStop } = useAgentSSE(); const { chatEndRef, scrollContainerRef, setShouldAutoScroll, handleScroll } = useAutoScroll([messages, streaming]); // ── 折叠控制 ── const toggleThought = (key: string) => setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] })); const toggleArgs = (tcId: string) => setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] })); const toggleResult = (tcId: string) => setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] })); const toggleSubAgent = (id: string) => setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] })); // ── 图片处理 ── const attachImage = (file: File) => { if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = (e) => { const result = e.target?.result as string; const commaIdx = result.indexOf(','); if (commaIdx === -1) return; setPendingImage({ data: result.substring(commaIdx + 1), mime_type: file.type, name: file.name, }); }; reader.readAsDataURL(file); }; const handlePaste = (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { e.preventDefault(); const file = items[i].getAsFile(); if (file) attachImage(file); return; } } }; // ── bibcode 切换重置 ── useEffect(() => { setMessages([]); setSessionId(null); setError(null); setExpandedThoughts({}); setExpandedArgs({}); setExpandedResults({}); setCollapsedSubAgents({}); setPendingImage(null); }, [bibcode]); const handleStop = async () => { sseStop(); if (sessionId) { try { await axios.post(`/api/chat/sessions/${sessionId}/stop`); } catch { /* noop */ } } }; const handleSend = async (questionText: string) => { if ((!questionText.trim() && !pendingImage) || streaming) return; const figure = pendingImage; setError(null); setPendingImage(null); const userMsg: Message = { sender: 'user', text: questionText, timeline: [], imageUrl: figure ? (figure.path ? `/api/files/${figure.path}` : `data:${figure.mime_type};base64,${figure.data}`) : undefined, }; setMessages(prev => [...prev, userMsg]); setInput(''); setShouldAutoScroll(true); // AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[]) const aiMsg: Message = { sender: 'ai', text: '', timeline: [], sources: [] }; setMessages(prev => [...prev, aiMsg]); // ── SSE 事件处理器(使用共享子代理路由)── const handlers: SSEEventHandlers = { onSession: (sid) => { if (!sessionId) setSessionId(sid); }, onThought: (step, content) => { setMessages(prev => updateAiTimeline(prev, (timeline) => routeThought(timeline, step, content), )); }, onToolCall: (step, _id, name, args) => { setMessages(prev => updateAiTimeline(prev, (timeline) => routeToolCall(timeline, step, _id, name, args), )); }, onToolResult: (_step, tcId, name, _output, isError, metadata) => { setExpandedResults(prev => ({ ...prev, [tcId]: true })); setMessages(prev => { if (prev.length === 0) return prev; const last = prev[prev.length - 1]; if (last.sender !== 'ai') return prev; const newTimeline = routeToolResult(last.timeline, tcId, name, _output, isError); // RAG 来源收集 let sources = [...(last.sources || [])]; if (name === 'rag_search' && metadata?.sources) { for (const s of metadata.sources) { if (!sources.some( c => c.bibcode === s.bibcode && c.paragraph_index === s.paragraph_index, )) { sources.push({ bibcode: s.bibcode, paragraph_index: s.paragraph_index, content: s.preview || '', distance: s.distance, }); } } } return [...prev.slice(0, -1), { ...last, timeline: newTimeline, sources }]; }); }, onTextDelta: (content, toolCallId) => { if (toolCallId) { setExpandedResults(prev => ({ ...prev, [toolCallId]: true })); } setMessages(prev => { const last = prev[prev.length - 1]; if (last?.sender !== 'ai') return prev; const { timeline, answerDelta } = routeTextDelta(last.timeline, content, toolCallId); return [ ...prev.slice(0, -1), { ...last, timeline, text: last.text + answerDelta }, ]; }); }, onError: (msg) => { setError(msg); }, }; const contextPrefix = `[当前正在阅读文献: ${bibcode}] `; const requestQuery = questionText.includes(bibcode) ? questionText : `${contextPrefix}${questionText}`; let imagePayload: { data: string; mime_type: string } | undefined; if (figure?.data) { imagePayload = { data: figure.data, mime_type: figure.mime_type }; } await send( { question: requestQuery, sessionId, mode: 'literature-reader', image: imagePayload ? { data: imagePayload.data, mime_type: imagePayload.mime_type } : undefined, }, handlers, ); }; // ── 时间线条目渲染(委托到共享组件)── const renderTimelineItem = (item: TimelineItem, idx: number, isStreaming: boolean) => { switch (item.type) { case 'subagent_container': return ( toggleSubAgent(item.id)} expandedThoughts={expandedThoughts} expandedArgs={expandedArgs} expandedResults={expandedResults} onToggleThought={toggleThought} onToggleArgs={toggleArgs} onToggleResult={toggleResult} /> ); case 'thought': { const thoughtKey = `ai-thought-${item.step}-${idx}`; return ( toggleThought(thoughtKey)} /> ); } case 'tool_call': { const tcId = item.id; return ( toggleArgs(tcId)} onToggleResult={() => toggleResult(tcId)} /> ); } case 'answer': return null; // finalAnswer 单独渲染在气泡内 default: return null; } }; return (
{/* 消息区 */}
{messages.length === 0 ? (

文献阅读助手

专注文献精读与理解的 AI 助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。

推荐学术提问:
{SUGGESTED_QUESTIONS.map((q, idx) => ( ))}
) : ( messages.map((msg, index) => (
{msg.sender === 'user' ? '我' : '天文科研助手'}
{msg.sender === 'user' ? (
{msg.imageUrl && ( Attached Figure )}

{msg.text}

) : (
{/* 时间线条目(思考/工具调用/子代理容器)*/} {msg.timeline.length > 0 && (
推理与工具链 ({msg.timeline.length} 项)
{msg.timeline.map((item, tIdx) => renderTimelineItem(item, tIdx, !!streaming), )}
)} {/* 最终回答 */} {msg.text ? (
{msg.text}
) : ( !streaming && ( 正在构建最终结论... ) )}
)}
{/* 关联来源 */} {msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && (
参考来源文献(点击跳转):
{msg.sources.map((src, sIdx) => ( ))}
)}
)) )} {streaming && messages.length > 0 && !messages[messages.length - 1].text && (
文献检索及推理结论构建中...
)} {error && (
查询发生错误
{error}
)}
{/* 底栏输入 */}
{ e.preventDefault(); handleSend(input); }} className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0" >