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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user