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:
fmq
2026-06-15 17:30:45 +08:00
parent 22e7e1dcee
commit b1fb884f21
23 changed files with 4174 additions and 347 deletions
+10 -2
View File
@@ -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>
+4 -3
View File
@@ -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>