feat: Agent 多模式系统、视觉模型集成、LLM 能力分层与 P3 性能收尾

核心架构变更:

  1. Agent 多模式系统替代 Coordinator
     - 移除 src/agent/coordinator/(Coordinator Agent/Worker/Tools,946 行)
     - 新建 src/agent/modes/:声明式模式抽象(AgentMode/ModeConfig/ToolSet)
     - 三种内置模式:
       - default:通用科研助手,零覆盖保持现有行为
       - deep-research:16 步、启用思考、research 权限、系统性调研
       - literature-reader:白名单工具、只读沙箱、结构化阅读
     - ModeRegistry + ModeConfig 预设 + ToolSet 过滤 + 身份/原则覆盖
     - AgentRuntime::with_mode() 统一入口,模式持久化到 session.mode 字段
     - GET /api/chat/modes 提供模式列表给前端选择器

  2. 视觉模型与图片分析
     - 新增 analyze_image 工具(340 行):本地/URL 图片 → 视觉模型流式分析
     - LlmClient::analyze_image_stream():SSE 增量实时推送
     - 配置:LLM_VISION_MODEL / LLM_VISION_API_KEY / LLM_VISION_API_BASE
     - 前端:粘贴/选择图片附件,重试时复用文件路径
     - Service 层移除 /chat/rag 和 /chat/figure 端点,统一走 Agent SSE
     - Body limit 提升至 100MB 适配大图上传

  3. LLM 三级能力分层
     - Tier 1 (Core) → Tier 2 (Medium) → Tier 3 (Fast),级联回退
     - medium_llm / fast_llm / vision_llm 注入 AppState
     - 资产批量翻译 → Medium LLM + Semaphore(3) 并发控制
     - 记忆提取/上下文压缩子代理 → Fast LLM
     - SubAgentRunner::with_llm_client() 支持注入专用 LLM

  4. 数据库与性能优化
     - SQLite 启用 WAL + busy_timeout(10s) 处理并发写入
     - RAG ingest:DELETE 合并为原子语句 + 批量事务写入
     - Meta sync:save_paper_to_db_tx() 事务化批量插入
     - 翻译词典:first_words HashSet 预过滤 + next_valid_index 跳跃优化
     - read_file 不截断输出 + skip_persist 防止级联磁盘持久化

  5. 工具系统增强
     - ToolContext 增加 tool_call_id + max_output_chars
     - ToolOutput 增加 skip_persist 标记
     - TextDelta SSE 携带可选 tool_call_id 支持工具的流式输出
     - ChatMessage::text() 辅助方法
This commit is contained in:
fmq
2026-06-24 19:52:27 +08:00
parent cec4b8cf7b
commit 85b6429c30
44 changed files with 2307 additions and 1578 deletions
@@ -12,7 +12,8 @@ import {
Brain, Settings, Eye, CheckCircle2, AlertTriangle,
Send, Loader, Plus, Trash2, Compass, Clock, Square,
BarChart3, ScrollText, Network, Rewind, RotateCcw,
GitBranch, RefreshCw, Search, X, MessageSquare, BookOpen
GitBranch, RefreshCw, Search, X, MessageSquare, BookOpen,
PanelLeftClose, PanelLeftOpen, Paperclip
} from 'lucide-react';
import { AskUserQuestionCard } from './AskUserQuestionCard';
import { PermissionRequestCard } from './PermissionRequestCard';
@@ -22,6 +23,7 @@ interface SessionSummary {
session_id: string;
title: string;
model: string;
mode: string;
turn_count: number;
summary?: string | null;
created_at: string;
@@ -72,6 +74,7 @@ type ColorScheme = 'parent' | 'subagent';
interface ActiveTurn {
question: string;
imagePath?: string;
timeline: TimelineItem[];
finalAnswer: string;
error?: string;
@@ -86,6 +89,7 @@ interface ProcessedTurn {
turn_index: number;
question: string;
questionMessageId?: number;
imagePath?: string;
timeline: TimelineItem[];
usage?: {
prompt_tokens: number;
@@ -113,6 +117,7 @@ interface RetryResult {
new_turn_index: number;
deleted_count: number;
session_id: string;
image_path?: string | null;
}
const safeSchema = {
@@ -180,6 +185,23 @@ function getToolDisplayName(name: string): string {
}
}
interface AgentMode {
id: string;
name: string;
description: string;
icon: string;
}
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Compass,
BookOpen
};
function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> {
return ICON_MAP[iconName] || Brain;
}
interface ResearchAgentPanelProps {
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
showAlert?: (message: string, title?: string) => void;
@@ -192,10 +214,41 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
const [activeTurn, setActiveTurn] = useState<ActiveTurn | null>(null);
const [streaming, setStreaming] = useState(false);
const [input, setInput] = useState('');
const [agentMode, setAgentMode] = useState('default');
const [agentModes, setAgentModes] = useState<AgentMode[]>([]);
const [thinking, setThinking] = useState(false);
const [coordinatorMode, setCoordinatorMode] = useState(false);
const [pendingImage, setPendingImage] = useState<{ data?: string; path?: string; mime_type: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [loadingSessions, setLoadingSessions] = useState(false);
// 将 File 转为 base64 并设置为 pendingImage
const attachImage = (file: File) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(',');
const data = commaIdx > -1 ? result.substring(commaIdx + 1) : result;
setPendingImage({ data, 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;
}
}
};
const [loadingHistory, setLoadingHistory] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// 全文搜索历史记录状态
const [searchQuery, setSearchQuery] = useState('');
@@ -266,6 +319,22 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
useEffect(() => {
fetchSessions(true);
const fetchModes = async () => {
try {
const res = await axios.get<AgentMode[]>('/api/chat/modes');
setAgentModes(res.data);
} catch (e) {
console.error('获取 Agent 运行模式列表失败:', e);
// Fallback in case of API failure for resilience
setAgentModes([
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
]);
}
};
fetchModes();
}, []);
// 加载选中的会话历史消息
@@ -278,6 +347,10 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
const res = await axios.get<{ session: SessionSummary; messages: MessageRecord[] }>(`/api/chat/sessions/${sessionId}`);
const newMessages = res.data.messages;
if (res.data.session && res.data.session.mode) {
setAgentMode(res.data.session.mode);
}
// 如果有成功回调,在更新 messages 之前立刻执行(如清除 activeTurn),保证在同一个 React 渲染批处理周期内完成
if (onSuccess) {
onSuccess();
@@ -531,7 +604,15 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
// 重新加载会话以移除已被硬删除的消息,然后自动触发发送
await loadSessionHistory(currentSessionId, true);
await fetchSessions();
// 如果原消息附带了图片,通过 path 复用已有文件,避免重新上传
if (res.data.image_path) {
const ext = res.data.image_path.split('.').pop() || 'png';
const mimeType = { jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', png: 'image/png' }[ext] || 'image/png';
handleSend(questionText, { path: res.data.image_path, mime_type: mimeType, name: res.data.image_path.split('/').pop() || 'image' });
return;
}
// 触发自动重发
handleSend(questionText);
} catch (e: any) {
@@ -555,7 +636,8 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
};
// 发送消息与流式响应处理
const handleSend = async (questionText: string) => {
// imageOverride: 重试时直接传入图片数据,绕过 React 异步状态更新
const handleSend = async (questionText: string, imageOverride?: { data?: string; path?: string; mime_type: string; name: string } | null) => {
if (!questionText.trim() || streaming) return;
if (!currentSessionId) {
@@ -563,11 +645,16 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
}
setInput('');
const image = imageOverride !== undefined ? imageOverride : pendingImage;
setPendingImage(null);
setStreaming(true);
setShouldAutoScroll(true);
const active: ActiveTurn = {
question: questionText,
imagePath: image
? (image.path ? `/api/files/${image.path}` : `data:${image.mime_type};base64,${image.data}`)
: undefined,
timeline: [],
finalAnswer: '',
};
@@ -582,8 +669,13 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
body: JSON.stringify({
question: questionText,
session_id: currentSessionId,
mode: agentMode,
thinking,
coordinator_mode: coordinatorMode,
...(image ? {
image: image.path
? { path: image.path, mime_type: image.mime_type }
: { data: image.data, mime_type: image.mime_type },
} : {}),
}),
});
@@ -756,6 +848,8 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
});
break;
case 'tool_result':
// 自动展开结果区域
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
setActiveTurn(prev => {
if (!prev) return prev;
const tcId = event.tool_call_id;
@@ -788,10 +882,11 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
}
}
// 父代理工具结果(正常路径)
// 始终更新 result,即使已有流式输出的部分内容——最终 tool_result 是完整答案
return {
...prev,
timeline: prev.timeline.map(t => {
if (t.type === 'tool_call' && t.id === tcId && !t.result) {
if (t.type === 'tool_call' && t.id === tcId) {
return {
...t,
result: { output: event.output, isError: event.is_error },
@@ -803,8 +898,42 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
});
break;
case 'text_delta':
// 如果带 tool_call_id,自动展开对应工具的结果区域以显示流式输出
if (event.tool_call_id) {
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
}
setActiveTurn(prev => {
if (!prev) return prev;
// 如果带有 tool_call_id,流式输出到对应工具的结果区域
if (event.tool_call_id) {
const tcId = event.tool_call_id;
// 检查 timeline 中是否有匹配的 tool_call
const hasToolCall = prev.timeline.some(
t => t.type === 'tool_call' && 'id' in t && t.id === tcId
);
if (hasToolCall) {
return {
...prev,
timeline: prev.timeline.map(t => {
if (t.type === 'tool_call' && t.id === tcId) {
const prevOutput = t.result?.output || '';
return {
...t,
result: {
output: prevOutput + event.content,
isError: false,
},
};
}
return t;
}),
};
}
// 工具调用尚未出现(TTL 场景),暂存到 finalAnswer 不处理
// 等 tool_call + tool_result 出现后,tool_result 会覆盖
return prev;
}
// 无 tool_call_id → 主回答文本流
return {
...prev,
finalAnswer: prev.finalAnswer + event.content,
@@ -1065,8 +1194,13 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
</button>
</div>
{isResultExpanded ? (
<div className="font-mono text-[10px] bg-slate-50 border border-slate-200 rounded-lg p-2.5 text-slate-700 overflow-x-auto whitespace-pre-wrap select-text leading-relaxed max-h-96">
{item.result!.output}
<div className="text-xs bg-slate-50 border border-slate-200 rounded-lg p-3 text-slate-700 overflow-auto select-text leading-relaxed max-h-[60vh] prose prose-sm max-w-none prose-headings:text-slate-800 prose-code:text-sky-700 prose-pre:bg-slate-100 prose-pre:text-xs prose-img:rounded-lg">
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
>
{item.result!.output}
</ReactMarkdown>
</div>
) : (
<div className="text-[10px] text-slate-400 italic font-medium">
@@ -1111,20 +1245,29 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-2xl border border-slate-200 h-[calc(100vh-130px)] shadow-xs">
{/* 左侧会话列表侧栏 */}
<div className="w-64 bg-slate-50 border-r border-slate-200 flex flex-col justify-between shrink-0 select-none">
<div className={`transition-all duration-300 ease-in-out ${sidebarCollapsed ? 'w-0 overflow-hidden opacity-0 border-r-0' : 'w-64 border-r border-slate-200'} bg-slate-50 flex flex-col justify-between shrink-0 select-none`}>
<div className="flex flex-col min-h-0 flex-1">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between shrink-0">
<span className="text-xs font-extrabold text-slate-800 tracking-wider flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-sky-600" />
<span></span>
{!sidebarCollapsed && <span></span>}
</span>
<button
onClick={handleNewSession}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 hover:text-slate-800 transition-all cursor-pointer shadow-2xs hover:scale-105"
title="新建会话"
>
<Plus className="w-3.5 h-3.5" />
</button>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={handleNewSession}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 hover:text-slate-800 transition-all cursor-pointer shadow-2xs hover:scale-105"
title="新建会话"
>
<Plus className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setSidebarCollapsed(true)}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-all cursor-pointer"
title="收起侧栏"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 搜索框区域 */}
@@ -1274,15 +1417,26 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
{/* 会话头部 */}
<div className="px-5 py-4 border-b border-slate-200 flex items-center justify-between shrink-0 bg-white">
<div className="min-w-0 pr-4">
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
{currentSessionId
? (sessions.find(s => s.session_id === currentSessionId)?.title || '未命名会话')
: '探索性科研研讨'}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
(ReAct Thought Action Observation)
</p>
<div className="min-w-0 pr-4 flex items-center gap-2">
{sidebarCollapsed && (
<button
onClick={() => setSidebarCollapsed(false)}
className="p-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-all cursor-pointer mr-1 shrink-0"
title="展开侧栏"
>
<PanelLeftOpen className="w-4 h-4" />
</button>
)}
<div>
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
{currentSessionId
? (sessions.find(s => s.session_id === currentSessionId)?.title || '未命名会话')
: '探索性科研研讨'}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
(ReAct Thought Action Observation)
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* 指标面板按钮 */}
@@ -1358,7 +1512,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin"
className="flex-1 overflow-y-auto p-5 pb-36 space-y-6 bg-slate-50/50 scrollbar-thin"
>
{loadingHistory ? (
@@ -1411,8 +1565,11 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
<Rewind className="w-3.5 h-3.5" />
</button>
)}
<div className="rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text flex-1">
{turn.question}
<div className="rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text flex-1 space-y-2">
{turn.imagePath && (
<img src={turn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" />
)}
<div>{turn.question}</div>
</div>
</div>
</div>
@@ -1439,8 +1596,11 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
{/* 当前提问 */}
<div className="flex flex-col items-end space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600">
{activeTurn.question}
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 space-y-2">
{activeTurn.imagePath && (
<img src={activeTurn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" />
)}
<div>{activeTurn.question}</div>
</div>
</div>
@@ -1534,65 +1694,137 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
e.preventDefault();
handleSend(input);
}}
className="p-4 border-t border-slate-200 bg-white shrink-0"
className="absolute bottom-0 left-0 right-0 p-4 bg-transparent pointer-events-none shrink-0"
>
<div className="flex gap-2.5 relative items-center max-w-4xl mx-auto">
{/* 思考模式开关 */}
<button
type="button"
onClick={() => setThinking(!thinking)}
disabled={streaming}
title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'}
className={`p-2.5 rounded-xl border text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5 shrink-0 ${
thinking
? 'bg-purple-50 border-purple-300 text-purple-700 shadow-2xs'
: 'bg-slate-50 border-slate-250 text-slate-400 hover:text-purple-500 hover:border-purple-200'
} disabled:opacity-60`}
>
<Brain className={`w-4 h-4 ${thinking ? 'text-purple-500' : ''}`} />
<span className="hidden sm:inline">{thinking ? '思考中' : '思考'}</span>
</button>
{/* 协调者模式开关 */}
<button
type="button"
onClick={() => setCoordinatorMode(!coordinatorMode)}
disabled={streaming}
title={coordinatorMode ? '协调者模式已开启(委托子智能体执行)' : '协调者模式已关闭(单智能体直接执行)'}
className={`p-2.5 rounded-xl border text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5 shrink-0 ${
coordinatorMode
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-2xs'
: 'bg-slate-50 border-slate-250 text-slate-400 hover:text-sky-500 hover:border-sky-200'
} disabled:opacity-60`}
>
<Network className={`w-4 h-4 ${coordinatorMode ? 'text-sky-500' : ''}`} />
<span className="hidden sm:inline">{coordinatorMode ? '协调中' : '协调'}</span>
</button>
<input
type="text"
<div className="max-w-4xl mx-auto bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all pointer-events-auto shadow-lg">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend(input);
}
}}
disabled={streaming}
placeholder="向科研智能体提问(可进行 ADS 检索、文献结构解析、SIMBAD 天体查询等..."
className="flex-1 bg-slate-50 border border-slate-250 rounded-xl text-xs text-slate-900 placeholder-slate-400 pl-4.5 pr-12 py-3.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"
placeholder="向科研智能体提问(可粘贴或上传图片,配合深度研究模式分析图表..."
rows={2}
onPaste={handlePaste}
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 leading-relaxed font-semibold min-h-[44px] max-h-40"
/>
{streaming ? (
<button
type="button"
onClick={handleStop}
className="absolute right-2 p-2 rounded-xl bg-rose-600 hover:bg-rose-700 text-white transition-colors cursor-pointer flex items-center justify-center"
title="手动停止执行"
>
<Square className="w-4 h-4 fill-white" />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="absolute right-2 p-2 rounded-xl 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-4 h-4" />
</button>
{/* 图片预览 */}
{pendingImage && (
<div className="flex items-center gap-2 px-1 pb-2">
<img
src={pendingImage.path ? `/api/files/${pendingImage.path}` : `data:${pendingImage.mime_type};base64,${pendingImage.data}`}
alt={pendingImage.name}
className="h-16 rounded-lg border border-slate-200 object-cover"
/>
<span className="text-[10px] text-slate-500 truncate max-w-[200px]">{pendingImage.name}</span>
<button
type="button"
onClick={() => setPendingImage(null)}
className="p-0.5 rounded-full bg-slate-200 hover:bg-slate-300 text-slate-500 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
)}
<div className="flex justify-between items-center mt-2.5 pt-2 border-t border-slate-100/80">
<div className="flex flex-wrap gap-2 items-center">
{/* 图片上传按钮 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) attachImage(file);
e.target.value = ''; // 允许重复选择同一文件
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={streaming}
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${
pendingImage
? 'bg-sky-50 border-sky-300 text-sky-700'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Paperclip className="w-3.5 h-3.5" />
</button>
{/* Agent 模式选择器 */}
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
{agentModes.map((m) => {
const IconComp = getIconComponent(m.icon);
const isSelected = agentMode === m.id;
return (
<button
key={m.id}
type="button"
onClick={() => setAgentMode(m.id)}
disabled={streaming}
title={m.description}
className={`px-2 py-1.5 rounded-md text-[10px] font-extrabold transition-all border cursor-pointer flex items-center gap-1 shrink-0 ${
isSelected
? 'bg-white text-indigo-700 shadow-xs border-slate-200/50'
: 'bg-transparent border-transparent text-slate-400 hover:text-indigo-600'
} disabled:opacity-60`}
>
<IconComp className={`w-3.5 h-3.5 ${isSelected ? 'text-indigo-500' : ''}`} />
<span className="hidden sm:inline">{m.name}</span>
</button>
);
})}
</div>
{/* 思考模式开关 — 仅默认模式允许用户选择,其他模式由代码固定 */}
{agentMode === 'default' && (
<>
<div className="h-4 w-px bg-slate-200 mx-1 hidden sm:block" />
<button
type="button"
onClick={() => setThinking(!thinking)}
disabled={streaming}
title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'}
className={`px-2.5 py-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 shrink-0 ${
thinking
? 'bg-sky-50 border-sky-205 text-sky-700 shadow-3xs'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Brain className={`w-3.5 h-3.5 ${thinking ? 'text-sky-500' : ''}`} />
<span className="hidden sm:inline"></span>
</button>
</>
)}
</div>
<div>
{streaming ? (
<button
type="button"
onClick={handleStop}
className="p-2 rounded-xl bg-rose-600 hover:bg-rose-700 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行"
>
<Square className="w-3.5 h-3.5 fill-white" />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="p-2 rounded-xl bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</form>
</div>
@@ -1647,6 +1879,10 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
if (msg.role === 'user') {
turn.question = msg.content;
turn.questionMessageId = msg.id;
// 提取用户消息中的图片路径
if (msg.metadata?.image_path) {
turn.imagePath = `/api/files/${msg.metadata.image_path}`;
}
} else if (msg.role === 'assistant') {
const stepNum = msg.step_index;
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
@@ -1668,7 +1904,7 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
} catch (e) { /* ignore parse errors */ }
} catch { /* ignore parse errors */ }
turn.timeline.push({
type: 'tool_call',
step: stepNum,
@@ -1749,7 +1985,7 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
} catch (e) { /* ignore */ }
} catch { /* ignore */ }
children.push({
type: 'tool_call',
step: stepNum,
@@ -135,20 +135,18 @@ export function AIAssistantPanel({
const handleSend = async (questionText: string) => {
if (!questionText.trim() || loading) return;
const figure = pendingFigure;
setError(null);
const userMsg: Message = {
sender: 'user',
text: questionText,
imageUrl: pendingFigure?.url
imageUrl: figure?.url || undefined
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
setShouldAutoScroll(true);
const isFigureQuery = !!pendingFigure;
const currentFigurePath = pendingFigure?.path;
if (onClearPendingFigure) {
onClearPendingFigure();
}
@@ -163,26 +161,57 @@ export function AIAssistantPanel({
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;
// 如果有待处理的图片,先获取并转为 base64
let imagePayload: { data: string; mime_type: string } | undefined = undefined;
if (figure) {
try {
const imgResponse = await fetch(figure.url);
if (!imgResponse.ok) {
throw new Error(`Failed to fetch image: ${imgResponse.statusText}`);
}
return next;
});
setLoading(false);
} else {
// 调用智能体流式对话接口(带文献上下文提示,确保 Agent 优先分析当前阅读的文献)
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
const requestQuery = questionText.includes(bibcode) ? questionText : `${contextPrefix}${questionText}`;
const blob = await imgResponse.blob();
const base64Res = await new Promise<{ data: string; mimeType: string }>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(',');
if (commaIdx === -1) {
reject(new Error('Invalid data URL'));
return;
}
const data = result.substring(commaIdx + 1);
const match = result.match(/^data:([^;]+);base64,/);
const mimeType = match ? match[1] : blob.type || 'image/png';
resolve({ data, mimeType });
};
reader.onerror = reject;
});
imagePayload = {
data: base64Res.data,
mime_type: base64Res.mimeType
};
} catch (imgErr) {
console.error('Failed to process image attachment:', imgErr);
setError('获取或处理选中的图表失败,请重试');
setMessages(prev => {
const next = [...prev];
// 移除用户和 AI 占位消息
if (next.length >= 2) {
return next.slice(0, -2);
}
return next;
});
setLoading(false);
return;
}
}
// 调用智能体流式对话接口(带文献上下文提示,确保 Agent 优先分析当前阅读的文献)
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
const requestQuery = questionText.includes(bibcode) ? questionText : `${contextPrefix}${questionText}`;
try {
const response = await fetch('/api/chat/agent', {
@@ -193,6 +222,8 @@ export function AIAssistantPanel({
body: JSON.stringify({
question: requestQuery,
session_id: sessionId,
mode: 'literature-reader',
image: imagePayload,
}),
});
@@ -271,7 +302,7 @@ export function AIAssistantPanel({
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');
const 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;
@@ -337,26 +368,17 @@ export function AIAssistantPanel({
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
});
console.error('智能体流式对话失败:', streamErr);
setError('智能体流式对话失败,请稍后重试');
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;
if (next.length > 0 && next[next.length - 1].sender === 'ai' && !next[next.length - 1].text) {
next.pop();
}
return next;
});
setLoading(false);
}
}
} catch (err: any) {
console.error('问答请求失败:', err);
setError(err.response?.data?.error || err.message || '网络请求错误,请稍后重试');
@@ -396,15 +418,15 @@ export function AIAssistantPanel({
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"
className="flex-1 overflow-y-auto p-4 pb-28 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>
<h3 className="text-xs font-bold text-slate-800"></h3>
<p className="text-[11px] text-slate-500 leading-relaxed font-semibold">
ReAct Markdown RAG
AI
</p>
</div>
@@ -582,35 +604,43 @@ export function AIAssistantPanel({
e.preventDefault();
handleSend(input);
}}
className="p-3 border-t border-slate-200 bg-white shrink-0"
className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0"
>
<div className="flex gap-2 relative items-center">
<input
type="text"
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all relative pointer-events-auto shadow-md">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend(input);
}
}}
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"
rows={2}
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 pr-8 leading-relaxed font-semibold min-h-[36px] max-h-32"
/>
{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 className="absolute right-2 bottom-2">
{loading ? (
<button
type="button"
onClick={handleStop}
className="p-1.5 rounded-lg bg-rose-600 hover:bg-rose-700 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行"
>
<Square className="w-3.5 h-3.5 fill-white" />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="p-1.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</form>
</div>
+1
View File
@@ -57,6 +57,7 @@ export interface SessionSummary {
session_id: string;
title: string;
model: string;
mode: string;
turn_count: number;
created_at: string;
updated_at: string;