refactor: 全栈架构重构与质量硬化
核心架构重构: - Config 按职责拆分为 LlmConfig/EmbeddingConfig/VisionConfig/CdsConfig/StorageConfig 五个子结构 - AppState 拆分为 LlmState/DataSourceState/SessionState 三个子结构,消除 50+ 平铺字段 - 新增 ServiceError 结构化错误类型替代 handler 中的 msg.contains() 字符串匹配 - 移除 api::handlers 兼容命名空间,路由直接引用 agent/auth/papers 等模块 认证性能优化: - login_rate_limiter/upload_rate_limiter 从 Mutex<HashMap> 迁移为 DashMap(无锁) - 新增 session_last_active: DashMap<String, AtomicU64>,auth 中间件快速路径免写锁 - 会话过期清理改为按间隔触发(300s),避免每次请求全表扫描 - MAX_SESSIONS 1000→10000,SSE 广播通道 256→1024 Agent 工具增强: - AgentTool trait 新增 is_internal()/display_name(),SSE 事件携带工具元数据 - 新增 GET /chat/tools 端点暴露注册工具列表 - pending_questions/pending_permissions 增加 created_at 时间戳,自动清理过期条目(10min TTL) - Agent 超时现在正确 abort 后台任务并设置取消令牌 观测数据源修复: - FITS 解析: APOGEE/DESI 改用 read_image+切片替代 read_rows(修复 fitsio panic) - ZTF: CIRCLE 参数分隔符 +→空格(修复 IRSA 400),半径自动裁剪至硬上限 - MAST TESS: parse_tic_json 兼容数组/对象两种 API 响应格式 - 统一检索: per_target_limit 默认 50→1,sources 支持 per-source release/version - Gaia 测光从 VizieR 镜像切换至官方 TAP 服务 RAG 并发优化: - 向量化降级从串行改为并发 5 条/批(buffer_unordered) - 混合检索 RRF 合并从借用改为 owned RetrievalResult 安全加固: - PDF 中间件: URL 解码 %2F/%2E 后判扩展名;文件名过滤非 ASCII + 禁 \ 防头注入 - chat_agent 日志截断问题内容至 50 字符;list_sessions 强制 limit clamp 前端双主题: - 设计令牌三层架构: primitive→semantic→component,浅色暖纸张学术/暗色 Night Indigo - useTheme hook + ThemeToggle 侧边栏组件 + main.tsx 防 FOUC 初始化 - 全组件从硬编码 slate 色迁移至语义令牌(bg-surface/text-content/border-subtle 等) - 新增 ToastContainer 非阻塞通知系统 部署优化: - deploy.sh 引入 SSH ControlMaster 单次密码复用
This commit is contained in:
parent
8f1ed6d08c
commit
eaf85707b5
2
.gitignore
vendored
2
.gitignore
vendored
@ -21,3 +21,5 @@ library/MinerU/
|
||||
libs/
|
||||
.claude
|
||||
.checkpoints
|
||||
.mimocode/
|
||||
.zcode/
|
||||
|
||||
@ -16,6 +16,9 @@ import { UncachedPaperModal } from './components/dialogs/UncachedPaperModal';
|
||||
import { PaperDetailModal } from './components/dialogs/PaperDetailModal';
|
||||
import { Logo } from './components/Logo';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ToastContainer } from './components/layout/ToastContainer';
|
||||
import type { ToastItem } from './components/layout/ToastContainer';
|
||||
|
||||
// 引入拆分出的业务逻辑 Hooks
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
@ -29,7 +32,7 @@ export default function App() {
|
||||
// 移动端菜单显示状态
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
|
||||
// 1. 全局自定义 Dialog 弹窗管理 (Alert / Confirm)
|
||||
// 1. 全局自定义 Dialog 弹窗与 Toast 通知管理
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: 'alert' | 'confirm';
|
||||
title: string;
|
||||
@ -38,14 +41,53 @@ export default function App() {
|
||||
onCancel?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const showAlert = useCallback((message: string, title = '系统提示') => {
|
||||
setDialog({
|
||||
type: 'alert',
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => {},
|
||||
});
|
||||
}, []);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, type: 'success' | 'error' | 'info' = 'success') => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const showAlert = useCallback(
|
||||
(message: string, title = '系统提示') => {
|
||||
const toastTriggers = [
|
||||
'成功',
|
||||
'完成',
|
||||
'复制',
|
||||
'导入',
|
||||
'保存',
|
||||
'通知',
|
||||
'已存',
|
||||
'提示',
|
||||
];
|
||||
const isToast = toastTriggers.some(
|
||||
(t) =>
|
||||
title.includes(t) ||
|
||||
message.includes('成功') ||
|
||||
message.includes('复制') ||
|
||||
message.includes('完成') ||
|
||||
message.includes('已存')
|
||||
);
|
||||
|
||||
if (isToast) {
|
||||
showToast(message, 'success');
|
||||
} else {
|
||||
setDialog({
|
||||
type: 'alert',
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => {},
|
||||
});
|
||||
}
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showConfirm = useCallback(
|
||||
(message: string, onConfirm: () => void, title = '确认操作') => {
|
||||
@ -344,10 +386,10 @@ export default function App() {
|
||||
// 4. 渲染顶层登录页面
|
||||
if (auth.isAuthenticated === null) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9]">
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-app">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader className="w-6 h-6 text-[#106ba3] animate-spin" />
|
||||
<span className="text-xs font-bold text-[#0a2540] tracking-wider font-sans">
|
||||
<Loader className="w-6 h-6 text-blueprint animate-spin" />
|
||||
<span className="text-xs font-bold text-content tracking-wider font-sans">
|
||||
正在校验系统安全凭证...
|
||||
</span>
|
||||
</div>
|
||||
@ -357,26 +399,32 @@ export default function App() {
|
||||
|
||||
if (auth.isAuthenticated === false) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9] overflow-hidden relative select-none font-sans">
|
||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-sky-200/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-200/20 rounded-full blur-3xl" />
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-app overflow-hidden relative select-none font-sans">
|
||||
<div
|
||||
className="absolute top-1/4 left-1/4 w-[500px] h-[500px] rounded-full blur-3xl"
|
||||
style={{ backgroundColor: 'var(--blob-a)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] rounded-full blur-3xl"
|
||||
style={{ backgroundColor: 'var(--blob-b)' }}
|
||||
/>
|
||||
|
||||
<div className="console-panel rounded-lg p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
|
||||
<div className="console-panel rounded-lg p-8 max-w-sm w-full mx-4 z-10 relative">
|
||||
<div className="flex flex-col items-center text-center mb-7 select-none">
|
||||
<div className="w-14 h-14 mb-3">
|
||||
<div className="w-14 h-14 mb-3 text-blueprint">
|
||||
<Logo gradientId="loginStarGrad" />
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">
|
||||
<h2 className="text-sm font-bold text-content tracking-wider mb-1">
|
||||
AstroResearch
|
||||
</h2>
|
||||
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">
|
||||
<p className="text-[10px] text-secondary font-medium tracking-wide">
|
||||
天文学科研辅助系统 · 安全登录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={auth.handleLogin} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-[#0a2540] block">
|
||||
<label className="text-[11px] font-bold text-content block">
|
||||
访问密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
@ -387,16 +435,16 @@ export default function App() {
|
||||
placeholder="请输入系统访问密码"
|
||||
autoFocus
|
||||
disabled={auth.loggingIn}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg bg-slate-50 border border-[#d2d8e2] text-slate-900 placeholder-slate-400 focus:outline-none focus:border-[#106ba3] focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-slate-400">
|
||||
<Lock className="w-3.5 h-3.5 text-slate-500" />
|
||||
<div className="absolute left-3 top-2.5 text-tertiary">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auth.loginError && (
|
||||
<div className="p-2.5 rounded-lg bg-red-50 border border-red-200 text-[10px] font-bold text-red-700 leading-relaxed">
|
||||
<div className="p-2.5 rounded-lg bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-[10px] font-bold text-rose-700 dark:text-rose-300 leading-relaxed">
|
||||
{auth.loginError}
|
||||
</div>
|
||||
)}
|
||||
@ -404,7 +452,7 @@ export default function App() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={auth.loggingIn}
|
||||
className="w-full btn-console btn-console-primary py-2 rounded-lg text-xs font-bold transition-all shadow-xs flex items-center justify-center gap-2 cursor-pointer"
|
||||
className="w-full btn-console btn-console-primary py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
{auth.loggingIn ? (
|
||||
<>
|
||||
@ -423,7 +471,7 @@ export default function App() {
|
||||
|
||||
// 5. 正常工作面板布局装配
|
||||
return (
|
||||
<div className="flex h-[100dvh] overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
<div className="flex h-[100dvh] overflow-hidden text-content bg-app select-text">
|
||||
{/* 导航左侧栏 */}
|
||||
<Sidebar
|
||||
activeTab={activeTab}
|
||||
@ -438,26 +486,26 @@ export default function App() {
|
||||
{/* 主工作区 */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* 移动端顶部 Header */}
|
||||
<header className="lg:hidden flex items-center justify-between px-4 pb-3 bg-white border-b border-slate-200 select-none shrink-0 z-20 pt-[calc(env(safe-area-inset-top,0px)+12px)]">
|
||||
<header className="lg:hidden flex items-center justify-between px-4 pb-3 bg-surface border-b border-subtle select-none shrink-0 z-20 pt-[calc(env(safe-area-inset-top,0px)+12px)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
className="p-1.5 rounded-lg border border-slate-200 hover:bg-slate-55 text-slate-600 transition-all cursor-pointer flex items-center justify-center"
|
||||
className="p-1.5 rounded-lg hover:bg-sunken text-secondary transition-all cursor-pointer flex items-center justify-center"
|
||||
title="打开菜单"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<div className="w-6 h-6 text-blueprint">
|
||||
<Logo gradientId="mobileHeaderStarGrad" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-800 tracking-wider">
|
||||
<span className="text-xs font-bold text-content tracking-wider">
|
||||
AstroResearch
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-sans tracking-wide uppercase">
|
||||
<span className="text-[10px] font-bold px-2.5 py-1 rounded bg-sunken text-secondary font-sans tracking-wide uppercase">
|
||||
{activeTab === 'search' && '统一检索'}
|
||||
{activeTab === 'library' && '馆藏管理'}
|
||||
{activeTab === 'reader' && '双语阅读'}
|
||||
@ -487,241 +535,342 @@ export default function App() {
|
||||
}`}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
{activeTab === 'search' && (
|
||||
<SearchPanel
|
||||
searchQuery={search.searchQuery}
|
||||
setSearchQuery={search.setSearchQuery}
|
||||
searchSource={search.searchSource}
|
||||
setSearchSource={search.setSearchSource}
|
||||
searchRows={search.searchRows}
|
||||
searchStart={search.searchStart}
|
||||
searchSort={search.searchSort}
|
||||
handlePageChange={search.handlePageChange}
|
||||
handleSortChange={search.handleSortChange}
|
||||
handleRowsChange={search.handleRowsChange}
|
||||
searching={search.searching}
|
||||
handleSearch={search.handleSearch}
|
||||
searchResults={search.searchResults}
|
||||
exportingList={search.exportingList}
|
||||
toggleExportItem={search.toggleExportItem}
|
||||
handleExportBibtex={search.handleExportBibtex}
|
||||
exporting={search.exporting}
|
||||
bibtexContent={search.bibtexContent}
|
||||
downloadingBibcodes={library.downloadingBibcodes}
|
||||
handleDownload={(bibcode, force) =>
|
||||
library.handleDownload(
|
||||
bibcode,
|
||||
force,
|
||||
search.setSearchResults
|
||||
)
|
||||
}
|
||||
selectedPaper={library.selectedPaper}
|
||||
setSelectedPaper={library.setSelectedPaper}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
loadCitations={citations.loadCitations}
|
||||
showAlert={showAlert}
|
||||
onShowDetail={(paper) =>
|
||||
library.setDetailBibcode(paper.bibcode)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'search' && (
|
||||
<motion.div
|
||||
key="search"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SearchPanel
|
||||
searchQuery={search.searchQuery}
|
||||
setSearchQuery={search.setSearchQuery}
|
||||
searchSource={search.searchSource}
|
||||
setSearchSource={search.setSearchSource}
|
||||
searchRows={search.searchRows}
|
||||
searchStart={search.searchStart}
|
||||
searchSort={search.searchSort}
|
||||
handlePageChange={search.handlePageChange}
|
||||
handleSortChange={search.handleSortChange}
|
||||
handleRowsChange={search.handleRowsChange}
|
||||
searching={search.searching}
|
||||
handleSearch={search.handleSearch}
|
||||
searchResults={search.searchResults}
|
||||
exportingList={search.exportingList}
|
||||
toggleExportItem={search.toggleExportItem}
|
||||
handleExportBibtex={search.handleExportBibtex}
|
||||
exporting={search.exporting}
|
||||
bibtexContent={search.bibtexContent}
|
||||
downloadingBibcodes={library.downloadingBibcodes}
|
||||
handleDownload={(bibcode, force) =>
|
||||
library.handleDownload(
|
||||
bibcode,
|
||||
force,
|
||||
search.setSearchResults
|
||||
)
|
||||
}
|
||||
selectedPaper={library.selectedPaper}
|
||||
setSelectedPaper={library.setSelectedPaper}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
loadCitations={citations.loadCitations}
|
||||
showAlert={showAlert}
|
||||
onShowDetail={(paper) =>
|
||||
library.setDetailBibcode(paper.bibcode)
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'library' && (
|
||||
<LibraryPanel
|
||||
library={library.library}
|
||||
fetchLibrary={library.fetchLibrary}
|
||||
setActiveTab={setActiveTab}
|
||||
onShowDetail={(paper) =>
|
||||
library.setDetailBibcode(paper.bibcode)
|
||||
}
|
||||
onOpenReader={openReader}
|
||||
onOpenCitation={(paper) =>
|
||||
library.openCitation(
|
||||
paper,
|
||||
setActiveTab,
|
||||
citations.loadCitations
|
||||
)
|
||||
}
|
||||
searchTerm={library.searchTerm}
|
||||
setSearchTerm={library.setSearchTerm}
|
||||
filterStatus={library.filterStatus}
|
||||
setFilterStatus={library.setFilterStatus}
|
||||
filterDoctype={library.filterDoctype}
|
||||
setFilterDoctype={library.setFilterDoctype}
|
||||
sortBy={library.sortBy}
|
||||
setSortBy={library.setSortBy}
|
||||
filterAuthor={library.filterAuthor}
|
||||
setFilterAuthor={library.setFilterAuthor}
|
||||
filterYear={library.filterYear}
|
||||
setFilterYear={library.setFilterYear}
|
||||
filterJournal={library.filterJournal}
|
||||
setFilterJournal={library.setFilterJournal}
|
||||
currentPage={library.currentPage}
|
||||
setCurrentPage={library.setCurrentPage}
|
||||
pageSize={library.pageSize}
|
||||
setPageSize={library.setPageSize}
|
||||
totalCount={library.totalCount}
|
||||
loading={library.loading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'library' && (
|
||||
<motion.div
|
||||
key="library"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<LibraryPanel
|
||||
library={library.library}
|
||||
fetchLibrary={library.fetchLibrary}
|
||||
setActiveTab={setActiveTab}
|
||||
onShowDetail={(paper) =>
|
||||
library.setDetailBibcode(paper.bibcode)
|
||||
}
|
||||
onOpenReader={openReader}
|
||||
onOpenCitation={(paper) =>
|
||||
library.openCitation(
|
||||
paper,
|
||||
setActiveTab,
|
||||
citations.loadCitations
|
||||
)
|
||||
}
|
||||
searchTerm={library.searchTerm}
|
||||
setSearchTerm={library.setSearchTerm}
|
||||
filterStatus={library.filterStatus}
|
||||
setFilterStatus={library.setFilterStatus}
|
||||
filterDoctype={library.filterDoctype}
|
||||
setFilterDoctype={library.setFilterDoctype}
|
||||
sortBy={library.sortBy}
|
||||
setSortBy={library.setSortBy}
|
||||
filterAuthor={library.filterAuthor}
|
||||
setFilterAuthor={library.setFilterAuthor}
|
||||
filterYear={library.filterYear}
|
||||
setFilterYear={library.setFilterYear}
|
||||
filterJournal={library.filterJournal}
|
||||
setFilterJournal={library.setFilterJournal}
|
||||
currentPage={library.currentPage}
|
||||
setCurrentPage={library.setCurrentPage}
|
||||
pageSize={library.pageSize}
|
||||
setPageSize={library.setPageSize}
|
||||
totalCount={library.totalCount}
|
||||
loading={library.loading}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'reader' &&
|
||||
(library.selectedPaper ? (
|
||||
<ReaderPanel
|
||||
selectedPaper={library.selectedPaper}
|
||||
library={library.library}
|
||||
recentlySelected={library.recentlySelected}
|
||||
onSwitchPaper={openReader}
|
||||
parsing={parsing}
|
||||
handleParse={handleParse}
|
||||
translating={translating}
|
||||
handleTranslate={handleTranslate}
|
||||
vectorizing={vectorizing}
|
||||
handleVectorize={handleVectorize}
|
||||
showNotesPanel={notes.showNotesPanel}
|
||||
setShowNotesPanel={notes.setShowNotesPanel}
|
||||
notes={notes.notes}
|
||||
englishText={englishText}
|
||||
chineseText={chineseText}
|
||||
handleTextSelection={notes.handleTextSelection}
|
||||
selectedParagraphIdx={notes.selectedParagraphIdx}
|
||||
setSelectedParagraphIdx={notes.setSelectedParagraphIdx}
|
||||
selectedText={notes.selectedText}
|
||||
setSelectedText={notes.setSelectedText}
|
||||
newNoteColor={notes.newNoteColor}
|
||||
setNewNoteColor={notes.setNewNoteColor}
|
||||
newNoteText={notes.newNoteText}
|
||||
setNewNoteText={notes.setNewNoteText}
|
||||
handleCreateNote={() =>
|
||||
notes.handleCreateNote(library.selectedPaper)
|
||||
}
|
||||
handleDeleteNote={notes.handleDeleteNote}
|
||||
showConfirm={showConfirm}
|
||||
onJumpToSource={handleJumpToSource}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-white rounded-2xl border border-slate-200 shadow-xs min-h-[450px] select-none">
|
||||
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
|
||||
<BookOpen className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2">
|
||||
未选定阅读文献
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
|
||||
双语对比阅读功能需要基于已下载的馆藏文献。请前往"馆藏管理"选择或上传一篇文献,然后开启沉浸式双语精读。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('library')}
|
||||
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
|
||||
>
|
||||
选择文献
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{activeTab === 'reader' && (
|
||||
<motion.div
|
||||
key="reader"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{library.selectedPaper ? (
|
||||
<ReaderPanel
|
||||
selectedPaper={library.selectedPaper}
|
||||
library={library.library}
|
||||
recentlySelected={library.recentlySelected}
|
||||
onSwitchPaper={openReader}
|
||||
parsing={parsing}
|
||||
handleParse={handleParse}
|
||||
translating={translating}
|
||||
handleTranslate={handleTranslate}
|
||||
vectorizing={vectorizing}
|
||||
handleVectorize={handleVectorize}
|
||||
showNotesPanel={notes.showNotesPanel}
|
||||
setShowNotesPanel={notes.setShowNotesPanel}
|
||||
notes={notes.notes}
|
||||
englishText={englishText}
|
||||
chineseText={chineseText}
|
||||
handleTextSelection={notes.handleTextSelection}
|
||||
selectedParagraphIdx={notes.selectedParagraphIdx}
|
||||
setSelectedParagraphIdx={notes.setSelectedParagraphIdx}
|
||||
selectedText={notes.selectedText}
|
||||
setSelectedText={notes.setSelectedText}
|
||||
newNoteColor={notes.newNoteColor}
|
||||
setNewNoteColor={notes.setNewNoteColor}
|
||||
newNoteText={notes.newNoteText}
|
||||
setNewNoteText={notes.setNewNoteText}
|
||||
handleCreateNote={() =>
|
||||
notes.handleCreateNote(library.selectedPaper)
|
||||
}
|
||||
handleDeleteNote={notes.handleDeleteNote}
|
||||
showConfirm={showConfirm}
|
||||
onJumpToSource={handleJumpToSource}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-surface rounded-2xl min-h-[450px] select-none relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05] pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at center, var(--color-blueprint) 0%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -6, 0] }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 4,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="w-16 h-16 rounded-2xl bg-blueprint/10 dark:bg-blueprint/15 border border-blueprint/20 flex items-center justify-center text-blueprint mb-5 relative z-10"
|
||||
>
|
||||
<BookOpen className="w-8 h-8" />
|
||||
</motion.div>
|
||||
<h3 className="text-base font-bold text-content tracking-wide mb-2 relative z-10">
|
||||
未选定阅读文献
|
||||
</h3>
|
||||
<p className="text-xs text-secondary max-w-sm text-center leading-relaxed mb-6 relative z-10 font-medium">
|
||||
双语对比阅读功能需要基于已下载的馆藏文献。请前往"馆藏管理"选择或上传一篇文献,然后开启沉浸式双语精读。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('library')}
|
||||
className="btn-console btn-console-primary px-6 py-2.5 rounded-lg text-xs font-bold transition-all cursor-pointer hover:scale-105 active:scale-95 relative z-10"
|
||||
>
|
||||
选择文献
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'citation' &&
|
||||
(library.selectedPaper ? (
|
||||
<CitationPanel
|
||||
selectedPaper={library.selectedPaper}
|
||||
library={library.library}
|
||||
recentlySelected={library.recentlySelected}
|
||||
onSwitchPaper={(paper) =>
|
||||
library.openCitation(
|
||||
paper,
|
||||
setActiveTab,
|
||||
citations.loadCitations
|
||||
)
|
||||
}
|
||||
loadingCitations={citations.loadingCitations}
|
||||
citationNetwork={citations.citationNetwork}
|
||||
citationHistory={citations.citationHistory}
|
||||
loadCitations={citations.loadCitations}
|
||||
onUncachedClick={citations.setUncachedBibcode}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-white rounded-2xl border border-slate-200 shadow-xs min-h-[450px] select-none">
|
||||
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
|
||||
<GitFork className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2">
|
||||
未选定中心文献
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
|
||||
引用星系拓扑图谱用于展示单篇文献的"引用 -
|
||||
被引"星系脉络。请前往"馆藏管理"选择一篇文献并生成其引用图谱。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('library')}
|
||||
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
|
||||
>
|
||||
选择文献
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{activeTab === 'citation' && (
|
||||
<motion.div
|
||||
key="citation"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{library.selectedPaper ? (
|
||||
<CitationPanel
|
||||
selectedPaper={library.selectedPaper}
|
||||
library={library.library}
|
||||
recentlySelected={library.recentlySelected}
|
||||
onSwitchPaper={(paper) =>
|
||||
library.openCitation(
|
||||
paper,
|
||||
setActiveTab,
|
||||
citations.loadCitations
|
||||
)
|
||||
}
|
||||
loadingCitations={citations.loadingCitations}
|
||||
citationNetwork={citations.citationNetwork}
|
||||
citationHistory={citations.citationHistory}
|
||||
loadCitations={citations.loadCitations}
|
||||
onUncachedClick={citations.setUncachedBibcode}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-surface rounded-2xl min-h-[450px] select-none relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05] pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at center, var(--color-blueprint) 0%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -6, 0] }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 4,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="w-16 h-16 rounded-2xl bg-blueprint/10 dark:bg-blueprint/15 border border-blueprint/20 flex items-center justify-center text-blueprint mb-5 relative z-10"
|
||||
>
|
||||
<GitFork className="w-8 h-8" />
|
||||
</motion.div>
|
||||
<h3 className="text-base font-bold text-content tracking-wide mb-2 relative z-10">
|
||||
未选定中心文献
|
||||
</h3>
|
||||
<p className="text-xs text-secondary max-w-sm text-center leading-relaxed mb-6 relative z-10 font-medium">
|
||||
引用星系拓扑图谱用于展示单篇文献的"引用 -
|
||||
被引"星系脉络。请前往"馆藏管理"选择一篇文献并生成其引用图谱。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('library')}
|
||||
className="btn-console btn-console-primary px-6 py-2.5 rounded-lg text-xs font-bold transition-all cursor-pointer hover:scale-105 active:scale-95 relative z-10"
|
||||
>
|
||||
选择文献
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'sync' && <SyncPanel />}
|
||||
{activeTab === 'sync' && (
|
||||
<motion.div
|
||||
key="sync"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SyncPanel />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'observation' && (
|
||||
<ObservationPanel
|
||||
capabilities={observation.capabilities}
|
||||
currentSpec={observation.currentSpec}
|
||||
searchForm={observation.searchForm}
|
||||
setSearchForm={observation.setSearchForm}
|
||||
searchResults={observation.searchResults}
|
||||
searching={observation.searching}
|
||||
searchError={observation.searchError}
|
||||
runSearch={observation.runSearch}
|
||||
selectedSourceIds={observation.selectedSourceIds}
|
||||
toggleSelect={observation.toggleSelect}
|
||||
selectAll={observation.selectAll}
|
||||
selectNone={observation.selectNone}
|
||||
downloading={observation.downloading}
|
||||
downloadResult={observation.downloadResult}
|
||||
downloadError={observation.downloadError}
|
||||
setDownloadResult={observation.setDownloadResult}
|
||||
downloadSelected={observation.downloadSelected}
|
||||
downloadByIds={observation.downloadByIds}
|
||||
downloadByCoordinates={observation.downloadByCoordinates}
|
||||
unifiedResult={observation.unifiedResult}
|
||||
unifiedSearching={observation.unifiedSearching}
|
||||
unifiedError={observation.unifiedError}
|
||||
runUnifiedSearch={observation.runUnifiedSearch}
|
||||
resolveNames={observation.resolveNames}
|
||||
unifiedSelected={observation.unifiedSelected}
|
||||
toggleUnifiedSelect={observation.toggleUnifiedSelect}
|
||||
downloadUnifiedSelected={observation.downloadUnifiedSelected}
|
||||
libraryItems={observation.libraryItems}
|
||||
libraryTotal={observation.libraryTotal}
|
||||
libraryLoading={observation.libraryLoading}
|
||||
libraryError={observation.libraryError}
|
||||
fetchLibrary={observation.fetchLibrary}
|
||||
libSource={observation.libSource}
|
||||
setLibSource={observation.setLibSource}
|
||||
libProduct={observation.libProduct}
|
||||
setLibProduct={observation.setLibProduct}
|
||||
libSearch={observation.libSearch}
|
||||
setLibSearch={observation.setLibSearch}
|
||||
libSort={observation.libSort}
|
||||
setLibSort={observation.setLibSort}
|
||||
libPage={observation.libPage}
|
||||
setLibPage={observation.setLibPage}
|
||||
libPageSize={observation.libPageSize}
|
||||
setLibPageSize={observation.setLibPageSize}
|
||||
resetLibraryFilters={observation.resetLibraryFilters}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'observation' && (
|
||||
<motion.div
|
||||
key="observation"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<ObservationPanel
|
||||
capabilities={observation.capabilities}
|
||||
currentSpec={observation.currentSpec}
|
||||
searchForm={observation.searchForm}
|
||||
setSearchForm={observation.setSearchForm}
|
||||
searchResults={observation.searchResults}
|
||||
searching={observation.searching}
|
||||
searchError={observation.searchError}
|
||||
runSearch={observation.runSearch}
|
||||
selectedSourceIds={observation.selectedSourceIds}
|
||||
toggleSelect={observation.toggleSelect}
|
||||
selectAll={observation.selectAll}
|
||||
selectNone={observation.selectNone}
|
||||
downloading={observation.downloading}
|
||||
downloadResult={observation.downloadResult}
|
||||
downloadError={observation.downloadError}
|
||||
setDownloadResult={observation.setDownloadResult}
|
||||
downloadSelected={observation.downloadSelected}
|
||||
downloadByIds={observation.downloadByIds}
|
||||
downloadByCoordinates={observation.downloadByCoordinates}
|
||||
unifiedResult={observation.unifiedResult}
|
||||
unifiedSearching={observation.unifiedSearching}
|
||||
unifiedError={observation.unifiedError}
|
||||
runUnifiedSearch={observation.runUnifiedSearch}
|
||||
resolveNames={observation.resolveNames}
|
||||
unifiedSelected={observation.unifiedSelected}
|
||||
toggleUnifiedSelect={observation.toggleUnifiedSelect}
|
||||
downloadUnifiedSelected={
|
||||
observation.downloadUnifiedSelected
|
||||
}
|
||||
libraryItems={observation.libraryItems}
|
||||
libraryTotal={observation.libraryTotal}
|
||||
libraryLoading={observation.libraryLoading}
|
||||
libraryError={observation.libraryError}
|
||||
fetchLibrary={observation.fetchLibrary}
|
||||
libSource={observation.libSource}
|
||||
setLibSource={observation.setLibSource}
|
||||
libProduct={observation.libProduct}
|
||||
setLibProduct={observation.setLibProduct}
|
||||
libSearch={observation.libSearch}
|
||||
setLibSearch={observation.setLibSearch}
|
||||
libSort={observation.libSort}
|
||||
setLibSort={observation.setLibSort}
|
||||
libPage={observation.libPage}
|
||||
setLibPage={observation.setLibPage}
|
||||
libPageSize={observation.libPageSize}
|
||||
setLibPageSize={observation.setLibPageSize}
|
||||
resetLibraryFilters={observation.resetLibraryFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'agent' && (
|
||||
<ResearchAgentPanel
|
||||
showConfirm={showConfirm}
|
||||
showAlert={showAlert}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
citations={citations}
|
||||
library={library}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'agent' && (
|
||||
<motion.div
|
||||
key="agent"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<ResearchAgentPanel
|
||||
showConfirm={showConfirm}
|
||||
showAlert={showAlert}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
citations={citations}
|
||||
library={library}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
@ -730,6 +879,12 @@ export default function App() {
|
||||
{/* 全局统一 Alert / Confirm 弹窗 */}
|
||||
<GlobalDialog dialog={dialog} onClose={() => setDialog(null)} />
|
||||
|
||||
{/* 全局非阻塞 Toast 通知容器 */}
|
||||
<ToastContainer
|
||||
toasts={toasts}
|
||||
onClose={(id) => setToasts((prev) => prev.filter((t) => t.id !== id))}
|
||||
/>
|
||||
|
||||
{/* 未入库文献操作引导弹窗 */}
|
||||
<UncachedPaperModal
|
||||
bibcode={citations.uncachedBibcode}
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
Paperclip,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AgentMarkdown,
|
||||
useAgentSSE,
|
||||
@ -25,8 +26,9 @@ import {
|
||||
routeToolCall,
|
||||
routeToolResult,
|
||||
routeTextDelta,
|
||||
setGlobalTools,
|
||||
} from './agent';
|
||||
import type { SSEEventHandlers, TimelineItem } from './agent';
|
||||
import type { SSEEventHandlers, TimelineItem, AgentToolMeta } from './agent';
|
||||
|
||||
interface RetrievalSource {
|
||||
bibcode: string;
|
||||
@ -142,6 +144,14 @@ export function AIAssistantPanel({
|
||||
});
|
||||
}, [bibcode]);
|
||||
|
||||
// ── 初始化加载工具元数据 ──
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get<AgentToolMeta[]>('/api/chat/tools')
|
||||
.then((res) => setGlobalTools(res.data))
|
||||
.catch((e) => console.error('加载工具列表失败:', e));
|
||||
}, []);
|
||||
|
||||
const handleStop = async () => {
|
||||
sseStop();
|
||||
if (sessionId) {
|
||||
@ -195,14 +205,31 @@ export function AIAssistantPanel({
|
||||
)
|
||||
);
|
||||
},
|
||||
onToolCall: (step, _id, name, args) => {
|
||||
onToolCall: (step, _id, name, args, is_internal, display_name) => {
|
||||
setMessages((prev) =>
|
||||
updateAiTimeline(prev, (timeline) =>
|
||||
routeToolCall(timeline, step, _id, name, args)
|
||||
routeToolCall(
|
||||
timeline,
|
||||
step,
|
||||
_id,
|
||||
name,
|
||||
args,
|
||||
is_internal,
|
||||
display_name
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
onToolResult: (_step, tcId, name, _output, isError, metadata) => {
|
||||
onToolResult: (
|
||||
_step,
|
||||
tcId,
|
||||
name,
|
||||
_output,
|
||||
isError,
|
||||
metadata,
|
||||
is_internal,
|
||||
display_name
|
||||
) => {
|
||||
setExpandedResults((prev) => ({ ...prev, [tcId]: true }));
|
||||
setMessages((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
@ -213,7 +240,10 @@ export function AIAssistantPanel({
|
||||
tcId,
|
||||
name,
|
||||
_output,
|
||||
isError
|
||||
isError,
|
||||
metadata,
|
||||
is_internal,
|
||||
display_name
|
||||
);
|
||||
// RAG 来源收集
|
||||
const sources = [...(last.sources || [])];
|
||||
@ -336,12 +366,13 @@ export function AIAssistantPanel({
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
const tcId = item.id;
|
||||
const tcId = item.id || `ai-tc-${item.step}-${idx}`;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={`tc-${tcId}-${idx}`}
|
||||
step={item.step}
|
||||
name={item.name}
|
||||
display_name={item.display_name}
|
||||
arguments={item.arguments}
|
||||
result={item.result}
|
||||
isStreaming={isStreaming}
|
||||
@ -366,20 +397,20 @@ export function AIAssistantPanel({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto p-4 pb-28 space-y-4 min-h-0"
|
||||
className="flex-1 overflow-y-auto p-4 pb-28 space-y-4 min-h-0 bg-sunken"
|
||||
>
|
||||
{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">
|
||||
<Compass className="w-10 h-10 mx-auto text-blueprint opacity-60" />
|
||||
<h3 className="text-xs font-bold text-content">文献阅读助手</h3>
|
||||
<p className="text-[11px] text-secondary leading-relaxed font-semibold">
|
||||
专注文献精读与理解的 AI
|
||||
助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">
|
||||
<span className="text-[10px] font-bold text-tertiary tracking-wider uppercase">
|
||||
推荐学术提问:
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
@ -387,7 +418,7 @@ export function AIAssistantPanel({
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSend(q)}
|
||||
className="w-full text-left bg-white hover:bg-slate-55 border border-slate-200 rounded-md p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all cursor-pointer hover:border-slate-350"
|
||||
className="w-full text-left bg-surface hover:bg-sunken rounded-md p-3 text-xs text-secondary leading-relaxed font-medium transition-all cursor-pointer hover:border-subtle"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
@ -397,18 +428,23 @@ export function AIAssistantPanel({
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, index) => (
|
||||
<div
|
||||
<motion.div
|
||||
key={index}
|
||||
className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5 w-full`}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">
|
||||
{msg.sender === 'user' ? '我' : '天文科研助手'}
|
||||
</span>
|
||||
{msg.sender !== 'user' && (
|
||||
<span className="text-[10px] font-bold text-tertiary px-1">
|
||||
天文科研助手
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[95%] rounded-lg px-4 py-3 text-xs leading-relaxed font-medium shadow-xs border ${
|
||||
className={`max-w-[95%] rounded-lg px-4 py-3 text-xs leading-relaxed font-medium ${
|
||||
msg.sender === 'user'
|
||||
? 'bg-blueprint text-white border-blueprint select-text'
|
||||
: 'bg-white text-slate-800 border-slate-250 select-text'
|
||||
? 'bg-blueprint/10 text-content dark:bg-blueprint/15 select-text'
|
||||
: 'border border-subtle bg-surface text-content select-text'
|
||||
}`}
|
||||
>
|
||||
{msg.sender === 'user' ? (
|
||||
@ -426,12 +462,12 @@ export function AIAssistantPanel({
|
||||
<div className="space-y-2.5">
|
||||
{/* 时间线条目(思考/工具调用/子代理容器)*/}
|
||||
{msg.timeline.length > 0 && (
|
||||
<div className="border border-slate-205 bg-slate-100/30 rounded-md p-2 select-none">
|
||||
<div className="text-[10px] font-bold text-slate-600 flex items-center gap-1.5 mb-2 px-1">
|
||||
<Brain className="w-3.5 h-3.5 text-slate-450" />
|
||||
<div className=" bg-sunken/60 rounded-md p-2 select-none">
|
||||
<div className="text-[10px] font-bold text-secondary flex items-center gap-1.5 mb-2 px-1">
|
||||
<Brain className="w-3.5 h-3.5 text-tertiary" />
|
||||
<span>推理与工具链 ({msg.timeline.length} 项)</span>
|
||||
</div>
|
||||
<div className="pl-4 border-l border-slate-200 space-y-2">
|
||||
<div className="pl-4 border-l border-subtle space-y-2">
|
||||
{msg.timeline.map((item, tIdx) =>
|
||||
renderTimelineItem(item, tIdx, !!streaming)
|
||||
)}
|
||||
@ -441,12 +477,12 @@ export function AIAssistantPanel({
|
||||
|
||||
{/* 最终回答 */}
|
||||
{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">
|
||||
<div className="prose prose-sm max-w-none text-content leading-relaxed prose-headings:text-content prose-headings:font-bold prose-strong:text-content prose-code:text-blueprint prose-img:rounded-lg">
|
||||
<AgentMarkdown>{msg.text}</AgentMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
!streaming && (
|
||||
<span className="text-slate-400 italic">
|
||||
<span className="text-tertiary italic">
|
||||
正在构建最终结论...
|
||||
</span>
|
||||
)
|
||||
@ -457,8 +493,8 @@ 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 select-none">
|
||||
<div className="w-full mt-2 pl-2 space-y-1.5 border-l-2 border-subtle">
|
||||
<span className="text-[9px] font-bold text-tertiary select-none">
|
||||
参考来源文献(点击跳转):
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-1.5 w-[95%]">
|
||||
@ -468,19 +504,19 @@ export function AIAssistantPanel({
|
||||
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 hover:border-sky-300"
|
||||
className="flex items-center justify-between text-left bg-surface hover:bg-sunken rounded-md px-2.5 py-1.5 text-[10px] text-secondary font-semibold transition-all shadow-sm cursor-pointer hover:border-blueprint/40"
|
||||
title={src.content}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 truncate mr-2">
|
||||
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
|
||||
<span className="font-bold text-slate-700 truncate">
|
||||
<BookOpen className="w-3 h-3 text-blueprint shrink-0" />
|
||||
<span className="font-bold text-secondary truncate">
|
||||
{src.bibcode}
|
||||
</span>
|
||||
<span className="text-slate-400 text-[9px] font-bold">
|
||||
<span className="text-tertiary text-[9px] font-bold">
|
||||
§{src.paragraph_index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-400 text-[9px] shrink-0 font-medium">
|
||||
<span className="text-tertiary text-[9px] shrink-0 font-medium">
|
||||
距: {src.distance.toFixed(3)}
|
||||
</span>
|
||||
</button>
|
||||
@ -488,24 +524,29 @@ export function AIAssistantPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
|
||||
{streaming &&
|
||||
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">
|
||||
文献检索及推理结论构建中...
|
||||
<div className="flex items-center space-x-2.5 text-secondary pl-2 py-1 select-none">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
|
||||
<span className="text-[10px] font-bold tracking-wide">
|
||||
文献检索及推理结论构建中
|
||||
</span>
|
||||
<span className="flex gap-0.5">
|
||||
<span className="w-1 h-1 rounded-full bg-blueprint animate-bounce [animation-delay:-0.3s]"></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blueprint animate-bounce [animation-delay:-0.15s]"></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blueprint animate-bounce"></span>
|
||||
</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-[95%] font-semibold">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 dark:bg-rose-500/10 dark:text-rose-300 dark:border-rose-500/30 rounded-lg p-3 text-xs w-[95%] font-semibold">
|
||||
<AlertCircle className="w-4 h-4 text-danger shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-bold">查询发生错误</div>
|
||||
<div className="text-[10px] opacity-80 mt-0.5">{error}</div>
|
||||
@ -522,9 +563,9 @@ export function AIAssistantPanel({
|
||||
e.preventDefault();
|
||||
handleSend(input);
|
||||
}}
|
||||
className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0"
|
||||
className="absolute bottom-0 left-0 right-0 px-3 pb-0 pt-3 bg-transparent pointer-events-none shrink-0"
|
||||
>
|
||||
<div className="bg-slate-50 border border-slate-300 rounded-lg p-2.5 focus-within:bg-white focus-within:border-blueprint focus-within:ring-1 focus-within:ring-blueprint/10 transition-all relative pointer-events-auto shadow-sm flex flex-col gap-2">
|
||||
<div className="bg-sunken border border-default rounded-lg p-2.5 focus-within:bg-surface focus-within:border-blueprint focus-within:ring-1 focus-within:ring-blueprint/10 transition-all relative pointer-events-auto shadow-sm flex flex-col gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@ -540,9 +581,9 @@ export function AIAssistantPanel({
|
||||
pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'
|
||||
}
|
||||
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 leading-relaxed font-semibold min-h-[36px] max-h-32 pb-1"
|
||||
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-content placeholder-tertiary leading-relaxed font-semibold min-h-[36px] max-h-32 pb-1"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1.5 pt-1.5 border-t border-slate-200/40">
|
||||
<div className="flex justify-between items-center mt-1.5 pt-1.5 border-t border-subtle">
|
||||
<div className="flex gap-2 items-center min-w-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@ -562,8 +603,8 @@ export function AIAssistantPanel({
|
||||
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
|
||||
className={`p-1 rounded-md border text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center shrink-0 ${
|
||||
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'
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300 dark:border-sky-500/30'
|
||||
: 'bg-surface border-subtle text-tertiary hover:text-blueprint hover:border-blueprint/20'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5" />
|
||||
@ -577,12 +618,12 @@ export function AIAssistantPanel({
|
||||
: `data:${pendingImage.mime_type};base64,${pendingImage.data}`
|
||||
}
|
||||
alt="Preview"
|
||||
className="w-5 h-5 object-cover rounded border border-slate-300 shadow-2xs"
|
||||
className="w-5 h-5 object-cover rounded shadow-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingImage(null)}
|
||||
className="absolute -top-1 -right-1 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full w-3 h-3 shadow-xs transition-colors cursor-pointer flex items-center justify-center"
|
||||
className="absolute -top-1 -right-1 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full w-3 h-3 transition-colors cursor-pointer flex items-center justify-center"
|
||||
title="移除图片"
|
||||
>
|
||||
<X className="w-2 h-2" />
|
||||
@ -595,7 +636,7 @@ export function AIAssistantPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="p-1 rounded-lg bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
|
||||
className="p-1 rounded-lg bg-elevated hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center"
|
||||
title="手动停止执行"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
@ -604,7 +645,7 @@ export function AIAssistantPanel({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() && !pendingImage}
|
||||
className="p-1 px-2.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 gap-1 shadow-xs text-[10px] font-bold"
|
||||
className="p-1 px-2.5 rounded-lg bg-blueprint hover:opacity-90 text-white disabled:bg-sunken disabled:text-tertiary transition-colors cursor-pointer flex items-center justify-center gap-1 text-[10px] font-bold"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>发送</span>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// dashboard/src/components/CitationGalaxyCanvas.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CitationNetwork } from '../types';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
interface CanvasProps {
|
||||
networks: CitationNetwork[];
|
||||
@ -9,6 +10,24 @@ interface CanvasProps {
|
||||
onNodeClick: (bibcode: string) => void;
|
||||
}
|
||||
|
||||
// 从 CSS 令牌读取当前主题的 canvas 配色(随浅色/暗色切换)
|
||||
function readCanvasColors() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const get = (k: string, fallback: string) =>
|
||||
cs.getPropertyValue(k).trim() || fallback;
|
||||
return {
|
||||
active: get('--canvas-active', '#0369a1'),
|
||||
inactive: get('--canvas-inactive', '#475569'),
|
||||
reference: get('--canvas-reference', '#b45309'),
|
||||
citation: get('--canvas-citation', '#0e7490'),
|
||||
link: get('--canvas-link', 'rgba(203, 213, 225, 0.22)'),
|
||||
ring: get('--canvas-ring', 'rgba(148, 163, 184, 0.08)'),
|
||||
pulse: get('--canvas-pulse', 'rgba(3, 105, 161, 0.025)'),
|
||||
label: get('--canvas-label', '#475569'),
|
||||
labelHover: get('--canvas-label-hover', '#0369a1'),
|
||||
};
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
@ -34,6 +53,7 @@ export function CitationGalaxyCanvas({
|
||||
onNodeClick,
|
||||
}: CanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@ -47,6 +67,9 @@ export function CitationGalaxyCanvas({
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// 读取当前主题的 canvas 配色(浅色/暗色自适应)
|
||||
const C = readCanvasColors();
|
||||
|
||||
const MAX_NODES = nodeLimit;
|
||||
const allIds = new Set<string>();
|
||||
const nodes: Node[] = [];
|
||||
@ -69,7 +92,7 @@ export function CitationGalaxyCanvas({
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius,
|
||||
color: isActive ? '#0369a1' : '#475569',
|
||||
color: isActive ? C.active : C.inactive,
|
||||
type: 'center',
|
||||
inDb: true,
|
||||
});
|
||||
@ -97,7 +120,7 @@ export function CitationGalaxyCanvas({
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: 8, // 初始值,稍后按连线数重算
|
||||
color: '#b45309',
|
||||
color: C.reference,
|
||||
type: 'reference',
|
||||
inDb,
|
||||
});
|
||||
@ -129,7 +152,7 @@ export function CitationGalaxyCanvas({
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: 8, // 初始值,稍后按连线数重算
|
||||
color: '#0e7490',
|
||||
color: C.citation,
|
||||
type: 'citation',
|
||||
inDb,
|
||||
});
|
||||
@ -256,7 +279,7 @@ export function CitationGalaxyCanvas({
|
||||
// 绘制背景宇宙引力线 & 刻度圈 - 极其素雅的学术引力线
|
||||
const centerNode = nodes.find((n) => n.id === activeNetwork.bibcode);
|
||||
if (centerNode) {
|
||||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.08)';
|
||||
ctx.strokeStyle = C.ring;
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.setLineDash([3, 5]);
|
||||
|
||||
@ -269,7 +292,7 @@ export function CitationGalaxyCanvas({
|
||||
ctx.stroke();
|
||||
|
||||
// 动态引力波圈 - 调低对比度,使其呈极其微弱安静的向外平滑脉冲
|
||||
ctx.strokeStyle = 'rgba(3, 105, 161, 0.025)';
|
||||
ctx.strokeStyle = C.pulse;
|
||||
ctx.setLineDash([]);
|
||||
const pulseRadius = 130 + (frameCount % 120) * 0.4;
|
||||
ctx.beginPath();
|
||||
@ -286,7 +309,7 @@ export function CitationGalaxyCanvas({
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceNode.x, sourceNode.y);
|
||||
ctx.lineTo(targetNode.x, targetNode.y);
|
||||
ctx.strokeStyle = 'rgba(203, 213, 225, 0.22)';
|
||||
ctx.strokeStyle = C.link;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
@ -327,7 +350,7 @@ export function CitationGalaxyCanvas({
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isHovered ? '#0369a1' : '#475569';
|
||||
ctx.fillStyle = isHovered ? C.labelHover : C.label;
|
||||
ctx.font = isHovered ? 'bold 9.5px sans-serif' : '9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
@ -531,7 +554,7 @@ export function CitationGalaxyCanvas({
|
||||
canvas.removeEventListener('touchmove', handleTouchMove);
|
||||
canvas.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [networks, activeNetwork, nodeLimit, onNodeClick]);
|
||||
}, [networks, activeNetwork, nodeLimit, onNodeClick, theme]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@ -53,22 +53,22 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between gap-1.5 bg-white border border-slate-250 hover:border-slate-350 rounded-md text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${
|
||||
className={`w-full flex items-center justify-between gap-1.5 bg-surface border border-default hover:border-border-strong rounded-md text-xs font-semibold text-secondary transition-all text-left outline-none cursor-pointer ${
|
||||
size === 'sm' ? 'px-2 py-0.5' : 'px-2.5 py-2'
|
||||
} ${
|
||||
isOpen ? 'border-blueprint ring-1 ring-blueprint bg-white' : ''
|
||||
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
|
||||
isOpen ? 'border-blueprint ring-1 ring-blueprint bg-surface' : ''
|
||||
} ${disabled ? 'bg-sunken text-tertiary cursor-not-allowed border-subtle' : ''}`}
|
||||
>
|
||||
<span className="truncate">{selectedOption?.label}</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0 ${
|
||||
className={`w-3.5 h-3.5 text-tertiary transition-transform duration-200 shrink-0 ${
|
||||
isOpen ? 'rotate-180 text-blueprint' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute left-0 mt-1.5 w-full min-w-[80px] bg-white border border-slate-200 rounded-md shadow-md py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
<div className="absolute left-0 mt-1.5 w-full min-w-[80px] bg-elevated rounded-md shadow-md py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
@ -84,7 +84,7 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
} ${
|
||||
isSelected
|
||||
? 'bg-blueprint/5 text-blueprint font-bold'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium'
|
||||
: 'text-secondary hover:bg-sunken hover:text-content font-medium'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@ -36,19 +36,17 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-white rounded-xl border border-slate-200 shadow-sm min-h-[300px]">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 border border-red-100 flex items-center justify-center text-red-500 mb-4">
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-surface rounded-xl shadow-sm min-h-[300px]">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 border border-red-100 dark:bg-rose-500/10 dark:border-rose-500/30 flex items-center justify-center text-red-500 dark:text-rose-400 mb-4">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-slate-800 mb-2">
|
||||
组件渲染出错
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 text-center max-w-sm mb-4">
|
||||
<h3 className="text-sm font-bold text-content mb-2">组件渲染出错</h3>
|
||||
<p className="text-xs text-secondary text-center max-w-sm mb-4">
|
||||
{this.state.error?.message || '发生了未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-sunken hover:bg-sunken text-secondary rounded-lg text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
重试
|
||||
|
||||
@ -16,27 +16,27 @@ export function Logo({
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
{/* Outer Orbit Ring (Light Blue) */}
|
||||
{/* Outer Orbit Ring (Adaptive) */}
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="21.5"
|
||||
stroke="#38bdf8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeOpacity="0.6"
|
||||
strokeOpacity="0.4"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
|
||||
{/* Secondary Ellipse Orbit (Light Blue, tilted along top-left to bottom-right diagonal) */}
|
||||
{/* Secondary Ellipse Orbit (Adaptive, tilted along top-left to bottom-right diagonal) */}
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="21"
|
||||
ry="8.5"
|
||||
transform="rotate(36 24 24)"
|
||||
stroke="#0284c7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeOpacity="0.5"
|
||||
strokeOpacity="0.35"
|
||||
/>
|
||||
|
||||
{/* Main Ellipse Orbit (Adaptive Color, tilted along bottom-left to top-right diagonal, major axis slightly larger than outer circle diameter) */}
|
||||
|
||||
@ -29,19 +29,19 @@ export function PaperCard({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="console-panel p-5 rounded-lg border border-slate-200 hover:border-slate-400 bg-white flex flex-col justify-between relative overflow-hidden group transition-all cursor-pointer shadow-sm select-none"
|
||||
className="console-panel p-5 rounded-lg flex flex-col justify-between relative overflow-hidden group cursor-pointer select-none"
|
||||
>
|
||||
{/* Top Right Corner Status Badge */}
|
||||
{statusBadge}
|
||||
|
||||
<div className="pr-10">
|
||||
<div>
|
||||
<h3 className="font-bold text-xs text-slate-900 line-clamp-2 hover:text-blueprint transition-all leading-relaxed">
|
||||
<h3 className="font-bold text-xs text-content line-clamp-2 hover:text-blueprint transition-all leading-relaxed">
|
||||
{getDoctypeBadge(paper.doctype)}
|
||||
<span className="align-middle">{paper.title}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
|
||||
<div className="text-[10px] text-secondary font-semibold mt-1.5 uppercase">
|
||||
作者: {paper.authors.slice(0, 2).join(', ')}
|
||||
{paper.authors.length > 2 ? ' 等' : ''} | 发表年份: {paper.year}
|
||||
</div>
|
||||
@ -49,7 +49,7 @@ export function PaperCard({
|
||||
|
||||
{/* Footer Area */}
|
||||
{(footerLeft || footerRight) && (
|
||||
<div className="flex items-center justify-between border-t border-slate-100 pt-3 mt-4 text-[10px]">
|
||||
<div className="flex items-center justify-between border-t border-subtle pt-3 mt-4 text-[10px]">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
{footerLeft}
|
||||
</div>
|
||||
@ -67,36 +67,33 @@ export function PaperCard({
|
||||
// default 'list' variant
|
||||
return (
|
||||
<div
|
||||
className={`console-panel p-6 rounded-lg border transition-all relative ${
|
||||
isSelected
|
||||
? 'border-slate-800 bg-slate-50/30'
|
||||
: 'border-slate-200 hover:border-slate-350 hover:bg-white'
|
||||
className={`console-panel p-6 rounded-lg relative ${
|
||||
isSelected ? '!border-blueprint !bg-blueprint/5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Selection indicator side bar */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-slate-800 rounded-l-lg" />
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-blueprint rounded-l-lg" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-3">
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className="font-bold text-sm text-slate-900 hover:text-slate-800 cursor-pointer transition-all line-clamp-2 leading-relaxed"
|
||||
className="font-bold text-sm text-content hover:text-content cursor-pointer transition-all line-clamp-2 leading-relaxed"
|
||||
onClick={onClick}
|
||||
>
|
||||
{getDoctypeBadge(paper.doctype)}
|
||||
<span className="align-middle">{paper.title}</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 font-medium mt-2 leading-snug">
|
||||
<p className="text-xs text-secondary font-medium mt-2 leading-snug">
|
||||
作者:{' '}
|
||||
<span className="text-slate-800">
|
||||
<span className="text-content">
|
||||
{paper.authors.slice(0, 5).join(', ')}
|
||||
{paper.authors.length > 5 ? ' 等' : ''}
|
||||
</span>{' '}
|
||||
• 年份:{' '}
|
||||
<span className="text-slate-850 font-bold">{paper.year}</span> •
|
||||
出版期刊:{' '}
|
||||
<span className="italic text-slate-700">
|
||||
• 年份: <span className="text-content font-bold">{paper.year}</span>{' '}
|
||||
• 出版期刊:{' '}
|
||||
<span className="italic text-secondary">
|
||||
{paper.pub_journal || '未标注'}
|
||||
</span>
|
||||
</p>
|
||||
@ -113,10 +110,10 @@ export function PaperCard({
|
||||
|
||||
{/* Footer Area */}
|
||||
{(footerLeft || footerRight) && (
|
||||
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-slate-100 pt-4 gap-3">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-subtle pt-4 gap-3">
|
||||
{footerLeft && <div className="flex gap-2">{footerLeft}</div>}
|
||||
{footerRight && (
|
||||
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono">
|
||||
<div className="text-xs text-tertiary flex flex-wrap gap-x-4 gap-y-1 font-mono">
|
||||
{footerRight}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -70,7 +70,7 @@ export function AgentInputArea({
|
||||
handlePaste,
|
||||
}: AgentInputAreaProps) {
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-transparent pointer-events-none shrink-0 z-30 flex flex-col gap-3">
|
||||
<div className="absolute bottom-0 left-0 right-0 px-4 pb-0 pt-4 bg-transparent pointer-events-none shrink-0 z-30 flex flex-col gap-3">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@ -78,7 +78,7 @@ export function AgentInputArea({
|
||||
}}
|
||||
className="max-w-4xl mx-auto w-full pointer-events-auto"
|
||||
>
|
||||
<div className="bg-slate-50 border border-slate-200/60 rounded-lg p-2.5 focus-within:bg-white focus-within:border-blueprint focus-within:ring-1 focus-within:ring-blueprint/10 transition-all shadow-sm flex flex-col gap-2">
|
||||
<div className="bg-sunken border border-default rounded-lg p-2.5 focus-within:bg-surface focus-within:border-blueprint focus-within:ring-1 focus-within:ring-blueprint/10 transition-all flex flex-col gap-2">
|
||||
{/* 图片预览 */}
|
||||
{pendingImage && (
|
||||
<div className="relative inline-block mb-1 group select-none self-start">
|
||||
@ -89,12 +89,12 @@ export function AgentInputArea({
|
||||
: `data:${pendingImage.mime_type};base64,${pendingImage.data}`
|
||||
}
|
||||
alt="Preview"
|
||||
className="w-12 h-12 object-cover rounded-lg border border-slate-200 shadow-sm"
|
||||
className="w-12 h-12 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingImage(null)}
|
||||
className="absolute -top-1.5 -right-1.5 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full p-0.5 shadow-sm transition-colors cursor-pointer flex items-center justify-center"
|
||||
className="absolute -top-1.5 -right-1.5 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full p-0.5 transition-colors cursor-pointer flex items-center justify-center"
|
||||
title="移除图片"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
@ -114,9 +114,9 @@ export function AgentInputArea({
|
||||
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"
|
||||
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-content placeholder-tertiary leading-relaxed font-semibold min-h-[44px] max-h-40"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2.5 pt-2 border-t border-slate-100/80">
|
||||
<div className="flex justify-between items-center mt-2.5 pt-2 border-t border-subtle">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* 图片上传 */}
|
||||
<input
|
||||
@ -138,14 +138,14 @@ export function AgentInputArea({
|
||||
className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
pendingImage
|
||||
? 'bg-blueprint/5 border-blueprint/30 text-blueprint'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
|
||||
: 'bg-surface border-subtle text-tertiary hover:text-blueprint hover:border-blueprint/20'
|
||||
} 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">
|
||||
<div className="flex gap-1 bg-sunken p-0.5 rounded-lg ">
|
||||
{agentModes.map((m) => {
|
||||
const IconComp = getIconComponent(m.icon);
|
||||
const isSelected = agentMode === m.id;
|
||||
@ -158,8 +158,8 @@ export function AgentInputArea({
|
||||
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-blueprint shadow-xs border-slate-200/50'
|
||||
: 'bg-transparent border-transparent text-slate-400 hover:text-blueprint'
|
||||
? 'bg-surface text-blueprint border-subtle'
|
||||
: 'bg-transparent border-transparent text-tertiary hover:text-blueprint'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<IconComp
|
||||
@ -174,7 +174,7 @@ export function AgentInputArea({
|
||||
{/* 思考模式开关 */}
|
||||
{agentMode === 'default' && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-slate-200 mx-1 hidden sm:block" />
|
||||
<div className="h-4 w-px bg-border-subtle mx-1 hidden sm:block" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinking(!thinking)}
|
||||
@ -186,8 +186,8 @@ export function AgentInputArea({
|
||||
}
|
||||
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-blueprint/5 border-blueprint/30 text-blueprint shadow-3xs'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
|
||||
? 'bg-blueprint/5 border-blueprint/30 text-blueprint'
|
||||
: 'bg-surface border-subtle text-tertiary hover:text-blueprint hover:border-blueprint/20'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Brain
|
||||
@ -204,7 +204,7 @@ export function AgentInputArea({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="p-2 rounded-md bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
|
||||
className="p-2 rounded-md bg-elevated hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center"
|
||||
title="手动停止执行"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 fill-white" />
|
||||
@ -213,7 +213,7 @@ export function AgentInputArea({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="p-2 rounded-md bg-blueprint hover:bg-[#0d5988] text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
|
||||
className="p-2 rounded-md bg-blueprint hover:opacity-90 text-white disabled:bg-sunken disabled:text-tertiary transition-colors cursor-pointer flex items-center justify-center"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
PanelLeftOpen,
|
||||
BarChart3,
|
||||
@ -12,15 +12,20 @@ import {
|
||||
RotateCcw,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
X,
|
||||
Sliders,
|
||||
ListTodo,
|
||||
} from 'lucide-react';
|
||||
import { AgentMarkdown } from './AgentMarkdown';
|
||||
import { ThoughtCard } from './ThoughtCard';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { TodoTaskCard } from './SpecialToolRenderers';
|
||||
import { AnswerCard } from './AnswerCard';
|
||||
import { SubAgentContainer } from './SubAgentContainer';
|
||||
import { PARENT_COLORS } from './constants';
|
||||
import type { ColorScheme } from './constants';
|
||||
import type { TimelineItem } from './types';
|
||||
import { getToolMeta } from './index';
|
||||
|
||||
import type {
|
||||
ProcessedTurn,
|
||||
@ -98,8 +103,18 @@ interface AgentMessageListProps {
|
||||
setActiveTab?: (tab: TabId) => void;
|
||||
citations?: ReturnType<typeof useCitations>;
|
||||
library?: ReturnType<typeof useLibrary>;
|
||||
showTaskSidebar?: boolean;
|
||||
setShowTaskSidebar?: (val: boolean) => void;
|
||||
hasTodos?: boolean;
|
||||
activeTodos?: any[];
|
||||
}
|
||||
|
||||
// 内部工具(is_internal = true)渲染模式配置:
|
||||
// - 'full': 开发阶段以完整卡片模式展示,方便调试与排错
|
||||
// - 'micro': 以微缩徽标模式展示
|
||||
// - 'hidden': 彻底隐藏,不在聊天时间线中展示
|
||||
const INTERNAL_TOOL_RENDER_MODE: 'full' | 'micro' | 'hidden' = 'full';
|
||||
|
||||
export function AgentMessageList({
|
||||
turns,
|
||||
activeTurn,
|
||||
@ -139,7 +154,32 @@ export function AgentMessageList({
|
||||
setActiveTab,
|
||||
citations,
|
||||
library,
|
||||
showTaskSidebar,
|
||||
setShowTaskSidebar,
|
||||
hasTodos,
|
||||
activeTodos,
|
||||
}: AgentMessageListProps) {
|
||||
const [isSessionControlExpanded, setIsSessionControlExpanded] =
|
||||
React.useState(false);
|
||||
// 点击外部自动关闭指标或审计日志面板(避开对切换按钮本身的点击判定)
|
||||
useEffect(() => {
|
||||
if (!showMetrics && !showAuditLog) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInsidePanel = target.closest('.absolute.left-4.right-4.top-2');
|
||||
const isTriggerButton = target.closest('.panel-trigger');
|
||||
|
||||
if (!isInsidePanel && !isTriggerButton) {
|
||||
setShowMetrics(false);
|
||||
setShowAuditLog(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [showMetrics, showAuditLog, setShowMetrics, setShowAuditLog]);
|
||||
|
||||
const renderTimelineItem = (
|
||||
item: TimelineItem,
|
||||
idx: number,
|
||||
@ -180,19 +220,59 @@ export function AgentMessageList({
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
// 如果是系统级内部工具,并且执行没有出错,则直接在前端隐藏,避免聊天时间线过于嘈杂
|
||||
if (
|
||||
SYSTEM_INTERNAL_TOOLS.includes(item.name) &&
|
||||
!item.result?.isError
|
||||
) {
|
||||
return null;
|
||||
const meta = getToolMeta(item.name);
|
||||
const isInternal =
|
||||
meta?.is_internal ||
|
||||
item.is_internal ||
|
||||
SYSTEM_INTERNAL_TOOLS.includes(item.name);
|
||||
const displayName =
|
||||
meta?.display_name || item.display_name || item.name;
|
||||
// 如果是系统级内部工具,并且执行没有出错
|
||||
if (isInternal && !item.result?.isError) {
|
||||
if (INTERNAL_TOOL_RENDER_MODE === 'hidden') {
|
||||
return null; // 彻底不显示
|
||||
}
|
||||
if (INTERNAL_TOOL_RENDER_MODE === 'micro') {
|
||||
const isPending = !item.result;
|
||||
return (
|
||||
<div
|
||||
key={`tc-internal-${item.id || idx}`}
|
||||
className="flex items-center gap-2 py-0.5 px-2.5 rounded-full bg-sunken/40 dark:bg-sunken/20 text-[10px] text-tertiary select-none w-fit font-mono my-0.5 ml-1 animate-in fade-in slide-in-from-left-1 duration-200"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blueprint opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-blueprint"></span>
|
||||
</span>
|
||||
<span className="font-extrabold text-content">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-[9px] text-blueprint/90 animate-pulse font-bold">
|
||||
执行中
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-secondary shrink-0" />
|
||||
<span className="text-secondary/80">{displayName}</span>
|
||||
<span className="text-[9px] text-secondary/40 font-medium">
|
||||
已完成
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 'full' 模式下,直接继续往下执行,渲染为完整的 ToolCallCard 样式
|
||||
}
|
||||
const tcId = item.id;
|
||||
const tcId = item.id || `tc-${item.step}-${idx}`;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={`tc-${tcId}-${idx}`}
|
||||
step={item.step}
|
||||
name={item.name}
|
||||
display_name={displayName}
|
||||
arguments={item.arguments}
|
||||
result={item.result}
|
||||
isStreaming={isStreaming}
|
||||
@ -222,27 +302,27 @@ export function AgentMessageList({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-surface relative">
|
||||
{/* 会话头部 */}
|
||||
<div className="px-5 py-4 border-b border-slate-200 flex items-center justify-between shrink-0 bg-white">
|
||||
<div className="px-5 py-4 flex items-center justify-between shrink-0 bg-surface">
|
||||
<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"
|
||||
className="p-1.5 rounded-lg bg-surface hover:bg-sunken text-secondary hover:text-secondary 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">
|
||||
<h3 className="text-xs font-bold text-content 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">
|
||||
<p className="text-[10px] text-tertiary font-semibold mt-0.5">
|
||||
科研智能体
|
||||
</p>
|
||||
</div>
|
||||
@ -254,10 +334,10 @@ export function AgentMessageList({
|
||||
setShowMetrics(!showMetrics);
|
||||
setShowAuditLog(false);
|
||||
}}
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
className={`panel-trigger p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
showMetrics
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:text-blueprint hover:border-blueprint/20'
|
||||
: 'bg-surface border-subtle text-secondary hover:text-blueprint hover:border-blueprint/20'
|
||||
}`}
|
||||
title="运行指标"
|
||||
>
|
||||
@ -270,10 +350,10 @@ export function AgentMessageList({
|
||||
setShowAuditLog(!showAuditLog);
|
||||
setShowMetrics(false);
|
||||
}}
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
className={`panel-trigger p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
showAuditLog
|
||||
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:text-amber-600 hover:border-amber-200'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-surface border-subtle text-secondary hover:text-blueprint hover:border-blueprint/20'
|
||||
}`}
|
||||
title="审计日志"
|
||||
>
|
||||
@ -283,7 +363,7 @@ export function AgentMessageList({
|
||||
{currentSessionId && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(currentSessionId, e)}
|
||||
className="p-1.5 rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-red-600 hover:border-red-200 transition-all cursor-pointer flex items-center gap-1"
|
||||
className="p-1.5 rounded-lg border bg-surface border-subtle text-secondary hover:text-danger hover:border-red-200 dark:hover:border-red-500/30 transition-all cursor-pointer flex items-center gap-1"
|
||||
title="删除会话"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@ -292,39 +372,95 @@ export function AgentMessageList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent 运行指标面板 — 固定在聊天区域上方 */}
|
||||
{showMetrics && (
|
||||
<div className="border-b border-sky-200 bg-white px-5 py-4 max-h-[45vh] overflow-y-auto scrollbar-thin shrink-0">
|
||||
<AgentMetricsPanel showAlert={showAlert} />
|
||||
</div>
|
||||
)}
|
||||
{/* 悬浮面板容器 */}
|
||||
<div className="relative z-20 shrink-0">
|
||||
{/* Agent 运行指标面板 — 悬浮在聊天区域上方 */}
|
||||
{showMetrics && (
|
||||
<div className="absolute left-4 right-4 top-2 bg-surface/95 backdrop-blur-md border border-subtle rounded-xl shadow-2xl px-5 py-4 max-h-[50vh] overflow-y-auto scrollbar-thin animate-in slide-in-from-top-2 fade-in duration-200 z-30">
|
||||
<div className="absolute right-4 top-4 z-10">
|
||||
<button
|
||||
onClick={() => setShowMetrics(false)}
|
||||
className="p-1 rounded-md hover:bg-sunken text-tertiary hover:text-content transition-colors cursor-pointer"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AgentMetricsPanel showAlert={showAlert} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会话审计日志 — 固定在聊天区域上方 */}
|
||||
{showAuditLog && currentSessionId && (
|
||||
<div className="border-b border-amber-200 bg-white px-5 py-4 max-h-[45vh] overflow-y-auto scrollbar-thin shrink-0">
|
||||
<AuditLogViewer
|
||||
sessionId={currentSessionId}
|
||||
onClose={() => setShowAuditLog(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 会话审计日志 — 悬浮在聊天区域上方 */}
|
||||
{showAuditLog && currentSessionId && (
|
||||
<div className="absolute left-4 right-4 top-2 bg-surface/95 backdrop-blur-md border border-subtle rounded-xl shadow-2xl px-5 py-4 max-h-[50vh] overflow-y-auto scrollbar-thin animate-in slide-in-from-top-2 fade-in duration-200 z-30">
|
||||
<AuditLogViewer
|
||||
sessionId={currentSessionId}
|
||||
onClose={() => setShowAuditLog(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 科研任务看板 — 悬浮在右上角 */}
|
||||
{showTaskSidebar &&
|
||||
hasTodos &&
|
||||
activeTodos &&
|
||||
activeTodos.length > 0 && (
|
||||
<div className="absolute right-4 top-2 w-80 bg-surface/95 backdrop-blur-md border border-subtle rounded-xl shadow-2xl flex flex-col max-h-[50vh] overflow-hidden animate-in slide-in-from-top-2 fade-in duration-200 z-30">
|
||||
{/* 固定头部 — 不随内容滚动 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-subtle bg-sunken/10 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-blueprint animate-pulse" />
|
||||
<h4 className="text-[11px] font-extrabold text-content tracking-wider uppercase">
|
||||
科研研究规划
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowTaskSidebar && setShowTaskSidebar(false)
|
||||
}
|
||||
className="p-1 rounded hover:bg-sunken text-tertiary hover:text-content transition-colors cursor-pointer"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 可滚动区域 — 仅滚动任务列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
|
||||
<TodoTaskCard todos={activeTodos} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮打开任务看板按钮 — 样式参考光谱放大按钮 */}
|
||||
{hasTodos && !showTaskSidebar && setShowTaskSidebar && (
|
||||
<button
|
||||
onClick={() => setShowTaskSidebar(true)}
|
||||
className="absolute top-2 right-4 p-1.5 rounded bg-surface/90 hover:bg-surface border border-subtle hover:border-default text-tertiary hover:text-content transition-all shadow-xs z-30 cursor-pointer flex items-center gap-1.5"
|
||||
title="展开任务规划"
|
||||
>
|
||||
<ListTodo className="w-3.5 h-3.5 text-blueprint" />
|
||||
<span className="text-[10px] font-bold">研究规划</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息滚动区域 */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin transition-all duration-300 ${
|
||||
className={`flex-1 overflow-y-auto p-5 space-y-6 bg-sunken scrollbar-thin transition-all duration-300 [overflow-anchor:auto] ${
|
||||
hasPendingPermission || hasPendingQuestion ? 'pb-80' : 'pb-36'
|
||||
}`}
|
||||
>
|
||||
{turns.length === 0 && !activeTurn ? (
|
||||
<div className="py-12 space-y-8 max-w-xl mx-auto">
|
||||
<div className="text-center space-y-2">
|
||||
<Compass className="w-12 h-12 mx-auto text-sky-500 opacity-60" />
|
||||
<h3 className="text-sm font-extrabold text-slate-800 tracking-wider">
|
||||
<Compass className="w-12 h-12 mx-auto text-blueprint opacity-60" />
|
||||
<h3 className="text-sm font-extrabold text-content tracking-wider">
|
||||
智能天体物理科研助手
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 leading-relaxed font-semibold">
|
||||
<p className="text-xs text-secondary leading-relaxed font-semibold">
|
||||
具备 NASA ADS 跨文献库检索、PDF/HTML 原文结构化解析、SQLite-Vec
|
||||
本地文献 RAG 语义检索、以及 CDS Sesame
|
||||
真实天体参数解析等专业级工具。
|
||||
@ -333,7 +469,7 @@ export function AgentMessageList({
|
||||
|
||||
{/* 快速提示词 */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase block">
|
||||
<span className="text-[10px] font-bold text-tertiary tracking-widest uppercase block">
|
||||
推荐研究提问方向:
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@ -341,12 +477,12 @@ export function AgentMessageList({
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSend(q)}
|
||||
className="w-full text-left bg-white hover:bg-blueprint/5 border border-slate-200 hover:border-blueprint/30 rounded-lg p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-2xs hover:shadow-xs cursor-pointer flex flex-col gap-1 group"
|
||||
className="w-full text-left bg-surface hover:bg-blueprint/5 hover:border-blueprint/30 rounded-lg p-3 text-xs text-secondary leading-relaxed font-medium transition-all cursor-pointer flex flex-col gap-1 group"
|
||||
>
|
||||
<span className="group-hover:text-blueprint font-semibold">
|
||||
{q}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider">
|
||||
<span className="text-[9px] text-tertiary uppercase font-bold tracking-wider">
|
||||
点击开始
|
||||
</span>
|
||||
</button>
|
||||
@ -361,20 +497,17 @@ export function AgentMessageList({
|
||||
<div key={turn.turn_index} className="space-y-4">
|
||||
{/* 用户提问 */}
|
||||
<div className="flex flex-col items-end space-y-1 group relative">
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">
|
||||
我
|
||||
</span>
|
||||
<div className="flex items-center gap-2 max-w-[85%]">
|
||||
{turn.questionMessageId && (
|
||||
<button
|
||||
onClick={() => handleRewind(turn.questionMessageId)}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-amber-600 p-1.5 rounded-lg hover:bg-slate-100 transition-all cursor-pointer shrink-0"
|
||||
className="opacity-0 group-hover:opacity-100 flex items-center justify-center w-7 h-7 rounded-full bg-surface border border-subtle shadow-sm text-tertiary hover:text-amber-600 hover:bg-sunken hover:border-amber-200/60 dark:hover:border-amber-500/30 transition-all duration-200 cursor-pointer shrink-0 mr-1"
|
||||
title="回退到此消息之前"
|
||||
>
|
||||
<Rewind className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-blueprint text-white border-blueprint select-text flex-1 space-y-2">
|
||||
<div className="rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold bg-blueprint/10 text-content dark:bg-blueprint/15 select-text flex-1 space-y-2">
|
||||
{turn.imagePath && (
|
||||
<img
|
||||
src={turn.imagePath}
|
||||
@ -389,7 +522,7 @@ export function AgentMessageList({
|
||||
|
||||
{/* 时间线 — 思考/工具/答案 */}
|
||||
{turn.timeline.length > 0 && (
|
||||
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
|
||||
<div className="pl-4 border-l border-strong space-y-3 my-2 relative">
|
||||
{turn.timeline.map((item: TimelineItem, idx: number) =>
|
||||
renderTimelineItem(item, idx, false)
|
||||
)}
|
||||
@ -398,7 +531,7 @@ export function AgentMessageList({
|
||||
|
||||
{/* Token 用量 */}
|
||||
{turn.usage && turn.usage.total_tokens > 0 && (
|
||||
<div className="flex items-center gap-1 text-[9px] text-slate-400 font-mono pl-4">
|
||||
<div className="flex items-center gap-1 text-[9px] text-tertiary font-mono pl-4">
|
||||
<span>Tokens: ~{turn.usage.total_tokens}</span>
|
||||
</div>
|
||||
)}
|
||||
@ -410,10 +543,7 @@ export function AgentMessageList({
|
||||
<div className="space-y-4">
|
||||
{/* 当前提问 */}
|
||||
<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-lg px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-blueprint text-white border-blueprint select-text space-y-2">
|
||||
<div className="max-w-[85%] rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold bg-blueprint/10 text-content dark:bg-blueprint/15 select-text space-y-2">
|
||||
{activeTurn.imagePath && (
|
||||
<img
|
||||
src={activeTurn.imagePath}
|
||||
@ -427,7 +557,7 @@ export function AgentMessageList({
|
||||
|
||||
{/* SSE Stream Timeline */}
|
||||
{activeTurn.timeline.length > 0 && (
|
||||
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
|
||||
<div className="pl-4 border-l border-strong space-y-3 my-2 relative">
|
||||
{activeTurn.timeline.map(
|
||||
(item: TimelineItem, idx: number) =>
|
||||
renderTimelineItem(item, idx, true)
|
||||
@ -438,11 +568,11 @@ export function AgentMessageList({
|
||||
{/* 最终回答 */}
|
||||
{activeTurn.finalAnswer && (
|
||||
<div className="flex flex-col items-start space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-tertiary px-1 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-blueprint" />
|
||||
<span>科研智能体输出最终结论</span>
|
||||
</span>
|
||||
<div className="max-w-[90%] rounded-lg px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 select-text prose prose-sm max-w-none prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-blueprint prose-img:rounded-lg">
|
||||
<div className="max-w-[90%] rounded-lg px-5 py-4 text-xs leading-relaxed font-semibold border bg-surface text-content border-subtle select-text prose prose-sm max-w-none prose-headings:text-content prose-headings:font-bold prose-strong:text-content prose-code:text-blueprint prose-img:rounded-lg">
|
||||
<AgentMarkdown>{activeTurn.finalAnswer}</AgentMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
@ -450,7 +580,7 @@ export function AgentMessageList({
|
||||
|
||||
{/* 活跃的加载指示器 */}
|
||||
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
|
||||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||||
<div className="flex items-center space-x-2 text-secondary pl-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-blueprint" />
|
||||
<span className="text-[10px] font-bold">
|
||||
智能体正在搜集信息进行推理分析...
|
||||
@ -460,8 +590,8 @@ export function AgentMessageList({
|
||||
|
||||
{/* Error block */}
|
||||
{activeTurn.error && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-750 rounded-lg p-3 text-xs w-[90%] font-semibold">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 dark:bg-rose-500/10 dark:text-rose-300 dark:border-rose-500/30 rounded-lg p-3 text-xs w-[90%] font-semibold">
|
||||
<AlertTriangle className="w-4 h-4 text-danger shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-bold">查询发生错误</div>
|
||||
<div className="text-[10px] opacity-80 mt-0.5">
|
||||
@ -473,37 +603,88 @@ export function AgentMessageList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会话操作小图标栏 */}
|
||||
{/* 会话操作控制台 (Session Version Controller) */}
|
||||
{currentSessionId && turns.length > 0 && !streaming && (
|
||||
<div className="flex items-center gap-1 pt-2.5 justify-start border-t border-slate-100/60 mt-4 max-w-[200px]">
|
||||
<button
|
||||
onClick={() => handleRewind(undefined, 1)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-amber-600 hover:bg-slate-100 transition-all cursor-pointer"
|
||||
title="回退最近一轮对话 (undo)"
|
||||
<div className="flex items-center mt-6 ml-4 select-none animate-in fade-in duration-300">
|
||||
<div
|
||||
className={`console-panel rounded-full px-3 py-1 flex items-center bg-surface border border-subtle shadow-md w-fit hover:border-strong transition-all duration-300 ${
|
||||
isSessionControlExpanded ? 'gap-1.5' : 'gap-1'
|
||||
}`}
|
||||
>
|
||||
<Rewind className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRestoreRewind}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-green-600 hover:bg-slate-100 transition-all cursor-pointer"
|
||||
title="恢复上次回退操作"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-slate-100 transition-all cursor-pointer"
|
||||
title="重试最后一轮对话"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBranch}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-slate-100 transition-all cursor-pointer"
|
||||
title="分叉当前会话"
|
||||
>
|
||||
<GitBranch className="w-4 h-4" />
|
||||
</button>
|
||||
{/* 可点击的控制头,用于折叠/展开 */}
|
||||
<div
|
||||
onClick={() =>
|
||||
setIsSessionControlExpanded(!isSessionControlExpanded)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-[9px] font-extrabold text-tertiary tracking-wider uppercase pl-0.5 cursor-pointer hover:text-content transition-colors select-none"
|
||||
>
|
||||
<Sliders
|
||||
className={`w-3 h-3 transition-transform duration-300 ${
|
||||
isSessionControlExpanded
|
||||
? 'rotate-90 text-blueprint'
|
||||
: 'text-tertiary'
|
||||
}`}
|
||||
/>
|
||||
<span>会话控制</span>
|
||||
{/* 微型展开/折叠指示标签 */}
|
||||
<span className="text-[7px] text-tertiary/60 ml-0.5">
|
||||
{isSessionControlExpanded ? '◀' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 展开内容:滑出显示的操作按键 */}
|
||||
<div
|
||||
className={`flex items-center transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isSessionControlExpanded
|
||||
? 'max-w-[150px] opacity-100'
|
||||
: 'max-w-0 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="h-3.5 w-[1px] bg-border-subtle mx-1.5 shrink-0" />
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRewind(undefined, 1);
|
||||
}}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-secondary border border-transparent hover:text-amber-600 hover:bg-sunken hover:border-amber-200/60 dark:hover:border-amber-500/30 transition-all duration-200 cursor-pointer"
|
||||
title="回退最近一轮对话 (undo)"
|
||||
>
|
||||
<Rewind className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRestoreRewind();
|
||||
}}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-secondary border border-transparent hover:text-green-600 hover:bg-sunken hover:border-green-200/60 dark:hover:border-green-500/30 transition-all duration-200 cursor-pointer"
|
||||
title="恢复上次回退操作"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry();
|
||||
}}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-secondary border border-transparent hover:text-indigo-600 hover:bg-sunken hover:border-indigo-200/60 dark:hover:border-indigo-500/30 transition-all duration-200 cursor-pointer"
|
||||
title="重试最后一轮对话"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleBranch();
|
||||
}}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-secondary border border-transparent hover:text-blue-600 hover:bg-sunken hover:border-blue-200/60 dark:hover:border-blue-500/30 transition-all duration-200 cursor-pointer"
|
||||
title="分叉当前会话"
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Loader,
|
||||
} from 'lucide-react';
|
||||
import type { AgentMetricsResponse } from '../../types';
|
||||
import { getToolMeta } from './index';
|
||||
|
||||
interface AgentMetricsPanelProps {
|
||||
showAlert?: (message: string, title?: string) => void;
|
||||
@ -54,43 +55,74 @@ const TOOL_LABELS: Record<string, string> = {
|
||||
|
||||
// 工具调用的分类色
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
read_file: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
grep_files: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
glob_files: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
run_bash: 'bg-slate-200 text-slate-700 border-slate-300',
|
||||
file_write: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
file_edit: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
search_papers: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
get_paper_metadata: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
download_paper: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
parse_paper: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
get_paper_content: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
rag_search: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
query_target: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
query_vizier: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
cone_search: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
find_observation: 'bg-slate-200 text-slate-700 border-slate-300',
|
||||
catalog_operation: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
save_note: 'bg-teal-100 text-teal-700 border-teal-200',
|
||||
todo_write: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
compress_context: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
load_skill: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
subagent: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
delegate_research: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
ask_user: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
save_memory: 'bg-pink-100 text-pink-700 border-pink-200',
|
||||
load_memory: 'bg-pink-100 text-pink-700 border-pink-200',
|
||||
bg_task_run: 'bg-cyan-100 text-cyan-700 border-cyan-200',
|
||||
bg_task_check: 'bg-cyan-100 text-cyan-700 border-cyan-200',
|
||||
spawn_teammate: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
|
||||
send_teammate_message: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
|
||||
team_broadcast: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
|
||||
check_team_inbox: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
|
||||
read_file:
|
||||
'bg-blue-100 text-blue-700 border-transparent dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
grep_files:
|
||||
'bg-blue-100 text-blue-700 border-transparent dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
glob_files:
|
||||
'bg-blue-100 text-blue-700 border-transparent dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
run_bash: 'bg-border-subtle text-secondary border-subtle',
|
||||
file_write:
|
||||
'bg-blue-100 text-blue-700 border-transparent dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
file_edit:
|
||||
'bg-blue-100 text-blue-700 border-transparent dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
search_papers:
|
||||
'bg-emerald-100 text-emerald-700 border-transparent dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
get_paper_metadata:
|
||||
'bg-emerald-100 text-emerald-700 border-transparent dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
download_paper:
|
||||
'bg-emerald-100 text-emerald-700 border-transparent dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
parse_paper:
|
||||
'bg-emerald-100 text-emerald-700 border-transparent dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
get_paper_content:
|
||||
'bg-emerald-100 text-emerald-700 border-transparent dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
rag_search:
|
||||
'bg-violet-100 text-violet-700 border-transparent dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
query_target:
|
||||
'bg-amber-100 text-amber-700 border-transparent dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30',
|
||||
query_vizier:
|
||||
'bg-violet-100 text-violet-700 border-transparent dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
cone_search:
|
||||
'bg-violet-100 text-violet-700 border-transparent dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
find_observation: 'bg-border-subtle text-secondary border-subtle',
|
||||
catalog_operation:
|
||||
'bg-violet-100 text-violet-700 border-transparent dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
save_note:
|
||||
'bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-500/15 dark:text-teal-300 dark:border-teal-500/30',
|
||||
todo_write:
|
||||
'bg-orange-100 text-orange-700 border-transparent dark:bg-orange-500/15 dark:text-orange-300 dark:border-orange-500/30',
|
||||
compress_context:
|
||||
'bg-rose-100 text-rose-700 border-transparent dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30',
|
||||
load_skill:
|
||||
'bg-indigo-100 text-indigo-700 border-transparent dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30',
|
||||
subagent:
|
||||
'bg-purple-100 text-purple-700 border-transparent dark:bg-purple-500/15 dark:text-purple-300 dark:border-purple-500/30',
|
||||
delegate_research:
|
||||
'bg-purple-100 text-purple-700 border-transparent dark:bg-purple-500/15 dark:text-purple-300 dark:border-purple-500/30',
|
||||
ask_user:
|
||||
'bg-amber-100 text-amber-700 border-transparent dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30',
|
||||
save_memory:
|
||||
'bg-pink-100 text-pink-700 border-transparent dark:bg-pink-500/15 dark:text-pink-300 dark:border-pink-500/30',
|
||||
load_memory:
|
||||
'bg-pink-100 text-pink-700 border-transparent dark:bg-pink-500/15 dark:text-pink-300 dark:border-pink-500/30',
|
||||
bg_task_run:
|
||||
'bg-cyan-100 text-cyan-700 border-transparent dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30',
|
||||
bg_task_check:
|
||||
'bg-cyan-100 text-cyan-700 border-transparent dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30',
|
||||
spawn_teammate:
|
||||
'bg-fuchsia-100 text-fuchsia-700 border-transparent dark:bg-fuchsia-500/15 dark:text-fuchsia-300 dark:border-fuchsia-500/30',
|
||||
send_teammate_message:
|
||||
'bg-fuchsia-100 text-fuchsia-700 border-transparent dark:bg-fuchsia-500/15 dark:text-fuchsia-300 dark:border-fuchsia-500/30',
|
||||
team_broadcast:
|
||||
'bg-fuchsia-100 text-fuchsia-700 border-transparent dark:bg-fuchsia-500/15 dark:text-fuchsia-300 dark:border-fuchsia-500/30',
|
||||
check_team_inbox:
|
||||
'bg-fuchsia-100 text-fuchsia-700 border-transparent dark:bg-fuchsia-500/15 dark:text-fuchsia-300 dark:border-fuchsia-500/30',
|
||||
};
|
||||
|
||||
export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
const [metrics, setMetrics] = useState<AgentMetricsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hideInternal, setHideInternal] = useState(false);
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(true);
|
||||
@ -130,29 +162,38 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
};
|
||||
}, [showAlert]);
|
||||
|
||||
// 提取工具调用排行(取前10)
|
||||
// 提取工具调用排行(取前15)
|
||||
const toolBreakdown = metrics?.tool_call_breakdown
|
||||
? Object.entries(metrics.tool_call_breakdown)
|
||||
.filter(([name]) => !hideInternal || !getToolMeta(name)?.is_internal)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 15)
|
||||
: [];
|
||||
|
||||
const maxToolCalls = toolBreakdown.length > 0 ? toolBreakdown[0][1] : 1;
|
||||
|
||||
const filteredBreakdown = metrics?.tool_call_breakdown
|
||||
? Object.fromEntries(
|
||||
Object.entries(metrics.tool_call_breakdown).filter(
|
||||
([name]) => !hideInternal || !getToolMeta(name)?.is_internal
|
||||
)
|
||||
)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-blueprint" />
|
||||
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
|
||||
<h3 className="text-xs font-extrabold text-content tracking-wide">
|
||||
智能体运行指标
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer disabled:opacity-50"
|
||||
className="p-1.5 rounded-md bg-sunken hover:bg-border-subtle text-secondary hover:text-secondary transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="刷新指标"
|
||||
>
|
||||
<RefreshCw
|
||||
@ -162,7 +203,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
</div>
|
||||
|
||||
{loading && !metrics ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-400 gap-2">
|
||||
<div className="flex items-center justify-center py-12 text-tertiary gap-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-blueprint" />
|
||||
<span className="text-xs font-bold">加载运行指标中...</span>
|
||||
</div>
|
||||
@ -198,11 +239,23 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
|
||||
{/* 工具调用分布 */}
|
||||
<div className="space-y-2.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
工具调用排行
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-tertiary uppercase tracking-wider">
|
||||
工具调用排行
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setHideInternal(!hideInternal)}
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold border transition-colors cursor-pointer select-none ${
|
||||
hideInternal
|
||||
? 'bg-blueprint/15 border-blueprint text-blueprint'
|
||||
: 'bg-surface border-subtle text-secondary hover:bg-sunken hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
{hideInternal ? '显示系统工具' : '隐藏系统工具'}
|
||||
</button>
|
||||
</div>
|
||||
{toolBreakdown.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic py-4 text-center">
|
||||
<p className="text-xs text-tertiary italic py-4 text-center">
|
||||
暂无工具调用记录
|
||||
</p>
|
||||
) : (
|
||||
@ -211,23 +264,26 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
const barWidth = Math.max((count / maxToolCalls) * 100, 2);
|
||||
const colorClass =
|
||||
CATEGORY_COLORS[name] ||
|
||||
'bg-slate-100 text-slate-700 border-slate-200';
|
||||
const label = TOOL_LABELS[name] || name;
|
||||
'bg-sunken text-secondary border-subtle';
|
||||
const label =
|
||||
getToolMeta(name)?.display_name ||
|
||||
TOOL_LABELS[name] ||
|
||||
name;
|
||||
return (
|
||||
<div key={name} className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="text-[10px] text-slate-500 w-24 shrink-0 text-right font-medium truncate"
|
||||
className="text-[10px] text-secondary w-24 shrink-0 text-right font-medium truncate"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-5 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
||||
<div className="flex-1 h-5 bg-sunken rounded-full overflow-hidden ">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${colorClass.split(' ')[0]}`}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-600 w-8 text-right shrink-0">
|
||||
<span className="text-[10px] font-bold text-secondary w-8 text-right shrink-0">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
@ -238,21 +294,21 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* 工具分类统计 */}
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">
|
||||
<div className="border-t border-subtle pt-3">
|
||||
<span className="text-[10px] font-bold text-tertiary uppercase tracking-wider block mb-2">
|
||||
按功能域分布
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(
|
||||
getCategoryCounts(metrics.tool_call_breakdown)
|
||||
).map(([category, count]) => (
|
||||
<span
|
||||
key={category}
|
||||
className="px-2.5 py-1 rounded-lg text-[10px] font-bold border bg-slate-50 text-slate-600 border-slate-200"
|
||||
>
|
||||
{category}: {count}
|
||||
</span>
|
||||
))}
|
||||
{Object.entries(getCategoryCounts(filteredBreakdown)).map(
|
||||
([category, count]) => (
|
||||
<span
|
||||
key={category}
|
||||
className="px-2.5 py-1 rounded-lg text-[10px] font-bold border bg-sunken text-secondary border-subtle"
|
||||
>
|
||||
{category}: {count}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@ -274,11 +330,14 @@ function MetricCard({
|
||||
color: string;
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
sky: 'border-slate-200 bg-slate-50 text-blueprint',
|
||||
emerald: 'border-emerald-200 bg-emerald-50/40 text-emerald-700',
|
||||
violet: 'border-violet-200 bg-violet-50/40 text-violet-700',
|
||||
rose: 'border-rose-200 bg-rose-50/40 text-rose-700',
|
||||
amber: 'border-amber-200 bg-amber-50/40 text-amber-700',
|
||||
sky: 'border-subtle bg-sunken text-blueprint',
|
||||
emerald:
|
||||
'border-transparent bg-emerald-50/40 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
violet:
|
||||
'border-transparent bg-violet-50/40 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
rose: 'border-transparent bg-rose-50/40 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30',
|
||||
amber:
|
||||
'border-transparent bg-amber-50/40 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -69,35 +69,35 @@ export function AgentSessionSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleCollapse(true)}
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
|
||||
aria-label="关闭侧栏"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`bg-slate-50 flex flex-col justify-between shrink-0 select-none transition-all duration-300 ease-in-out fixed inset-y-0 left-0 z-40 w-64 lg:relative lg:translate-x-0 ${
|
||||
className={`bg-surface border-r border-subtle flex flex-col justify-between shrink-0 select-none transition-all duration-300 ease-in-out fixed inset-y-0 left-0 z-40 w-64 lg:relative lg:translate-x-0 ${
|
||||
collapsed
|
||||
? '-translate-x-full lg:translate-x-0 lg:w-0 lg:overflow-hidden lg:opacity-0 lg:border-r-0'
|
||||
: 'translate-x-0 border-r border-slate-200'
|
||||
: 'translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<div className="p-4 bg-surface flex items-center justify-between shrink-0">
|
||||
<span className="text-xs font-extrabold text-content tracking-wider flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-blueprint" />
|
||||
<span>会话历史</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-650 hover:text-slate-800 transition-all cursor-pointer shadow-2xs"
|
||||
className="p-1 rounded-md bg-surface hover:bg-sunken text-secondary hover:text-content transition-all cursor-pointer"
|
||||
title="新建会话"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleCollapse(true)}
|
||||
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-555 hover:text-slate-700 transition-all cursor-pointer"
|
||||
className="p-1 rounded-md bg-surface hover:bg-sunken text-secondary hover:text-secondary transition-all cursor-pointer"
|
||||
title="收起侧栏"
|
||||
>
|
||||
<PanelLeftClose className="w-3.5 h-3.5" />
|
||||
@ -106,34 +106,34 @@ export function AgentSessionSidebar({
|
||||
</div>
|
||||
|
||||
{/* 搜索框区域 */}
|
||||
<div className="px-3 py-2 border-b border-slate-200/60 bg-white flex flex-col gap-1.5 shrink-0">
|
||||
<div className="px-3 py-2 bg-surface border-b border-subtle flex flex-col gap-1.5 shrink-0">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索历史会话或消息内容..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-md bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-[11px] font-medium"
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-[11px] font-medium"
|
||||
/>
|
||||
<div className="absolute left-2.5 top-2.5 text-slate-400">
|
||||
<Search className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div className="absolute left-2.5 top-2.5 text-tertiary">
|
||||
<Search className="w-3.5 h-3.5 text-tertiary" />
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-2 p-0.5 rounded-full hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
className="absolute right-2 top-2 p-0.5 rounded-full hover:bg-border-subtle text-tertiary hover:text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{currentSessionId && (
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer font-medium select-none ml-0.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-secondary cursor-pointer font-medium select-none ml-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchScopeOnlyCurrent}
|
||||
onChange={(e) => setSearchScopeOnlyCurrent(e.target.checked)}
|
||||
className="rounded text-blueprint border-slate-300 focus:ring-blueprint w-3 h-3 cursor-pointer"
|
||||
className="rounded text-blueprint border-subtle focus:ring-blueprint w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
<span>仅搜索当前会话</span>
|
||||
</label>
|
||||
@ -143,12 +143,12 @@ export function AgentSessionSidebar({
|
||||
<div className="flex-1 overflow-y-auto p-2.5 space-y-1.5 scrollbar-thin">
|
||||
{searchQuery.trim() ? (
|
||||
loadingSearch ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<div className="flex items-center justify-center p-8 text-tertiary text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
|
||||
<span>检索历史记录中...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||
<div className="text-center py-12 text-tertiary text-[11px] italic">
|
||||
未找到匹配的历史记录
|
||||
</div>
|
||||
) : (
|
||||
@ -165,15 +165,15 @@ export function AgentSessionSidebar({
|
||||
onClick={() => handleSelectSession(result.session_id)}
|
||||
className={`w-full text-left p-2.5 rounded-md border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
|
||||
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold'
|
||||
: 'border-transparent bg-surface hover:bg-sunken text-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-tertiary flex items-center gap-1">
|
||||
{isMessage ? (
|
||||
<>
|
||||
<MessageSquare className="w-3 h-3 text-slate-400" />
|
||||
<MessageSquare className="w-3 h-3 text-tertiary" />
|
||||
<span>{role === 'user' ? '提问' : '解答'}</span>
|
||||
</>
|
||||
) : (
|
||||
@ -184,18 +184,18 @@ export function AgentSessionSidebar({
|
||||
)}
|
||||
</span>
|
||||
{result.created_at && (
|
||||
<span className="text-[8px] text-slate-400 font-mono">
|
||||
<span className="text-[8px] text-tertiary font-mono">
|
||||
{result.created_at.split(' ')[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
|
||||
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-content">
|
||||
{result.title || '无标题会话'}
|
||||
</span>
|
||||
|
||||
<p
|
||||
className="text-[10px] text-slate-500 font-medium leading-normal block text-left line-clamp-3 bg-slate-50/50 p-1.5 rounded border border-slate-100/50"
|
||||
className="text-[10px] text-secondary font-medium leading-normal block text-left line-clamp-3 bg-sunken/50 p-1.5 rounded /50"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
</button>
|
||||
@ -204,12 +204,12 @@ export function AgentSessionSidebar({
|
||||
)
|
||||
) : // 正常的会话列表渲染
|
||||
loadingSessions ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<div className="flex items-center justify-center p-8 text-tertiary text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
|
||||
<span>加载历史会话中...</span>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||
<div className="text-center py-12 text-tertiary text-[11px] italic">
|
||||
暂无历史会话记录
|
||||
</div>
|
||||
) : (
|
||||
@ -221,22 +221,22 @@ export function AgentSessionSidebar({
|
||||
onClick={() => handleSelectSession(session.session_id)}
|
||||
className={`w-full text-left p-3 rounded-md border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
|
||||
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-655'
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold'
|
||||
: 'border-transparent bg-transparent hover:bg-sunken text-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left">
|
||||
<span className="text-xs leading-snug line-clamp-2 block text-left">
|
||||
<span className="text-xs leading-snug truncate block text-left">
|
||||
{session.title || '无标题会话'}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold block text-left">
|
||||
<span className="text-[9px] text-tertiary font-semibold block text-left">
|
||||
{session.turn_count} 轮交互 •{' '}
|
||||
{session.updated_at.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => onDeleteSession(session.session_id, e)}
|
||||
className="text-slate-400 hover:text-red-655 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-white border border-transparent hover:border-slate-200 transition-all cursor-pointer shrink-0"
|
||||
className="text-tertiary hover:text-danger p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-surface border border-transparent hover:border-subtle transition-all cursor-pointer shrink-0"
|
||||
title="删除此会话"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
|
||||
@ -21,13 +21,13 @@ export function AnswerCard({
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${colorScheme.answer}`}
|
||||
/>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1 mb-1">
|
||||
<span className="text-[10px] font-bold text-tertiary px-1 flex items-center gap-1 mb-1">
|
||||
<CheckCircle2
|
||||
className={`w-3.5 h-3.5 text-emerald-500 ${isStreaming ? 'animate-pulse' : ''}`}
|
||||
className={`w-3.5 h-3.5 text-success ${isStreaming ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span>结论</span>
|
||||
</span>
|
||||
<div className="w-full rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 prose prose-sm max-w-none select-text prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
|
||||
<div className="w-full rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold border bg-surface text-content border-subtle prose prose-sm max-w-none select-text prose-headings:text-content prose-headings:font-bold prose-strong:text-content prose-code:text-blueprint prose-img:rounded-lg">
|
||||
<AgentMarkdown>{content}</AgentMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -149,17 +149,17 @@ export function AskUserQuestionCard({
|
||||
return (
|
||||
<div
|
||||
key={q.question_id}
|
||||
className="rounded-md border border-slate-200 border-l-4 border-l-blueprint bg-slate-50 p-4 transition-all pointer-events-auto text-xs shadow-sm"
|
||||
className="rounded-md border-l-4 border-l-blueprint bg-sunken p-4 transition-all pointer-events-auto text-xs"
|
||||
>
|
||||
{/* 标题栏 - 头部与问题放在同一行,可省略独立的问题文本框 */}
|
||||
<div className="mb-2.5 flex items-center justify-between border-b border-slate-200 pb-2 min-w-0">
|
||||
<div className="mb-2.5 flex items-center justify-between border-b border-subtle pb-2 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3">
|
||||
<MessageCircle className="h-3.5 w-3.5 text-blueprint shrink-0" />
|
||||
<span className="px-1.5 py-0.2 rounded bg-slate-200 border border-slate-300 text-[9px] font-bold text-slate-755 shrink-0">
|
||||
<span className="px-1.5 py-0.2 rounded bg-border-subtle text-[9px] font-bold text-content shrink-0">
|
||||
{q.header || '提问'}
|
||||
</span>
|
||||
<span
|
||||
className="font-extrabold text-slate-800 text-[11px] truncate flex-1"
|
||||
className="font-extrabold text-content text-[11px] truncate flex-1"
|
||||
title={q.question || ''}
|
||||
>
|
||||
{q.question || ''}
|
||||
@ -189,7 +189,7 @@ export function AskUserQuestionCard({
|
||||
{/* 选项列表 */}
|
||||
{options.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
<span className="text-[9px] font-bold text-tertiary uppercase tracking-wider block">
|
||||
{multiSelect ? '可多选' : '请选择一项'}
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
@ -216,7 +216,7 @@ export function AskUserQuestionCard({
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${
|
||||
selected
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold'
|
||||
: 'bg-white border-slate-200/60 text-slate-700 hover:border-slate-300 hover:bg-slate-50'
|
||||
: 'bg-surface border-subtle text-secondary hover:border-subtle hover:bg-sunken'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{/* 选择指示器 */}
|
||||
@ -224,14 +224,14 @@ export function AskUserQuestionCard({
|
||||
selected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-blueprint shrink-0" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<Square className="w-3.5 h-3.5 text-tertiary shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
|
||||
selected
|
||||
? 'border-blueprint bg-blueprint'
|
||||
: 'border-slate-300'
|
||||
: 'border-subtle'
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
@ -244,12 +244,12 @@ export function AskUserQuestionCard({
|
||||
|
||||
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
|
||||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 truncate">
|
||||
<span className="font-extrabold text-slate-800 text-[11px] shrink-0 leading-normal">
|
||||
<span className="font-extrabold text-content text-[11px] shrink-0 leading-normal">
|
||||
{label}
|
||||
</span>
|
||||
{desc && (
|
||||
<span
|
||||
className="text-[10px] text-slate-500 font-medium truncate leading-normal"
|
||||
className="text-[10px] text-secondary font-medium truncate leading-normal"
|
||||
title={desc}
|
||||
>
|
||||
— {desc}
|
||||
@ -276,13 +276,13 @@ export function AskUserQuestionCard({
|
||||
disabled={isSubmitting}
|
||||
placeholder="补充说明(可选)..."
|
||||
rows={1}
|
||||
className="w-full bg-white border border-slate-200/60 rounded-md px-2.5 py-1.5 text-[11px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:ring-1 focus:ring-blueprint/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal"
|
||||
className="w-full bg-surface border border-default rounded-md px-2.5 py-1.5 text-[11px] text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:ring-1 focus:ring-blueprint/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{qError && (
|
||||
<div className="text-[10px] text-red-700 bg-red-50 border border-red-200 rounded-md px-2.5 py-1.5 font-bold">
|
||||
<div className="text-[10px] text-danger bg-red-50 border border-red-200 dark:bg-rose-500/10 dark:border-rose-500/30 rounded-md px-2.5 py-1.5 font-bold">
|
||||
{qError}
|
||||
</div>
|
||||
)}
|
||||
@ -292,7 +292,7 @@ export function AskUserQuestionCard({
|
||||
<button
|
||||
onClick={() => handleSubmit(q.question_id)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 bg-emerald-700 hover:bg-emerald-800 border border-emerald-700 text-white rounded-md py-1.5 text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 bg-success hover:opacity-90 border border-success text-white rounded-md py-1.5 text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
@ -309,7 +309,7 @@ export function AskUserQuestionCard({
|
||||
<button
|
||||
onClick={() => dismissQuestion(q.question_id)}
|
||||
disabled={isSubmitting}
|
||||
className="px-3 bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-250 rounded-md text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
|
||||
className="px-3 bg-sunken hover:bg-border-subtle text-secondary rounded-md text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
|
||||
@ -98,14 +98,14 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="w-4 h-4 text-blueprint" />
|
||||
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
|
||||
<h3 className="text-xs font-extrabold text-content tracking-wide">
|
||||
会话审计日志
|
||||
</h3>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
className="text-[10px] font-bold text-tertiary hover:text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
@ -115,44 +115,42 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
{/* 汇总 */}
|
||||
{entries.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-md bg-emerald-50/40 border border-emerald-200 px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-emerald-700">
|
||||
{okCount}
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-emerald-500">成功</div>
|
||||
<div className="rounded-md bg-emerald-50/40 border border-transparent px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-success">{okCount}</div>
|
||||
<div className="text-[9px] font-bold text-success">成功</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-rose-50/40 border border-rose-200 px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-rose-700">
|
||||
<div className="rounded-md bg-rose-50/40 border border-transparent px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-danger">
|
||||
{failCount}
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-rose-500">失败</div>
|
||||
<div className="text-[9px] font-bold text-danger">失败</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-50 border border-slate-200 px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-slate-700">
|
||||
<div className="rounded-md bg-sunken px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-secondary">
|
||||
{(totalElapsed / 1000).toFixed(1)}s
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-slate-500">总耗时</div>
|
||||
<div className="text-[9px] font-bold text-secondary">总耗时</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载/错误状态 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-slate-400 gap-2">
|
||||
<div className="flex items-center justify-center py-8 text-tertiary gap-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-blueprint" />
|
||||
<span className="text-xs font-bold">加载审计日志中...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-slate-800 bg-slate-50 border border-slate-200 border-l-4 border-l-rose-500 rounded-md px-3 py-2 text-xs font-semibold">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-rose-500 shrink-0 mt-0.2" />
|
||||
<div className="flex items-start gap-2 text-content bg-sunken border-l-4 border-l-rose-500 rounded-md px-3 py-2 text-xs font-semibold">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-danger shrink-0 mt-0.2" />
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日志表格 */}
|
||||
{!loading && entries.length === 0 && !error && (
|
||||
<p className="text-xs text-slate-400 italic py-8 text-center">
|
||||
<p className="text-xs text-tertiary italic py-8 text-center">
|
||||
该会话暂无审计日志记录
|
||||
</p>
|
||||
)}
|
||||
@ -161,20 +159,20 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left">
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-10">
|
||||
<tr className="border-b border-subtle text-left">
|
||||
<th className="pb-2 pr-2 font-extrabold text-tertiary uppercase tracking-wider w-10">
|
||||
步骤
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-2 font-extrabold text-tertiary uppercase tracking-wider">
|
||||
工具
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-12">
|
||||
<th className="pb-2 pr-2 font-extrabold text-tertiary uppercase tracking-wider w-12">
|
||||
状态
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-16 text-right">
|
||||
<th className="pb-2 pr-2 font-extrabold text-tertiary uppercase tracking-wider w-16 text-right">
|
||||
耗时
|
||||
</th>
|
||||
<th className="pb-2 font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
<th className="pb-2 font-extrabold text-tertiary uppercase tracking-wider">
|
||||
输出预览
|
||||
</th>
|
||||
</tr>
|
||||
@ -188,39 +186,39 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={`border-b border-slate-100 ${
|
||||
className={`border-b border-subtle ${
|
||||
entry.status === 'FAIL' ? 'bg-rose-50/30' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 pr-2 font-mono text-slate-500 align-top">
|
||||
<td className="py-2 pr-2 font-mono text-secondary align-top">
|
||||
#{entry.step}
|
||||
</td>
|
||||
<td className="py-2 pr-2 font-semibold text-slate-700 align-top">
|
||||
<td className="py-2 pr-2 font-semibold text-secondary align-top">
|
||||
{getToolDisplayName(entry.tool_name)}
|
||||
{entry.tool_name && (
|
||||
<span className="text-[9px] text-slate-400 font-mono block">
|
||||
<span className="text-[9px] text-tertiary font-mono block">
|
||||
{entry.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 align-top">
|
||||
{entry.status === 'OK' ? (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold text-[9px]">
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-emerald-100 text-success font-bold text-[9px]">
|
||||
<CheckCircle2 className="w-2.5 h-2.5" />
|
||||
OK
|
||||
</span>
|
||||
) : entry.status === 'FAIL' ? (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold text-[9px]">
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-rose-100 text-danger font-bold text-[9px]">
|
||||
<XCircle className="w-2.5 h-2.5" />
|
||||
FAIL
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400 text-[9px]">
|
||||
<span className="text-tertiary text-[9px]">
|
||||
{entry.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono text-slate-500 align-top">
|
||||
<td className="py-2 pr-2 text-right font-mono text-secondary align-top">
|
||||
<span className="flex items-center gap-0.5 justify-end">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{entry.elapsed_ms >= 1000
|
||||
@ -233,7 +231,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
<div>
|
||||
{isExpanded ? (
|
||||
<div className="space-y-1">
|
||||
<pre className="font-mono text-[9px] text-slate-600 bg-slate-50 border border-slate-200 rounded-md p-2 max-h-32 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
<pre className="font-mono text-[9px] text-secondary bg-sunken rounded-md p-2 max-h-32 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{entry.output_preview}
|
||||
</pre>
|
||||
<button
|
||||
@ -257,7 +255,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
[entry.id]: true,
|
||||
}))
|
||||
}
|
||||
className="text-left font-mono text-[9px] text-slate-500 hover:text-blueprint transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]"
|
||||
className="text-left font-mono text-[9px] text-secondary hover:text-blueprint transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]"
|
||||
>
|
||||
<ChevronDown className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
@ -270,7 +268,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 italic text-[9px]">
|
||||
<span className="text-tertiary italic text-[9px]">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -96,17 +96,17 @@ export function PermissionRequestCard({
|
||||
return (
|
||||
<div
|
||||
key={req.permission_id}
|
||||
className="rounded-md border border-slate-200 border-l-4 border-l-amber-500 bg-slate-50 p-4 transition-all pointer-events-auto text-xs shadow-sm"
|
||||
className="rounded-md border-l-4 border-l-amber-500 bg-sunken p-4 transition-all pointer-events-auto text-xs"
|
||||
>
|
||||
{/* 标题与控制栏 */}
|
||||
<div className="mb-2.5 flex items-center justify-between border-b border-slate-200 pb-2">
|
||||
<div className="mb-2.5 flex items-center justify-between border-b border-subtle pb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Shield className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
<span className="font-bold text-slate-800 tracking-wide text-[11px] shrink-0">
|
||||
<span className="font-bold text-content tracking-wide text-[11px] shrink-0">
|
||||
敏感操作授权
|
||||
</span>
|
||||
<code
|
||||
className="rounded bg-slate-200/80 border border-slate-300 px-1.5 py-0.5 text-[9px] font-mono font-bold text-slate-700 truncate"
|
||||
className="rounded bg-border-subtle px-1.5 py-0.5 text-[9px] font-mono font-bold text-secondary truncate"
|
||||
title={req.tool_name}
|
||||
>
|
||||
{req.tool_name}
|
||||
@ -117,7 +117,7 @@ export function PermissionRequestCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleParams(req.permission_id)}
|
||||
className="text-[10px] font-bold text-slate-500 hover:text-slate-800 underline underline-offset-2 cursor-pointer select-none"
|
||||
className="text-[10px] font-bold text-secondary hover:text-content underline underline-offset-2 cursor-pointer select-none"
|
||||
>
|
||||
{showArgs ? '隐藏参数' : '参数详情'}
|
||||
</button>
|
||||
@ -125,13 +125,13 @@ export function PermissionRequestCard({
|
||||
</div>
|
||||
|
||||
{/* 警告消息 */}
|
||||
<p className="mb-3 text-[11px] font-medium text-slate-750 leading-relaxed">
|
||||
<p className="mb-3 text-[11px] font-medium text-secondary leading-relaxed">
|
||||
{req.message}
|
||||
</p>
|
||||
|
||||
{/* 显示工具参数的简化预览 - 按需展开 */}
|
||||
{hasArgs && showArgs && (
|
||||
<pre className="mb-3 max-h-24 overflow-auto rounded bg-white border border-slate-200 p-2 text-[9px] font-mono text-slate-600 scrollbar-thin">
|
||||
<pre className="mb-3 max-h-24 overflow-auto rounded bg-surface p-2 text-[9px] font-mono text-secondary scrollbar-thin">
|
||||
{JSON.stringify(req.arguments, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
@ -141,7 +141,7 @@ export function PermissionRequestCard({
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, false)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-emerald-700 hover:bg-emerald-800 text-white disabled:opacity-50"
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-success hover:opacity-90 text-white disabled:opacity-50"
|
||||
>
|
||||
{submitting[req.tool_call_id] ? (
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
@ -153,14 +153,14 @@ export function PermissionRequestCard({
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, true)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-slate-800 hover:bg-slate-900 text-white disabled:opacity-50"
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-elevated hover:bg-slate-900 text-white disabled:opacity-50"
|
||||
>
|
||||
总是允许
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, false, false)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-slate-100 border border-slate-250 text-slate-700 hover:bg-slate-200 disabled:opacity-50"
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-sunken border border-default text-secondary hover:bg-border-subtle disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
拒绝执行
|
||||
|
||||
@ -5,14 +5,13 @@ import {
|
||||
GitFork,
|
||||
Download,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
Check,
|
||||
Table as TableIcon,
|
||||
Database,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import type { useCitations } from '../../hooks/useCitations';
|
||||
@ -55,10 +54,10 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
const vizierUrl = `https://vizier.cds.unistra.fr/viz-bin/VizieR-s?${encodeURIComponent(target_name)}`;
|
||||
|
||||
return (
|
||||
<div className="bg-sky-50/40 border border-sky-100 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
|
||||
<div className="flex items-center justify-between border-b border-sky-100/70 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-sky-900 text-[11px] uppercase tracking-wide">
|
||||
<Orbit className="w-3.5 h-3.5 text-sky-600 animate-spin-slow" />
|
||||
<div className="bg-sky-50/40 border border-transparent dark:bg-sky-500/10 dark:border-sky-500/30 rounded-lg p-3.5 space-y-3 text-xs">
|
||||
<div className="flex items-center justify-between border-b border-transparent dark:border-sky-500/30 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-sky-900 dark:text-sky-300 text-[11px] uppercase tracking-wide">
|
||||
<Orbit className="w-3.5 h-3.5 text-sky-600 dark:text-sky-300 animate-spin-slow" />
|
||||
<span>天体参数查询结果</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@ -66,7 +65,7 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
href={simbadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 flex items-center gap-0.5 hover:underline"
|
||||
className="text-[10px] font-bold text-sky-600 dark:text-sky-300 hover:opacity-90 flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<span>SIMBAD</span>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
@ -75,7 +74,7 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
href={vizierUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 flex items-center gap-0.5 hover:underline"
|
||||
className="text-[10px] font-bold text-sky-600 dark:text-sky-300 hover:opacity-90 flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<span>VizieR</span>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
@ -83,65 +82,65 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-slate-700">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-secondary">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
主要名称
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
<span className="font-semibold text-content">
|
||||
{oname || target_name}
|
||||
</span>
|
||||
</div>
|
||||
{otype && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
天体类型
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900">{otype}</span>
|
||||
<span className="font-semibold text-content">{otype}</span>
|
||||
</div>
|
||||
)}
|
||||
{ra && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
赤经 (RA)
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-900">{ra}</span>
|
||||
<span className="font-mono font-medium text-content">{ra}</span>
|
||||
</div>
|
||||
)}
|
||||
{dec && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
赤纬 (Dec)
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-900">{dec}</span>
|
||||
<span className="font-mono font-medium text-content">{dec}</span>
|
||||
</div>
|
||||
)}
|
||||
{v_magnitude !== undefined && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
V星等
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-slate-900">
|
||||
<span className="font-mono font-semibold text-content">
|
||||
{v_magnitude.toFixed(3)} mag
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{parallax !== undefined && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
视差 (Parallax)
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-900">
|
||||
<span className="font-mono font-medium text-content">
|
||||
{parallax.toFixed(4)} mas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{spectral_type && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
<span className="text-[10px] text-tertiary font-bold block">
|
||||
光谱类型
|
||||
</span>
|
||||
<span className="font-mono font-bold text-slate-900">
|
||||
<span className="font-mono font-bold text-content">
|
||||
{spectral_type}
|
||||
</span>
|
||||
</div>
|
||||
@ -150,9 +149,9 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
|
||||
{/* 别名 */}
|
||||
{aliases.length > 0 && (
|
||||
<div className="border-t border-sky-100/50 pt-2 text-[10px]">
|
||||
<span className="text-slate-400 font-bold block">常用别名</span>
|
||||
<span className="text-slate-600 leading-normal line-clamp-2">
|
||||
<div className="border-t border-transparent dark:border-sky-500/30 pt-2 text-[10px]">
|
||||
<span className="text-tertiary font-bold block">常用别名</span>
|
||||
<span className="text-secondary leading-normal line-clamp-2">
|
||||
{aliases.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
@ -160,8 +159,8 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
|
||||
{/* 多波段测光 */}
|
||||
{Object.keys(photometry).length > 0 && (
|
||||
<div className="border-t border-sky-100/50 pt-2">
|
||||
<span className="text-[10px] text-slate-400 font-bold block mb-1">
|
||||
<div className="border-t border-transparent dark:border-sky-500/30 pt-2">
|
||||
<span className="text-[10px] text-tertiary font-bold block mb-1">
|
||||
多波段测光数据
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@ -170,10 +169,12 @@ export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
.map(([band, val]) => (
|
||||
<div
|
||||
key={band}
|
||||
className="bg-white/60 border border-sky-100/50 px-2 py-0.5 rounded flex items-center gap-1 font-mono text-[10px]"
|
||||
className="bg-white/60 border border-transparent dark:bg-sky-500/10 dark:border-sky-500/30 px-2 py-0.5 rounded flex items-center gap-1 font-mono text-[10px]"
|
||||
>
|
||||
<span className="font-bold text-sky-850">{band}</span>
|
||||
<span className="text-slate-700 font-semibold">
|
||||
<span className="font-bold text-sky-900 dark:text-sky-300">
|
||||
{band}
|
||||
</span>
|
||||
<span className="text-secondary font-semibold">
|
||||
{val.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
@ -220,7 +221,7 @@ export function PaperListCard({
|
||||
|
||||
if (papers.length === 0) {
|
||||
return (
|
||||
<div className="text-[10px] text-slate-400 italic p-2 border border-dashed border-slate-200 rounded-md">
|
||||
<div className="text-[10px] text-tertiary italic p-2 border border-dashed border-subtle rounded-md">
|
||||
未检索到匹配的文献结果。
|
||||
</div>
|
||||
);
|
||||
@ -300,7 +301,7 @@ export function PaperListCard({
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-w-full select-none">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider pl-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-tertiary uppercase tracking-wider pl-1">
|
||||
<span>检索结果共 {metadata.count} 篇</span>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto pr-1 scrollbar-thin">
|
||||
@ -318,17 +319,17 @@ export function PaperListCard({
|
||||
return (
|
||||
<div
|
||||
key={paper.bibcode}
|
||||
className="bg-white border border-slate-200/80 rounded-lg p-3 hover:shadow-2xs transition-all flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs relative"
|
||||
className="bg-surface rounded-lg p-3 hover:transition-all flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs relative"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h4
|
||||
className="font-extrabold text-slate-800 leading-tight line-clamp-2 select-text"
|
||||
className="font-extrabold text-content leading-tight line-clamp-2 select-text"
|
||||
title={paper.title}
|
||||
>
|
||||
{paper.title}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-slate-500 font-semibold">
|
||||
<span className="font-bold text-slate-700">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-secondary font-semibold">
|
||||
<span className="font-bold text-secondary">
|
||||
{paper.first_author} et al.
|
||||
</span>
|
||||
<span>·</span>
|
||||
@ -351,7 +352,7 @@ export function PaperListCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-slate-400 font-bold select-all truncate">
|
||||
<div className="text-[9px] font-mono text-tertiary font-bold select-all truncate">
|
||||
{paper.bibcode}
|
||||
</div>
|
||||
</div>
|
||||
@ -362,7 +363,7 @@ export function PaperListCard({
|
||||
{isDownloaded ? (
|
||||
<button
|
||||
onClick={() => triggerRead(paper.bibcode)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-700 text-white font-bold text-[10px] transition-colors cursor-pointer"
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-blueprint hover:opacity-90 text-white font-bold text-[10px] transition-colors cursor-pointer"
|
||||
title="立即在对比阅读器中打开"
|
||||
>
|
||||
<BookOpen className="w-3 h-3" />
|
||||
@ -371,18 +372,18 @@ export function PaperListCard({
|
||||
) : isDownloading ? (
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-slate-100 text-slate-400 border border-slate-200 font-bold text-[10px] cursor-not-allowed"
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-sunken text-tertiary font-bold text-[10px] cursor-not-allowed"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-slate-400" />
|
||||
<Loader2 className="w-3 h-3 animate-spin text-tertiary" />
|
||||
<span>下载中</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => triggerDownload(paper.bibcode)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-white hover:bg-slate-50 text-sky-650 border border-sky-200 hover:border-sky-300 font-bold text-[10px] transition-colors cursor-pointer"
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-surface hover:bg-sunken text-blueprint border border-blueprint/20 hover:border-blueprint/40 font-bold text-[10px] transition-colors cursor-pointer"
|
||||
title="下载文献资源到本地"
|
||||
>
|
||||
<Download className="w-3 h-3 text-sky-600" />
|
||||
<Download className="w-3 h-3 text-blueprint" />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
)}
|
||||
@ -390,7 +391,7 @@ export function PaperListCard({
|
||||
{/* 2. 引用拓扑 */}
|
||||
<button
|
||||
onClick={() => triggerGalaxy(paper)}
|
||||
className="flex items-center justify-center p-1 rounded bg-white hover:bg-slate-50 text-slate-500 hover:text-sky-600 border border-slate-200 hover:border-slate-350 transition-colors cursor-pointer"
|
||||
className="flex items-center justify-center p-1 rounded bg-surface hover:bg-sunken text-secondary hover:text-blueprint hover:border-blueprint/40 transition-colors cursor-pointer"
|
||||
title="生成并查看引用星系图"
|
||||
>
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
@ -401,7 +402,7 @@ export function PaperListCard({
|
||||
href={adsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 rounded bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 border border-slate-200 hover:border-slate-350 transition-colors"
|
||||
className="flex items-center justify-center p-1 rounded bg-surface hover:bg-sunken text-secondary hover:text-secondary hover:border-subtle transition-colors"
|
||||
title="访问 NASA ADS 详情页"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
@ -433,65 +434,100 @@ export function TodoTaskCard({ todos }: TodoTaskCardProps) {
|
||||
if (!Array.isArray(todos) || todos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs select-none">
|
||||
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide border-b border-slate-200/70 pb-2">
|
||||
<Activity className="w-3.5 h-3.5 text-blueprint animate-pulse" />
|
||||
<span>科研研究任务看板</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 relative pl-1 select-none">
|
||||
{todos.map((todo, idx) => {
|
||||
const isCompleted = todo.status === 'completed';
|
||||
const isInProgress = todo.status === 'in_progress';
|
||||
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => {
|
||||
const isCompleted = todo.status === 'completed';
|
||||
const isInProgress = todo.status === 'in_progress';
|
||||
// 智能依赖阻塞检查
|
||||
const isBlocked =
|
||||
todo.blockedBy &&
|
||||
todo.blockedBy.some((depId) => {
|
||||
const dep = todos.find((t) => t.id === depId);
|
||||
return dep && dep.status !== 'completed';
|
||||
});
|
||||
|
||||
// 基于状态选择 Lucide 图标而不用 Emoji
|
||||
let statusIcon = (
|
||||
<Circle className="w-4.5 h-4.5 text-slate-350 shrink-0" />
|
||||
);
|
||||
let statusStyle = 'border-slate-200 bg-white text-slate-700';
|
||||
// 状态元素定义
|
||||
let statusIcon = (
|
||||
<div className="w-4 h-4 rounded-full border border-subtle bg-surface flex items-center justify-center text-[8px] text-tertiary/60 shrink-0 font-mono font-bold">
|
||||
{idx + 1}
|
||||
</div>
|
||||
);
|
||||
let rowClass = 'opacity-70';
|
||||
let titleClass = 'text-secondary font-medium';
|
||||
let badgeClass = 'bg-sunken text-tertiary';
|
||||
|
||||
if (isCompleted) {
|
||||
statusIcon = (
|
||||
<CheckCircle2 className="w-4.5 h-4.5 text-emerald-600 shrink-0" />
|
||||
);
|
||||
statusStyle =
|
||||
'border-emerald-100 bg-emerald-50/20 text-slate-400 line-through';
|
||||
} else if (isInProgress) {
|
||||
statusIcon = (
|
||||
<Loader2 className="w-4.5 h-4.5 text-blueprint shrink-0 animate-spin" />
|
||||
);
|
||||
statusStyle =
|
||||
'border-blueprint bg-blueprint/5 text-slate-900 font-bold border-l-4';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`border rounded-lg p-2.5 flex items-start gap-2.5 transition-all ${statusStyle}`}
|
||||
>
|
||||
{statusIcon}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[9px] bg-slate-200 text-slate-600 px-1 rounded font-extrabold leading-normal select-all">
|
||||
{todo.id}
|
||||
</span>
|
||||
{todo.blockedBy && todo.blockedBy.length > 0 && (
|
||||
<span className="font-mono text-[8px] bg-amber-50 text-amber-700 border border-amber-200 px-1 rounded flex items-center gap-0.5 leading-normal">
|
||||
<span>依赖:</span>
|
||||
<span className="font-bold">
|
||||
{todo.blockedBy.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed select-text font-semibold">
|
||||
{todo.content}
|
||||
</p>
|
||||
</div>
|
||||
if (isCompleted) {
|
||||
statusIcon = (
|
||||
<div className="w-4.5 h-4.5 rounded-full bg-emerald-500 border border-emerald-600 flex items-center justify-center shrink-0 shadow-[0_0_6px_rgba(16,185,129,0.2)]">
|
||||
<Check className="w-2.5 h-2.5 text-white stroke-[3]" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
rowClass = 'hover:bg-emerald-500/[0.02]';
|
||||
titleClass = 'text-tertiary line-through font-medium';
|
||||
badgeClass =
|
||||
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
} else if (isInProgress) {
|
||||
statusIcon = (
|
||||
<div className="w-4.5 h-4.5 rounded-full bg-blueprint border border-blueprint flex items-center justify-center shrink-0 shadow-[0_0_8px_rgba(59,130,246,0.4)] animate-pulse">
|
||||
<Loader2 className="w-2.5 h-2.5 text-white animate-spin" />
|
||||
</div>
|
||||
);
|
||||
rowClass =
|
||||
'bg-blueprint/[0.02] dark:bg-blueprint/[0.04] hover:bg-blueprint/[0.04]';
|
||||
titleClass = 'text-content font-extrabold text-[11.5px]';
|
||||
badgeClass = 'bg-blueprint/20 text-blueprint font-bold';
|
||||
} else if (isBlocked) {
|
||||
statusIcon = (
|
||||
<div className="w-4.5 h-4.5 rounded-full bg-amber-500/15 border border-amber-500/30 flex items-center justify-center shrink-0">
|
||||
<Lock className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
);
|
||||
rowClass = 'opacity-50 hover:bg-amber-500/[0.01]';
|
||||
titleClass = 'text-secondary/80 font-medium';
|
||||
badgeClass = 'bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
||||
} else {
|
||||
// 普通 pending 状态
|
||||
rowClass = 'hover:bg-sunken/40';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`relative flex items-start gap-3.5 px-2 py-2.5 rounded-lg transition-all duration-150 ${rowClass}`}
|
||||
>
|
||||
{/* 时间线垂直轴线 */}
|
||||
{idx < todos.length - 1 && (
|
||||
<div
|
||||
className={`absolute left-[17px] top-[26px] bottom-[-16px] w-[1.5px] z-0 ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500/40'
|
||||
: isInProgress
|
||||
? 'bg-gradient-to-b from-blueprint to-subtle/50'
|
||||
: 'border-l border-dashed border-subtle/60'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 左侧状态点 */}
|
||||
<div className="relative z-10 flex items-center justify-center w-5 h-5 shrink-0">
|
||||
{statusIcon}
|
||||
</div>
|
||||
|
||||
{/* 右侧单行任务信息 */}
|
||||
<div className="min-w-0 flex-1 text-[11px] leading-relaxed select-text font-semibold tracking-wide">
|
||||
<span
|
||||
className={`inline-block mr-2 px-1.5 py-0.2 rounded text-[8px] font-mono tracking-wider font-extrabold uppercase leading-normal align-middle shrink-0 ${badgeClass}`}
|
||||
>
|
||||
{todo.id}
|
||||
</span>
|
||||
<span className={`${titleClass} align-middle`}>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -518,34 +554,37 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
|
||||
let statusText = '执行中';
|
||||
let progressColor = 'bg-blueprint animate-infinite-scroll';
|
||||
let statusIcon = <Cpu className="w-4 h-4 text-blueprint animate-spin-slow" />;
|
||||
let cardBg = 'bg-sky-50/20 border-sky-100';
|
||||
let cardBg =
|
||||
'bg-sky-50/20 border-transparent dark:bg-sky-500/10 dark:border-sky-500/30';
|
||||
|
||||
if (isCompleted) {
|
||||
statusText = '已完成';
|
||||
progressColor = 'bg-emerald-600';
|
||||
statusIcon = <Check className="w-4.5 h-4.5 text-white" />;
|
||||
cardBg = 'bg-emerald-50/10 border-emerald-100';
|
||||
cardBg =
|
||||
'bg-emerald-50/10 border-transparent dark:bg-emerald-500/10 dark:border-emerald-500/30';
|
||||
} else if (isFailed) {
|
||||
statusText = '执行失败';
|
||||
progressColor = 'bg-rose-600';
|
||||
statusIcon = <AlertTriangle className="w-4.5 h-4.5 text-rose-600" />;
|
||||
cardBg = 'bg-rose-50/10 border-rose-150';
|
||||
cardBg =
|
||||
'bg-rose-50/10 border-transparent dark:bg-rose-500/10 dark:border-rose-500/30';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg p-3.5 space-y-3.5 text-xs shadow-2xs select-none ${cardBg}`}
|
||||
className={`border rounded-lg p-3.5 space-y-3.5 text-xs select-none ${cardBg}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200/50 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
|
||||
<div className="flex items-center justify-between border-b border-subtle pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-content text-[11px] uppercase tracking-wide">
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-blueprint animate-spin" />
|
||||
) : (
|
||||
<Activity className="w-3.5 h-3.5 text-slate-600" />
|
||||
<Activity className="w-3.5 h-3.5 text-secondary" />
|
||||
)}
|
||||
<span>异步后台任务进度</span>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] bg-slate-200 text-slate-600 px-1.5 py-0.2 rounded font-extrabold select-all">
|
||||
<span className="font-mono text-[9px] bg-border-subtle text-secondary px-1.5 py-0.2 rounded font-extrabold select-all">
|
||||
ID: {task_id}
|
||||
</span>
|
||||
</div>
|
||||
@ -554,23 +593,23 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center border shrink-0 ${
|
||||
isRunning
|
||||
? 'bg-sky-100/60 border-sky-200'
|
||||
? 'bg-sky-100/60 border-transparent dark:bg-sky-500/15 dark:border-sky-500/30'
|
||||
: isCompleted
|
||||
? 'bg-emerald-600 border-emerald-700'
|
||||
: 'bg-rose-100 border-rose-200'
|
||||
: 'bg-rose-100 border-transparent dark:bg-rose-500/15 dark:border-rose-500/30'
|
||||
}`}
|
||||
>
|
||||
{statusIcon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-slate-500 font-semibold text-[10px]">
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-secondary font-semibold text-[10px]">
|
||||
<div>
|
||||
<span>后台工具:</span>
|
||||
<code className="text-slate-700 font-bold">{tool_name}</code>
|
||||
<code className="text-secondary font-bold">{tool_name}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span>目标文献:</span>
|
||||
<code className="text-slate-700 font-bold truncate block select-all">
|
||||
<code className="text-secondary font-bold truncate block select-all">
|
||||
{bibcode}
|
||||
</code>
|
||||
</div>
|
||||
@ -582,8 +621,8 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
|
||||
isRunning
|
||||
? 'text-blueprint'
|
||||
: isCompleted
|
||||
? 'text-emerald-700'
|
||||
: 'text-rose-700'
|
||||
? 'text-success'
|
||||
: 'text-danger'
|
||||
}`}
|
||||
>
|
||||
{statusText}
|
||||
@ -591,7 +630,7 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden relative">
|
||||
<div className="w-full h-1.5 bg-sunken rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${progressColor} ${
|
||||
isRunning ? 'w-2/3 animate-pulse' : 'w-full'
|
||||
@ -600,7 +639,7 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
|
||||
</div>
|
||||
|
||||
{isRunning && (
|
||||
<p className="text-[9px] text-slate-400 font-medium leading-relaxed">
|
||||
<p className="text-[9px] text-tertiary font-medium leading-relaxed">
|
||||
任务在后台静默运行中,完成后将自动通知您,无需在此页等待。
|
||||
</p>
|
||||
)}
|
||||
@ -655,18 +694,18 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
: 'https://vizier.cds.unistra.fr/';
|
||||
|
||||
return (
|
||||
<div className="bg-violet-50/40 border border-violet-100 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
|
||||
<div className="bg-violet-50/40 border border-transparent dark:bg-violet-500/10 dark:border-violet-500/30 rounded-lg p-3.5 space-y-3 text-xs">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between border-b border-violet-100/70 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-violet-900 text-[11px] uppercase tracking-wide">
|
||||
<TableIcon className="w-3.5 h-3.5 text-violet-600" />
|
||||
<div className="flex items-center justify-between border-b border-transparent dark:border-violet-500/30 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-violet-900 dark:text-violet-300 text-[11px] uppercase tracking-wide">
|
||||
<TableIcon className="w-3.5 h-3.5 text-violet-600 dark:text-violet-300" />
|
||||
<span>VizieR 星表查询结果</span>
|
||||
</div>
|
||||
<a
|
||||
href={cdsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-bold text-violet-600 hover:text-violet-800 flex items-center gap-0.5 hover:underline"
|
||||
className="text-[10px] font-bold text-violet-600 dark:text-violet-300 hover:opacity-90 flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<span>CDS</span>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
@ -674,19 +713,19 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-secondary">
|
||||
{table_name && (
|
||||
<span className="flex items-center gap-1 font-semibold text-violet-700">
|
||||
<span className="flex items-center gap-1 font-semibold text-violet-700 dark:text-violet-300">
|
||||
<Database className="w-3 h-3" />
|
||||
{table_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded font-medium">
|
||||
<span className="bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300 px-1.5 py-0.5 rounded font-medium">
|
||||
{row_count} 行
|
||||
</span>
|
||||
<span className="text-slate-500">{fields.length} 列</span>
|
||||
<span className="text-secondary">{fields.length} 列</span>
|
||||
{truncated && (
|
||||
<span className="flex items-center gap-0.5 text-amber-600 font-medium">
|
||||
<span className="flex items-center gap-0.5 text-amber-600 dark:text-amber-300 font-medium">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
已截断
|
||||
</span>
|
||||
@ -695,16 +734,16 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
|
||||
{/* 表格 */}
|
||||
{fields.length === 0 || rows.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-400 italic py-2">(无数据行)</p>
|
||||
<p className="text-[11px] text-tertiary italic py-2">(无数据行)</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-72 overflow-y-auto border border-slate-200 rounded">
|
||||
<div className="overflow-x-auto max-h-72 overflow-y-auto rounded">
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-100 z-10">
|
||||
<thead className="sticky top-0 bg-sunken z-10">
|
||||
<tr>
|
||||
{fields.map((f, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="px-2 py-1.5 text-left font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap"
|
||||
className="px-2 py-1.5 text-left font-bold text-secondary border-b border-subtle whitespace-nowrap"
|
||||
title={
|
||||
f.description || f.unit
|
||||
? `${f.description || ''} ${f.unit ? `[${f.unit}]` : ''}`.trim()
|
||||
@ -713,7 +752,7 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
>
|
||||
{f.name}
|
||||
{f.unit && (
|
||||
<span className="text-slate-400 font-normal ml-1">
|
||||
<span className="text-tertiary font-normal ml-1">
|
||||
[{f.unit}]
|
||||
</span>
|
||||
)}
|
||||
@ -725,12 +764,12 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
{displayRows.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
className={ri % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}
|
||||
className={ri % 2 === 0 ? 'bg-surface' : 'bg-sunken/50'}
|
||||
>
|
||||
{fields.map((_, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className="px-2 py-1 text-slate-700 border-b border-slate-100 whitespace-nowrap font-mono"
|
||||
className="px-2 py-1 text-secondary border-b border-subtle whitespace-nowrap font-mono"
|
||||
>
|
||||
{renderCell(row[ci])}
|
||||
</td>
|
||||
@ -744,7 +783,7 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
|
||||
{/* 底部提示 */}
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-[10px] text-slate-400">
|
||||
<p className="text-[10px] text-tertiary">
|
||||
已展示前 {MAX_DISPLAY_ROWS} 行,{hiddenCount} 行已折叠
|
||||
</p>
|
||||
)}
|
||||
@ -754,11 +793,11 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
|
||||
// ==========================================
|
||||
// 6. 统一观测数据下载结果卡片 (find_observation)
|
||||
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
|
||||
// 支持多 artifact 产物(如 Gaia 光变 G/BP/RP 三波段各一个文件)
|
||||
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
|
||||
// 支持多 artifact 产物(如 Gaia 光变 G/BP/RP 三波段各一个文件)
|
||||
//
|
||||
// 实现已抽出到共享组件 ObservationResultCard,本函数仅作为 Agent 工具结果
|
||||
// 渲染入口(metadata 形状与 ObservationBatchResult 完全一致)。
|
||||
// 实现已抽出到共享组件 ObservationResultCard,本函数仅作为 Agent 工具结果
|
||||
// 渲染入口(metadata 形状与 ObservationBatchResult 完全一致)。
|
||||
// ==========================================
|
||||
import { ObservationResultCard } from '../observation/ObservationResultCard';
|
||||
import type { ObservationBatchResult } from '../observation/constants';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// dashboard/src/components/agent/SubAgentContainer.tsx
|
||||
// 子代理容器:展示子代理的执行过程(可折叠,含嵌套时间线)
|
||||
import { Network, CheckCircle2, Loader } from 'lucide-react';
|
||||
import { Network, Loader } from 'lucide-react';
|
||||
import type { TimelineItem } from './types';
|
||||
import { SUBAGENT_COLORS } from './constants';
|
||||
import { ThoughtCard } from './ThoughtCard';
|
||||
@ -26,7 +26,7 @@ interface SubAgentContainerProps {
|
||||
export function SubAgentContainer({
|
||||
status,
|
||||
children,
|
||||
summary,
|
||||
summary: _summary,
|
||||
isStreaming: parentStreaming,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
@ -42,84 +42,80 @@ export function SubAgentContainer({
|
||||
const toolCount = children.filter((c) => c.type === 'tool_call').length;
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2 select-none">
|
||||
<div className="relative space-y-1.5 select-none w-full">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${
|
||||
parentStreaming ? 'bg-violet-500' : 'bg-violet-400'
|
||||
parentStreaming ? 'bg-amber-500' : 'bg-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<div className="border border-violet-200 bg-violet-50/30 rounded-md shadow-2xs overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 cursor-pointer hover:bg-violet-50/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network
|
||||
className={`w-4 h-4 ${
|
||||
parentStreaming ? 'text-violet-500' : 'text-violet-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[10px] font-extrabold text-violet-700 tracking-wider uppercase">
|
||||
{parentStreaming ? '子代理执行中...' : '子代理完成'}
|
||||
</span>
|
||||
{isComplete && (
|
||||
<span className="text-[9px] font-bold text-violet-500 ml-1 flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{stepCount} 步骤 · {toolCount} 工具
|
||||
</span>
|
||||
)}
|
||||
{parentStreaming && (
|
||||
<Loader className="w-3 h-3 text-violet-500 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-violet-600 hover:text-violet-800 shrink-0">
|
||||
{isCollapsed ? '展开' : '收起'}
|
||||
{/* Capsule Header (w-fit) - 控制台按钮体系 */}
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className={`rounded-full px-3.5 py-1.5 w-fit cursor-pointer flex items-center gap-2 border select-none transition-all duration-200 ${
|
||||
!isCollapsed
|
||||
? 'bg-amber-500/5 border-amber-500 shadow-sm'
|
||||
: 'bg-surface border-subtle shadow-sm hover:bg-sunken hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
<Network
|
||||
className={`w-3.5 h-3.5 ${
|
||||
parentStreaming ? 'text-amber-500 animate-pulse' : 'text-amber-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[10px] font-extrabold text-amber-700 dark:text-amber-300 tracking-wider uppercase">
|
||||
{parentStreaming ? '子代理执行中...' : '子代理完成'}
|
||||
</span>
|
||||
{isComplete && (
|
||||
<span className="text-[8px] text-tertiary font-bold ml-1.5 bg-sunken px-1.5 py-0.5 rounded-full border border-subtle">
|
||||
{stepCount} 步骤 · {toolCount} 工具
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 折叠态摘要 */}
|
||||
{isCollapsed && summary && (
|
||||
<div className="px-3.5 pb-3 border-t border-violet-100/50">
|
||||
<p className="text-[11px] text-violet-600 font-medium leading-relaxed line-clamp-2 mt-2">
|
||||
{summary.length > 200 ? summary.slice(0, 200) + '...' : summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isCollapsed && !summary && parentStreaming && (
|
||||
<div className="px-3.5 pb-3 border-t border-violet-100/50">
|
||||
<p className="text-[11px] text-violet-400 italic font-medium mt-2">
|
||||
子代理正在收集信息...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 展开态:嵌套时间线 */}
|
||||
{/* 展开的详情:向右缩进的单独子容器 (CSS Grid 折叠动效) */}
|
||||
<div
|
||||
className={`grid transition-all duration-300 ease-in-out ml-8 relative ${
|
||||
!isCollapsed
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{/* L型树形连接线 (L-shaped Connector Line) - 增强对比,向右偏移 */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-violet-100/50 px-3.5 py-3">
|
||||
<div className="absolute -left-4 -top-1.5 w-4 h-4.5 border-l border-b border-dashed border-tertiary/60 rounded-bl-md pointer-events-none" />
|
||||
)}
|
||||
<div className="overflow-hidden">
|
||||
<div className="console-panel rounded-lg p-3.5 space-y-3.5 border-l-2 border-l-amber-500/50 shadow-inner">
|
||||
{/* 子代理正在初始化步骤 */}
|
||||
{children.length === 0 && parentStreaming && (
|
||||
<div className="flex items-center gap-2 text-violet-400 py-2">
|
||||
<Loader className="w-3 h-3 animate-spin" />
|
||||
<span className="text-[10px] font-bold">子代理初始化中...</span>
|
||||
<div className="flex items-center gap-2 text-amber-500/70 py-1.5 animate-in fade-in duration-200">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
<span className="text-[10px] font-bold">
|
||||
子代理正在规划执行步骤...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 嵌套时间线渲染 */}
|
||||
{children.length > 0 && (
|
||||
<div className="pl-3.5 border-l border-dashed border-amber-500/20 space-y-3.5 nested-timeline-wrapper">
|
||||
{children.map((child, childIdx) =>
|
||||
renderNestedTimelineItem(
|
||||
child,
|
||||
childIdx,
|
||||
parentStreaming,
|
||||
expandedThoughts,
|
||||
expandedArgs,
|
||||
expandedResults,
|
||||
onToggleThought,
|
||||
onToggleArgs,
|
||||
onToggleResult
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="pl-4 border-l border-violet-100 space-y-3">
|
||||
{children.map((child, childIdx) =>
|
||||
renderNestedTimelineItem(
|
||||
child,
|
||||
childIdx,
|
||||
parentStreaming,
|
||||
expandedThoughts,
|
||||
expandedArgs,
|
||||
expandedResults,
|
||||
onToggleThought,
|
||||
onToggleArgs,
|
||||
onToggleResult
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -153,12 +149,13 @@ function renderNestedTimelineItem(
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
const tcId = item.id;
|
||||
const tcId = item.id || `sub-tc-${item.step}-${idx}`;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={`tc-${tcId}-${idx}`}
|
||||
step={item.step}
|
||||
name={item.name}
|
||||
display_name={item.display_name}
|
||||
arguments={item.arguments}
|
||||
result={item.result}
|
||||
isStreaming={isStreaming}
|
||||
|
||||
@ -13,63 +13,55 @@ interface ThoughtCardProps {
|
||||
}
|
||||
|
||||
export function ThoughtCard({
|
||||
step,
|
||||
content,
|
||||
isStreaming,
|
||||
colorScheme,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: ThoughtCardProps) {
|
||||
const preview = content.length > 120 ? content.slice(0, 120) + '…' : content;
|
||||
const dotColor = isStreaming
|
||||
? colorScheme.thought.active
|
||||
: colorScheme.thought.idle;
|
||||
|
||||
// 从 colorScheme 推导包裹色(用于容器背景)
|
||||
const isSub = colorScheme.thought.idle.includes('violet');
|
||||
const bgClass = isSub
|
||||
? 'bg-violet-50/40 border border-violet-100/50'
|
||||
: 'bg-purple-50/40 border border-purple-100/50';
|
||||
const textClass = isSub ? 'text-violet-600' : 'text-purple-600';
|
||||
const previewClass = isSub ? 'text-violet-400' : 'text-purple-400';
|
||||
const hoverClass = isSub
|
||||
? 'text-violet-500 hover:text-violet-700'
|
||||
: 'text-purple-500 hover:text-purple-700';
|
||||
const mutedClass = isSub ? 'text-violet-400' : 'text-purple-400';
|
||||
// 统一使用高级学术浅灰色(Stone Gray)风格
|
||||
const bgClass =
|
||||
'bg-stone-100/50 border border-transparent dark:bg-stone-900/30 dark:border-stone-800/30';
|
||||
const textClass = 'text-stone-600 dark:text-stone-300';
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest('a') ||
|
||||
target.closest('button') ||
|
||||
target.closest('input') ||
|
||||
target.closest('.thought-content')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2 select-none">
|
||||
<div className="relative space-y-2 select-none w-full">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
|
||||
/>
|
||||
<div className={`space-y-1.5 rounded-md p-3 select-text ${bgClass}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between text-left cursor-pointer"
|
||||
>
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={`space-y-1.5 rounded-md p-3 select-text cursor-pointer ${bgClass}`}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between text-left">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[10px] font-extrabold tracking-wider uppercase ${textClass}`}
|
||||
>
|
||||
<Brain className="w-3.5 h-3.5" />
|
||||
<span>分析与推理 (Thought)</span>
|
||||
<span className={`text-[9px] font-mono ${mutedClass}`}>
|
||||
· Step {step}
|
||||
</span>
|
||||
<span>分析与推理</span>
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold shrink-0 ${hoverClass}`}>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<p className="text-slate-700 font-medium leading-relaxed font-sans whitespace-pre-wrap text-xs">
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<p className="text-secondary font-medium leading-relaxed font-sans whitespace-pre-wrap text-xs thought-content">
|
||||
{content}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className={`text-[11px] italic font-medium leading-relaxed ${previewClass}`}
|
||||
>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
// dashboard/src/components/agent/ToolCallCard.tsx
|
||||
// 工具调用卡片:展示参数和结果,皆可独立折叠
|
||||
import { Settings, Eye } from 'lucide-react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import type { ColorScheme } from './constants';
|
||||
import { getToolDisplayName } from './toolDisplayNames';
|
||||
import { AgentMarkdown } from './AgentMarkdown';
|
||||
import { useAutoScroll } from './useAutoScroll';
|
||||
import {
|
||||
@ -21,6 +20,7 @@ import type { TabId } from '../layout/Sidebar';
|
||||
interface ToolCallCardProps {
|
||||
step: number;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: {
|
||||
output: string;
|
||||
@ -40,8 +40,8 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
export function ToolCallCard({
|
||||
step,
|
||||
name,
|
||||
display_name,
|
||||
arguments: args,
|
||||
result,
|
||||
isStreaming,
|
||||
@ -271,80 +271,148 @@ export function ToolCallCard({
|
||||
|
||||
const specialCard = renderSpecialCard();
|
||||
|
||||
const isExpanded = isArgsExpanded || isResultExpanded;
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (isExpanded) {
|
||||
if (isArgsExpanded) onToggleArgs();
|
||||
if (isResultExpanded) onToggleResult();
|
||||
} else {
|
||||
if (hasResult) {
|
||||
onToggleResult();
|
||||
} else {
|
||||
onToggleArgs();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tab: 'args' | 'result') => {
|
||||
if (tab === 'args') {
|
||||
if (!isArgsExpanded) onToggleArgs();
|
||||
if (isResultExpanded) onToggleResult();
|
||||
} else {
|
||||
if (!isResultExpanded) onToggleResult();
|
||||
if (isArgsExpanded) onToggleArgs();
|
||||
}
|
||||
};
|
||||
|
||||
const detailBorderColor = isArgsExpanded
|
||||
? 'border-l-blueprint/60'
|
||||
: result?.isError
|
||||
? 'border-l-danger/60'
|
||||
: 'border-l-success/60';
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2 select-none w-full">
|
||||
<div className="relative space-y-1.5 select-none w-full">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
|
||||
/>
|
||||
<div className="space-y-2 border border-slate-100 bg-white rounded-md p-3 shadow-2xs w-full overflow-hidden">
|
||||
{/* 工具名称 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
|
||||
<Settings
|
||||
className={`w-3.5 h-3.5 ${hasResult ? 'text-slate-500' : 'text-sky-500 animate-spin'}`}
|
||||
/>
|
||||
<span>调用工具: {getToolDisplayName(name)}</span>
|
||||
<span className="text-[9px] text-slate-400 font-mono font-medium">
|
||||
· Step {step}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleArgs}
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
{isArgsExpanded ? '收起参数' : '展开参数'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Capsule Header (w-fit) - 控制台按钮体系 */}
|
||||
<div
|
||||
onClick={handleHeaderClick}
|
||||
className={`rounded-full px-3.5 py-1.5 w-fit cursor-pointer flex items-center gap-2 border select-none transition-all duration-200 ${
|
||||
isExpanded
|
||||
? 'bg-blueprint/5 border-blueprint shadow-sm'
|
||||
: 'bg-surface border-subtle shadow-sm hover:bg-sunken hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
<Settings
|
||||
className={`w-3.5 h-3.5 ${hasResult ? 'text-secondary' : 'text-blueprint animate-spin'}`}
|
||||
/>
|
||||
<span className="text-[10px] font-extrabold text-blueprint tracking-wider uppercase">
|
||||
调用工具: {display_name || name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 参数 */}
|
||||
{isArgsExpanded && (
|
||||
<div className="font-mono text-[10px] bg-slate-50 border border-slate-200 rounded-md p-2 text-slate-650 overflow-x-auto whitespace-pre-wrap max-w-full leading-relaxed select-text">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</div>
|
||||
{/* 展开的详情:向右缩进的单独子容器 (CSS Grid 折叠动效) */}
|
||||
<div
|
||||
className={`grid transition-all duration-300 ease-in-out ml-8 relative ${
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{/* L型树形连接线 (L-shaped Connector Line) - 增强对比,向右偏移 */}
|
||||
{isExpanded && (
|
||||
<div className="absolute -left-4 -top-1.5 w-4 h-4.5 border-l border-b border-dashed border-tertiary/60 rounded-bl-md pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* 结果/特殊卡片 */}
|
||||
{specialCard ? (
|
||||
<div className="border-t border-slate-100 pt-2.5 mt-2.5 w-full overflow-hidden">
|
||||
{specialCard}
|
||||
</div>
|
||||
) : (
|
||||
hasResult && (
|
||||
<div className="space-y-1.5 border-t border-slate-100 pt-2.5 mt-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-emerald-700 tracking-wider uppercase">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>观测与反馈</span>
|
||||
{result!.isError && (
|
||||
<span className="px-1.5 py-0.2 rounded bg-rose-50 text-rose-700 border border-rose-100 text-[9px]">
|
||||
错误返回
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={`console-panel rounded-lg p-3.5 space-y-3.5 border-l-2 ${detailBorderColor} shadow-inner`}
|
||||
>
|
||||
{/* 标签栏 (Tabs) - 极简拟物分段控制器 */}
|
||||
<div className="flex bg-sunken border border-subtle rounded-full p-0.5 w-fit gap-0.5 select-none shadow-inner">
|
||||
<button
|
||||
onClick={() => handleTabClick('args')}
|
||||
className={`px-3 py-1 text-[9px] font-bold rounded-full transition-all duration-200 cursor-pointer select-none flex items-center gap-1.5 ${
|
||||
isArgsExpanded
|
||||
? 'bg-surface text-content shadow-sm border border-subtle'
|
||||
: 'border border-transparent text-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blueprint/60" />
|
||||
<span>传入参数</span>
|
||||
</button>
|
||||
{hasResult && (
|
||||
<button
|
||||
onClick={onToggleResult}
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer flex items-center gap-0.5"
|
||||
onClick={() => handleTabClick('result')}
|
||||
className={`px-3 py-1 text-[9px] font-bold rounded-full transition-all duration-200 cursor-pointer select-none flex items-center gap-1.5 ${
|
||||
isResultExpanded
|
||||
? 'bg-surface text-content shadow-sm border border-subtle'
|
||||
: 'border border-transparent text-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{isResultExpanded ? '收起结果' : '展开结果'}
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
result!.isError
|
||||
? 'bg-danger/80 animate-pulse'
|
||||
: 'bg-success/60'
|
||||
}`}
|
||||
/>
|
||||
<span>返回结果</span>
|
||||
</button>
|
||||
</div>
|
||||
{isResultExpanded ? (
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统一内容区 (虚线分隔,平铺展示,带滚动条占位稳定器) */}
|
||||
<div className="mt-2.5 pt-2.5 border-t border-dashed border-subtle">
|
||||
{/* 传入参数内容 */}
|
||||
{isArgsExpanded && (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2.5 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"
|
||||
key="args-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-mono text-[9px] bg-sunken border-l-2 border-blueprint/30 rounded-r-md p-2.5 text-secondary overflow-x-auto whitespace-pre-wrap max-w-full leading-relaxed select-text animate-in fade-in duration-200 [scrollbar-gutter:stable]"
|
||||
>
|
||||
<AgentMarkdown>{result!.output}</AgentMarkdown>
|
||||
<div ref={chatEndRef} />
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-slate-400 italic font-medium">
|
||||
结果已截断 (共 {result!.output.length} 字符)。点击右侧展开。
|
||||
)}
|
||||
|
||||
{/* 返回结果内容 */}
|
||||
{isResultExpanded && (
|
||||
<div
|
||||
key="result-content"
|
||||
className="animate-in fade-in duration-200"
|
||||
>
|
||||
{specialCard ? (
|
||||
<div className="w-full overflow-hidden special-card-wrap">
|
||||
{specialCard}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-secondary overflow-auto select-text leading-relaxed max-h-[60vh] prose prose-sm max-w-none prose-headings:text-content prose-code:text-blueprint prose-pre:bg-sunken prose-pre:text-xs prose-img:rounded-lg [scrollbar-gutter:stable]"
|
||||
>
|
||||
<AgentMarkdown>{result!.output}</AgentMarkdown>
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -40,13 +40,13 @@ export interface ColorScheme {
|
||||
}
|
||||
|
||||
export const PARENT_COLORS: ColorScheme = {
|
||||
thought: { active: 'bg-slate-400', idle: 'bg-slate-300' },
|
||||
tool_call: { active: 'bg-blueprint', idle: 'bg-slate-300' },
|
||||
answer: 'bg-slate-550',
|
||||
thought: { active: 'bg-secondary', idle: 'bg-tertiary' },
|
||||
tool_call: { active: 'bg-blueprint', idle: 'bg-tertiary' },
|
||||
answer: 'bg-success',
|
||||
};
|
||||
|
||||
export const SUBAGENT_COLORS: ColorScheme = {
|
||||
thought: { active: 'bg-slate-500', idle: 'bg-slate-400' },
|
||||
tool_call: { active: 'bg-blueprint', idle: 'bg-slate-350' },
|
||||
answer: 'bg-slate-450',
|
||||
thought: { active: 'bg-amber-500', idle: 'bg-amber-400' },
|
||||
tool_call: { active: 'bg-blueprint', idle: 'bg-tertiary' },
|
||||
answer: 'bg-success',
|
||||
};
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
// Agent 渲染共享模块 barrel export
|
||||
export { safeSchema, PARENT_COLORS, SUBAGENT_COLORS } from './constants';
|
||||
export type { ColorScheme } from './constants';
|
||||
export { getToolDisplayName } from './toolDisplayNames';
|
||||
export { getToolMeta, setGlobalTools } from './toolDisplayNames';
|
||||
export type { AgentToolMeta } from './toolDisplayNames';
|
||||
export { AgentMarkdown } from './AgentMarkdown';
|
||||
export { ThoughtCard } from './ThoughtCard';
|
||||
export { ToolCallCard } from './ToolCallCard';
|
||||
|
||||
@ -84,7 +84,9 @@ export function routeToolCall(
|
||||
step: number,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
args: Record<string, unknown>,
|
||||
is_internal?: boolean,
|
||||
display_name?: string
|
||||
): TimelineItem[] {
|
||||
// 创建 subagent_container
|
||||
if (isSubagentContainerCall(name)) {
|
||||
@ -120,17 +122,35 @@ export function routeToolCall(
|
||||
step,
|
||||
id,
|
||||
name: cleanName,
|
||||
display_name,
|
||||
arguments: args,
|
||||
is_internal,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
// 无活跃容器 → fallback
|
||||
return upsertParentToolCall(timeline, step, id, cleanName, args);
|
||||
return upsertParentToolCall(
|
||||
timeline,
|
||||
step,
|
||||
id,
|
||||
cleanName,
|
||||
args,
|
||||
is_internal,
|
||||
display_name
|
||||
);
|
||||
}
|
||||
|
||||
// 父代理工具调用
|
||||
return upsertParentToolCall(timeline, step, id, name, args);
|
||||
return upsertParentToolCall(
|
||||
timeline,
|
||||
step,
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
is_internal,
|
||||
display_name
|
||||
);
|
||||
}
|
||||
|
||||
// 路由 tool_result 事件
|
||||
@ -140,7 +160,9 @@ export function routeToolResult(
|
||||
name: string,
|
||||
output: string,
|
||||
isError: boolean,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
is_internal?: boolean,
|
||||
display_name?: string
|
||||
): TimelineItem[] {
|
||||
// 匹配 subagent_container id → 完成容器
|
||||
const saCompleteIdx = timeline.findIndex(
|
||||
@ -167,7 +189,12 @@ export function routeToolResult(
|
||||
return updateContainerChild(timeline, saIdx, (children) =>
|
||||
children.map((c) => {
|
||||
if (c.type === 'tool_call' && c.id === toolCallId && !c.result) {
|
||||
return { ...c, result: { output, isError, metadata } };
|
||||
return {
|
||||
...c,
|
||||
is_internal: is_internal ?? c.is_internal,
|
||||
display_name: display_name ?? c.display_name,
|
||||
result: { output, isError, metadata },
|
||||
};
|
||||
}
|
||||
return c;
|
||||
})
|
||||
@ -178,7 +205,12 @@ export function routeToolResult(
|
||||
// 父代理工具结果
|
||||
return timeline.map((t) => {
|
||||
if (t.type === 'tool_call' && t.id === toolCallId) {
|
||||
return { ...t, result: { output, isError, metadata } };
|
||||
return {
|
||||
...t,
|
||||
is_internal: is_internal ?? t.is_internal,
|
||||
display_name: display_name ?? t.display_name,
|
||||
result: { output, isError, metadata },
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
@ -231,7 +263,9 @@ function upsertParentToolCall(
|
||||
step: number,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
args: Record<string, unknown>,
|
||||
is_internal?: boolean,
|
||||
display_name?: string
|
||||
): TimelineItem[] {
|
||||
const existing = timeline.find(
|
||||
(t) => t.type === 'tool_call' && 'id' in t && t.id === id
|
||||
@ -239,7 +273,15 @@ function upsertParentToolCall(
|
||||
if (existing) return timeline;
|
||||
return [
|
||||
...timeline,
|
||||
{ type: 'tool_call' as const, step, id, name, arguments: args },
|
||||
{
|
||||
type: 'tool_call' as const,
|
||||
step,
|
||||
id,
|
||||
name,
|
||||
display_name,
|
||||
arguments: args,
|
||||
is_internal,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -1,87 +1,37 @@
|
||||
// dashboard/src/components/agent/toolDisplayNames.ts
|
||||
// 工具名称 → 人类可读中文名称(所有面板共用)
|
||||
// 共享工具元数据客户端缓存
|
||||
|
||||
export function getToolDisplayName(name: string): string {
|
||||
switch (name) {
|
||||
// 文件系统工具
|
||||
case 'read_file':
|
||||
return '读取文件内容';
|
||||
case 'grep_files':
|
||||
return '正则搜索文件';
|
||||
case 'glob_files':
|
||||
return '通配符匹配文件';
|
||||
case 'run_bash':
|
||||
return '执行 Shell 命令';
|
||||
case 'file_write':
|
||||
return '写入文件';
|
||||
case 'file_edit':
|
||||
return '精确编辑文件';
|
||||
// 天文科研工具
|
||||
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 '检索馆藏知识库';
|
||||
case 'query_target':
|
||||
return '查询天体物理参数 (CDS)';
|
||||
case 'query_vizier':
|
||||
return '查询 VizieR 星表';
|
||||
case 'cone_search':
|
||||
return '锥形检索天体';
|
||||
case 'find_observation':
|
||||
return '下载观测数据';
|
||||
case 'catalog_operation':
|
||||
return '星表操作';
|
||||
case 'save_note':
|
||||
return '保存文献手札';
|
||||
// Agent 控制工具
|
||||
case 'todo_write':
|
||||
return '管理任务列表';
|
||||
case 'compress_context':
|
||||
return '压缩上下文窗口';
|
||||
case 'load_skill':
|
||||
return '加载专家技能';
|
||||
case 'subagent':
|
||||
return '派发子代理';
|
||||
case 'delegate_research':
|
||||
return '派发子代理';
|
||||
case 'ask_user':
|
||||
return '向用户提问';
|
||||
// 记忆系统
|
||||
case 'save_memory':
|
||||
return '保存项目记忆';
|
||||
case 'load_memory':
|
||||
return '读取项目记忆';
|
||||
// 图片分析
|
||||
case 'analyze_image':
|
||||
return '分析图像内容';
|
||||
// 后台任务
|
||||
case 'bg_task_run':
|
||||
return '启动后台任务';
|
||||
case 'bg_task_check':
|
||||
return '检查后台任务状态';
|
||||
// 团队协作
|
||||
case 'spawn_teammate':
|
||||
return '创建团队成员';
|
||||
case 'send_teammate_message':
|
||||
return '发送团队成员消息';
|
||||
case 'team_broadcast':
|
||||
return '团队广播消息';
|
||||
case 'check_team_inbox':
|
||||
return '检查团队收件箱';
|
||||
default: {
|
||||
// 子代理工具名带 [sub] 前缀
|
||||
if (name.startsWith('[sub] ')) {
|
||||
return `子代理: ${getToolDisplayName(name.slice(6))}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
export interface AgentToolMeta {
|
||||
name: string;
|
||||
display_name: string;
|
||||
is_internal: boolean;
|
||||
}
|
||||
|
||||
let globalToolsCache: Record<string, AgentToolMeta> = {};
|
||||
|
||||
export function setGlobalTools(tools: AgentToolMeta[]) {
|
||||
const map: Record<string, AgentToolMeta> = {};
|
||||
for (const t of tools) {
|
||||
map[t.name] = t;
|
||||
}
|
||||
globalToolsCache = map;
|
||||
}
|
||||
|
||||
export function getToolMeta(name: string): AgentToolMeta | undefined {
|
||||
if (name.startsWith('[sub] ')) {
|
||||
const inner = getToolMeta(name.slice(6));
|
||||
if (inner) {
|
||||
return {
|
||||
name,
|
||||
display_name: `子代理: ${inner.display_name}`,
|
||||
is_internal: inner.is_internal,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
is_internal: false,
|
||||
};
|
||||
}
|
||||
return globalToolsCache[name];
|
||||
}
|
||||
|
||||
@ -9,7 +9,9 @@ export type TimelineItem =
|
||||
step: number;
|
||||
id: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
arguments: Record<string, unknown>;
|
||||
is_internal?: boolean;
|
||||
result?: {
|
||||
output: string;
|
||||
isError: boolean;
|
||||
@ -33,7 +35,9 @@ export interface SSEEventHandlers {
|
||||
step: number,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
args: Record<string, unknown>,
|
||||
is_internal?: boolean,
|
||||
display_name?: string
|
||||
) => void;
|
||||
onToolResult?: (
|
||||
step: number,
|
||||
@ -41,7 +45,9 @@ export interface SSEEventHandlers {
|
||||
name: string,
|
||||
output: string,
|
||||
isError: boolean,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
is_internal?: boolean,
|
||||
display_name?: string
|
||||
) => void;
|
||||
onTextDelta?: (content: string, toolCallId?: string) => void;
|
||||
onUsage?: (usage: {
|
||||
|
||||
@ -100,7 +100,9 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
||||
event.step,
|
||||
event.id,
|
||||
event.name,
|
||||
event.arguments
|
||||
event.arguments,
|
||||
event.is_internal,
|
||||
event.display_name
|
||||
);
|
||||
break;
|
||||
case 'tool_result':
|
||||
@ -110,7 +112,9 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
||||
event.name,
|
||||
event.output,
|
||||
event.is_error,
|
||||
event.metadata
|
||||
event.metadata,
|
||||
event.is_internal,
|
||||
event.display_name
|
||||
);
|
||||
break;
|
||||
case 'text_delta':
|
||||
|
||||
@ -31,7 +31,7 @@ export function BaseModal({
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div
|
||||
@ -39,7 +39,7 @@ export function BaseModal({
|
||||
className={`bg-card rounded-lg border border-precision shadow-sm w-full p-6 space-y-4 cursor-default animate-fade-in mx-4 ${sizeClasses[size]}`}
|
||||
>
|
||||
{/* 头部栏 */}
|
||||
<div className="flex items-start justify-between border-b border-slate-150 pb-2.5 shrink-0">
|
||||
<div className="flex items-start justify-between border-b border-subtle pb-2.5 shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{typeof title === 'string' ? (
|
||||
<h3 className="text-xs font-bold text-main flex items-center gap-1.5 pr-4">
|
||||
@ -56,7 +56,7 @@ export function BaseModal({
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
|
||||
className="text-tertiary hover:text-secondary p-1 rounded-md hover:bg-sunken transition-colors shrink-0 cursor-pointer"
|
||||
title="关闭"
|
||||
>
|
||||
<svg
|
||||
|
||||
@ -71,7 +71,7 @@ export function PaperDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 期刊 & 年份 */}
|
||||
<div className="grid grid-cols-5 gap-4 border-y border-slate-150 py-2.5 shrink-0 h-14">
|
||||
<div className="grid grid-cols-5 gap-4 border-y border-subtle py-2.5 shrink-0 h-14">
|
||||
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
|
||||
<span className="text-muted font-bold block text-[10px] leading-tight">
|
||||
发表期刊
|
||||
@ -96,7 +96,7 @@ export function PaperDetailModal({
|
||||
{/* 摘要 */}
|
||||
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
|
||||
<span className="text-muted font-bold block">摘要</span>
|
||||
<p className="text-main leading-relaxed font-normal bg-slate-50 p-3 flex-1 rounded-md border border-precision text-justify overflow-y-auto scrollbar-thin select-text">
|
||||
<p className="text-main leading-relaxed font-normal bg-sunken p-3 flex-1 rounded-md border border-precision text-justify overflow-y-auto scrollbar-thin select-text">
|
||||
{paper.abstract_text || '该文献暂无摘要数据。'}
|
||||
</p>
|
||||
</div>
|
||||
@ -109,22 +109,22 @@ export function PaperDetailModal({
|
||||
{paper.keywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="px-2 py-0.5 rounded bg-slate-100 border border-precision text-muted font-bold text-[9px]"
|
||||
className="px-2 py-0.5 rounded bg-sunken border border-precision text-muted font-bold text-[9px]"
|
||||
>
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center flex-1 bg-slate-50 border border-precision rounded-md text-[10px] text-muted font-bold select-none">
|
||||
<div className="flex items-center justify-center flex-1 bg-sunken border border-precision rounded-md text-[10px] text-muted font-bold select-none">
|
||||
暂无关键词
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标识符 */}
|
||||
<div className="border-t border-slate-155 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<div className="border-t border-subtle pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
|
||||
<div className="bg-sunken px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">BIBCODE</span>
|
||||
<span
|
||||
className="text-main font-semibold select-all truncate block"
|
||||
@ -149,7 +149,7 @@ export function PaperDetailModal({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<div className="bg-sunken px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">DOI</span>
|
||||
<span
|
||||
className="text-main font-semibold select-all truncate block"
|
||||
@ -174,7 +174,7 @@ export function PaperDetailModal({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<div className="bg-sunken px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">ARXIV ID</span>
|
||||
<span
|
||||
className="text-main font-semibold select-all truncate block"
|
||||
@ -202,10 +202,10 @@ export function PaperDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 手动上传文件(应对防爬阻断) */}
|
||||
<div className="border-t border-slate-150 pt-3 space-y-2">
|
||||
<div className="border-t border-subtle pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted font-bold block">手动离线上传文献</span>
|
||||
<span className="text-[9px] text-amber-800 font-bold bg-amber-50 px-2 py-0.5 rounded-md border border-amber-200/60">
|
||||
<span className="text-[9px] text-amber-800 dark:text-amber-300 font-bold bg-amber-50 dark:bg-amber-500/10 px-2 py-0.5 rounded-md border border-amber-200/60 dark:border-amber-500/30">
|
||||
防爬/人机验证备用
|
||||
</span>
|
||||
</div>
|
||||
@ -214,7 +214,7 @@ export function PaperDetailModal({
|
||||
后在此处上传覆盖。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
|
||||
<div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-sunken transition-colors relative cursor-pointer group min-h-[50px]">
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
@ -226,7 +226,7 @@ export function PaperDetailModal({
|
||||
disabled={uploadingBibcode === paper.bibcode}
|
||||
/>
|
||||
{paper.has_pdf && (
|
||||
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
||||
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200/60 dark:border-emerald-500/30 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
||||
已下载
|
||||
</span>
|
||||
)}
|
||||
@ -238,7 +238,7 @@ export function PaperDetailModal({
|
||||
<span className="text-[8px] text-muted">支持 .pdf 格式</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
|
||||
<div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-sunken transition-colors relative cursor-pointer group min-h-[50px]">
|
||||
<input
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@ -250,7 +250,7 @@ export function PaperDetailModal({
|
||||
disabled={uploadingBibcode === paper.bibcode}
|
||||
/>
|
||||
{paper.has_html && (
|
||||
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
||||
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200/60 dark:border-emerald-500/30 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
||||
已下载
|
||||
</span>
|
||||
)}
|
||||
@ -292,17 +292,17 @@ export function PaperDetailModal({
|
||||
{/* 自动下载失败诊断 */}
|
||||
{!paper.is_downloaded && (paper.pdf_error || paper.html_error) && (
|
||||
<div
|
||||
className={`rounded-md p-3 text-[10px] space-y-1 border border-slate-200 border-l-4 ${
|
||||
className={`rounded-md p-3 text-[10px] space-y-1 border-l-4 ${
|
||||
paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource'
|
||||
? 'bg-slate-50 border-l-amber-500 text-slate-800'
|
||||
: 'bg-slate-50 border-l-rose-500 text-slate-800'
|
||||
? 'bg-sunken border-l-amber-500 text-content'
|
||||
: 'bg-sunken border-l-rose-500 text-content'
|
||||
}`}
|
||||
>
|
||||
{paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource' ? (
|
||||
<div className="leading-relaxed">
|
||||
<div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px] mb-1">
|
||||
<div className="font-bold flex items-center gap-1.5 text-content text-[11px] mb-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
已手动标记为【无有效全文资源】
|
||||
</div>
|
||||
@ -310,13 +310,13 @@ export function PaperDetailModal({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px]">
|
||||
<div className="font-bold flex items-center gap-1.5 text-content text-[11px]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-600" />
|
||||
自动下载失败诊断信息:
|
||||
</div>
|
||||
{paper.pdf_error && (
|
||||
<div className="leading-relaxed">
|
||||
<span className="font-semibold text-slate-600">
|
||||
<span className="font-semibold text-secondary">
|
||||
PDF 错误:{' '}
|
||||
</span>
|
||||
{paper.pdf_error}
|
||||
@ -324,7 +324,7 @@ export function PaperDetailModal({
|
||||
)}
|
||||
{paper.html_error && (
|
||||
<div className="leading-relaxed mt-0.5">
|
||||
<span className="font-semibold text-slate-600">
|
||||
<span className="font-semibold text-secondary">
|
||||
HTML 错误:{' '}
|
||||
</span>
|
||||
{paper.html_error}
|
||||
@ -337,7 +337,7 @@ export function PaperDetailModal({
|
||||
</div>
|
||||
|
||||
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
|
||||
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-150">
|
||||
<div className="flex flex-wrap gap-2 pt-3 border-t border-subtle">
|
||||
{paper.is_downloaded ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
@ -26,7 +26,7 @@ export function UncachedPaperModal({
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-main leading-relaxed">
|
||||
文献{' '}
|
||||
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded-md text-blueprint font-bold select-all">
|
||||
<span className="font-mono bg-sunken px-1.5 py-0.5 rounded-md text-blueprint font-bold select-all">
|
||||
{bibcode}
|
||||
</span>{' '}
|
||||
尚未收录在本地数据库中。
|
||||
|
||||
@ -13,6 +13,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import { Logo } from '../Logo';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
export type TabId =
|
||||
| 'search'
|
||||
@ -44,6 +46,7 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const effectiveCollapsed = isCollapsed && !isOpen;
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -58,7 +61,7 @@ export function Sidebar({
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`bg-slate-50 border-r border-slate-200 flex flex-col justify-between py-6 z-40 select-none transition-all duration-355 cubic-bezier(0.4, 0, 0.2, 1) fixed inset-y-0 left-0 lg:relative lg:translate-x-0 ${
|
||||
className={`bg-surface flex flex-col justify-between py-6 z-40 select-none transition-all duration-300 fixed inset-y-0 left-0 lg:relative lg:translate-x-0 sidebar-seam ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
} ${
|
||||
isOpen ? 'w-64 px-4' : effectiveCollapsed ? 'w-16 px-2' : 'w-64 px-4'
|
||||
@ -73,22 +76,21 @@ export function Sidebar({
|
||||
: 'justify-between px-3'
|
||||
}`}
|
||||
>
|
||||
{/* Logo & 标题文字 */}
|
||||
{/* Logo按钮 (只在折叠状态下可点击展开) */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Logo按钮 (只在折叠状态下可点击展开) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!effectiveCollapsed}
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className={`flex items-center justify-center shrink-0 rounded-lg transition-all duration-300 ${
|
||||
effectiveCollapsed
|
||||
? 'w-11 h-11 bg-white hover:bg-slate-55 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm'
|
||||
? 'w-11 h-11 bg-elevated hover:bg-sunken cursor-pointer'
|
||||
: 'w-9 h-9 bg-transparent border border-transparent cursor-default'
|
||||
}`}
|
||||
title={effectiveCollapsed ? '展开导航' : undefined}
|
||||
>
|
||||
<div
|
||||
className={`transition-all duration-300 flex items-center justify-center ${effectiveCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}
|
||||
className={`transition-all duration-300 flex items-center justify-center text-blueprint ${effectiveCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}
|
||||
>
|
||||
<Logo gradientId="sidebarStarGrad" />
|
||||
</div>
|
||||
@ -102,20 +104,22 @@ export function Sidebar({
|
||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<h1 className="text-sm font-bold text-slate-800 tracking-wider whitespace-nowrap">
|
||||
<h1 className="text-sm font-bold text-content tracking-wider whitespace-nowrap">
|
||||
AstroResearch
|
||||
</h1>
|
||||
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap">
|
||||
<span className="text-[11px] text-secondary block font-medium font-sans whitespace-nowrap">
|
||||
天文学科研辅助系统
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 折叠控制按钮 (圆润悬浮效果,仅在桌面端显示) */}
|
||||
{/* 折叠控制按钮 (仅在桌面端显示) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className={`p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-505 hover:text-slate-800 transition-all duration-300 cursor-pointer shadow-2xs shrink-0 items-center justify-center hover:scale-105 active:scale-95 hidden lg:flex ${
|
||||
aria-label="收起导航菜单"
|
||||
aria-expanded={!effectiveCollapsed}
|
||||
className={`p-1.5 rounded-full bg-sunken hover:bg-border-subtle text-secondary hover:text-content transition-all duration-300 cursor-pointer shrink-0 items-center justify-center hover:scale-105 active:scale-95 hidden lg:flex ${
|
||||
effectiveCollapsed
|
||||
? 'opacity-0 scale-75 pointer-events-none w-0 h-0 p-0 border-0 overflow-hidden'
|
||||
: 'opacity-100 scale-100'
|
||||
@ -147,6 +151,8 @@ export function Sidebar({
|
||||
<button
|
||||
key={tab.id}
|
||||
title={effectiveCollapsed ? tab.label : undefined}
|
||||
aria-label={tab.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
if (tab.id === 'citation' && selectedPaper) {
|
||||
@ -156,16 +162,16 @@ export function Sidebar({
|
||||
}}
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border ${
|
||||
effectiveCollapsed
|
||||
? 'px-2 py-2.5 justify-center'
|
||||
: 'px-3 py-2.5'
|
||||
? 'px-2 py-3 justify-center min-h-[44px]'
|
||||
: 'px-3 py-3 min-h-[44px]'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
|
||||
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
|
||||
? 'bg-sunken border-transparent text-content'
|
||||
: 'border-transparent text-secondary hover:bg-sunken hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`}
|
||||
className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-blueprint' : 'text-secondary group-hover:text-content'}`}
|
||||
/>
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
@ -182,14 +188,20 @@ export function Sidebar({
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
|
||||
{/* 底部:主题切换 + 当前选定文献 + 退出登录 */}
|
||||
<div className="space-y-3">
|
||||
<ThemeToggle
|
||||
theme={theme}
|
||||
onToggle={toggleTheme}
|
||||
collapsed={effectiveCollapsed}
|
||||
/>
|
||||
|
||||
{selectedPaper ? (
|
||||
<div
|
||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||
effectiveCollapsed
|
||||
? 'p-0 border-transparent bg-transparent flex justify-center'
|
||||
: 'p-3.5 border-slate-200 bg-slate-100/50'
|
||||
: 'p-3.5 border-transparent bg-sunken'
|
||||
}`}
|
||||
title={
|
||||
effectiveCollapsed
|
||||
@ -199,7 +211,7 @@ export function Sidebar({
|
||||
>
|
||||
{/* 折叠下的微型图书图标 */}
|
||||
<div
|
||||
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-55 text-slate-650 shrink-0 shadow-xs ${
|
||||
className={`transition-all duration-300 flex items-center justify-center rounded-lg bg-sunken text-secondary shrink-0 ${
|
||||
effectiveCollapsed
|
||||
? 'w-9 h-9 opacity-100 scale-100'
|
||||
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
|
||||
@ -216,13 +228,13 @@ export function Sidebar({
|
||||
: 'opacity-100 max-w-[200px] max-h-40'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1">
|
||||
<span className="text-[9px] font-bold text-tertiary tracking-widest block mb-1">
|
||||
当前选定文献
|
||||
</span>
|
||||
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">
|
||||
<h4 className="text-xs text-content font-bold line-clamp-2 mb-2 leading-relaxed">
|
||||
{selectedPaper.title}
|
||||
</h4>
|
||||
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2">
|
||||
<div className="flex items-center justify-between text-[10px] font-medium text-secondary gap-2">
|
||||
<span className="shrink-0">
|
||||
发表年份: {selectedPaper.year}
|
||||
</span>
|
||||
@ -237,13 +249,13 @@ export function Sidebar({
|
||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||
effectiveCollapsed
|
||||
? 'p-0 border-transparent bg-transparent flex justify-center'
|
||||
: 'p-3 border-slate-200 bg-slate-100/30'
|
||||
: 'p-3 border-transparent bg-sunken'
|
||||
}`}
|
||||
title={effectiveCollapsed ? '未选定研究目标' : undefined}
|
||||
>
|
||||
{/* 折叠下的微型馆藏图标 */}
|
||||
<div
|
||||
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100/30 text-slate-400 shrink-0 ${
|
||||
className={`transition-all duration-300 flex items-center justify-center rounded-lg bg-sunken text-tertiary shrink-0 ${
|
||||
effectiveCollapsed
|
||||
? 'w-9 h-9 opacity-100 scale-100'
|
||||
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
|
||||
@ -260,7 +272,7 @@ export function Sidebar({
|
||||
: 'opacity-100 max-w-[200px] max-h-12'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[10px] text-slate-400 font-medium tracking-wide">
|
||||
<span className="text-[10px] text-tertiary font-medium tracking-wide">
|
||||
未选定研究目标
|
||||
</span>
|
||||
</div>
|
||||
@ -271,12 +283,15 @@ export function Sidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-slate-500 hover:bg-rose-50/50 hover:text-rose-600 cursor-pointer group ${
|
||||
effectiveCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
|
||||
aria-label="退出登录"
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-secondary hover:bg-rose-50/50 dark:hover:bg-rose-500/10 hover:text-danger cursor-pointer group ${
|
||||
effectiveCollapsed
|
||||
? 'px-2 py-3 justify-center min-h-[44px]'
|
||||
: 'px-3 py-3 min-h-[44px]'
|
||||
}`}
|
||||
title={effectiveCollapsed ? '退出登录' : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-505 transition-colors" />
|
||||
<LogOut className="w-4 h-4 shrink-0 text-tertiary group-hover:text-danger transition-colors" />
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
effectiveCollapsed
|
||||
|
||||
47
dashboard/src/components/layout/ThemeToggle.tsx
Normal file
47
dashboard/src/components/layout/ThemeToggle.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
// dashboard/src/components/layout/ThemeToggle.tsx
|
||||
//
|
||||
// 浅色/暗色主题切换按钮。适配侧边栏折叠态(仅图标)与展开态。
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import type { Theme } from '../../hooks/useTheme';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
theme: Theme;
|
||||
onToggle: () => void;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function ThemeToggle({
|
||||
theme,
|
||||
onToggle,
|
||||
collapsed = false,
|
||||
}: ThemeToggleProps) {
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={isDark ? '切换到浅色模式' : '切换到暗色模式'}
|
||||
aria-label={isDark ? '切换到浅色模式' : '切换到暗色模式'}
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-secondary hover:bg-sunken hover:text-content cursor-pointer group ${
|
||||
collapsed
|
||||
? 'px-2 py-3 justify-center min-h-[44px]'
|
||||
: 'px-3 py-3 min-h-[44px]'
|
||||
}`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-4 h-4 shrink-0 text-amber-400 group-hover:text-amber-300 transition-colors" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4 shrink-0 text-secondary group-hover:text-blueprint transition-colors" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
collapsed
|
||||
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
|
||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
|
||||
}`}
|
||||
>
|
||||
{isDark ? '浅色模式' : '深空模式'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
71
dashboard/src/components/layout/ToastContainer.tsx
Normal file
71
dashboard/src/components/layout/ToastContainer.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CheckCircle, AlertCircle, Info, X } from 'lucide-react';
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastItem[];
|
||||
onClose: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onClose }: ToastContainerProps) {
|
||||
return (
|
||||
<div className="fixed top-5 right-5 z-[9999] flex flex-col gap-3 max-w-sm w-full pointer-events-none px-4 sm:px-0">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => {
|
||||
const isSuccess = toast.type === 'success';
|
||||
const isError = toast.type === 'error';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`pointer-events-auto flex items-start gap-3 p-3.5 rounded-lg border shadow-lg backdrop-blur-md transition-all ${
|
||||
isSuccess
|
||||
? 'bg-emerald-50/90 dark:bg-emerald-950/80 border-emerald-200 dark:border-emerald-500/30 text-emerald-800 dark:text-emerald-200'
|
||||
: isError
|
||||
? 'bg-rose-50/90 dark:bg-rose-950/80 border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-200'
|
||||
: 'bg-slate-50/90 dark:bg-slate-900/80 border-slate-200 dark:border-slate-800 text-slate-800 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{/* Type Icon */}
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{isSuccess && (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
{isError && (
|
||||
<AlertCircle className="w-4 h-4 text-rose-600 dark:text-rose-400" />
|
||||
)}
|
||||
{!isSuccess && !isError && (
|
||||
<Info className="w-4 h-4 text-blueprint" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-[11px] font-bold leading-relaxed flex-1 select-text">
|
||||
{toast.message}
|
||||
</p>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(toast.id)}
|
||||
className="shrink-0 p-0.5 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-all text-secondary hover:text-content cursor-pointer"
|
||||
title="关闭通知"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,15 @@
|
||||
// dashboard/src/components/observation/ObservationPreviewRenderer.tsx
|
||||
//
|
||||
// 预览渲染分发器 —— 按 ObservationPreview.kind 路由到对应绘图组件
|
||||
//
|
||||
// 扩展新产品类型的可视化时:
|
||||
// 1. 实现 XxxPlot 组件(如 LightCurvePlot.tsx)
|
||||
// 2. 在此 switch 加 case(从联合类型窄化出对应变体)
|
||||
// 3. 在 constants.ts 的 canPreview() 放开该 product
|
||||
// 现有 Spectrum 渲染无需改动(OCP:对扩展开放,对修改关闭)。
|
||||
// 支持点击右上角“放大”图标,通过 React Portal 全屏打开高清晰灯箱模态框。
|
||||
// 针对光谱图,在大图模态框中会以高精尺寸重新渲染,获得更准确的数据滑块精度;
|
||||
// 同时解析展示 FITS 头信息(红移、天体分类、有效温度、金属丰度等)。
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Maximize2, X, Info } from 'lucide-react';
|
||||
import type { ObservationPreview } from './constants';
|
||||
import { SOURCE_THEME, PRODUCT_LABEL } from './constants';
|
||||
import { SpectrumPlot } from './SpectrumPlot';
|
||||
|
||||
interface ObservationPreviewRendererProps {
|
||||
@ -18,44 +19,197 @@ interface ObservationPreviewRendererProps {
|
||||
export function ObservationPreviewRenderer({
|
||||
preview,
|
||||
}: ObservationPreviewRendererProps) {
|
||||
switch (preview.kind) {
|
||||
case 'spectrum':
|
||||
// 窄化:preview 此处为 { kind: 'spectrum' } & SpectrumPreview
|
||||
return <SpectrumPlot segments={preview.segments} />;
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
case 'lightcurve':
|
||||
// TODO: 实现 LightCurvePlot 后替换占位
|
||||
return (
|
||||
<div className="text-[10px] text-slate-400 py-4 text-center">
|
||||
光变曲线预览(开发中)
|
||||
</div>
|
||||
);
|
||||
// 监听 Escape 按键以快速关闭模态框
|
||||
useEffect(() => {
|
||||
if (!isZoomed) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsZoomed(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isZoomed]);
|
||||
|
||||
case 'photometry':
|
||||
// TODO: 实现 PhotometryTable 后替换占位
|
||||
return (
|
||||
<div className="text-[10px] text-slate-400 py-4 text-center">
|
||||
测光预览(开发中)
|
||||
</div>
|
||||
);
|
||||
const sourceTheme = SOURCE_THEME[preview.source] ?? {
|
||||
badge: 'bg-border-subtle text-secondary',
|
||||
label: preview.source,
|
||||
dot: 'bg-secondary',
|
||||
};
|
||||
const productLabel = PRODUCT_LABEL[preview.kind] ?? preview.kind;
|
||||
|
||||
case 'image':
|
||||
// image 变体已有 preview_data_url,直接渲染
|
||||
return (
|
||||
<img
|
||||
src={preview.preview_data_url}
|
||||
alt="cutout"
|
||||
className="w-full rounded border border-slate-200"
|
||||
style={{ maxHeight: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="relative group w-full overflow-hidden rounded border border-default/40 bg-sunken/30 select-none">
|
||||
{/* 常规缩略预览 */}
|
||||
{preview.kind === 'spectrum' && (
|
||||
<SpectrumPlot segments={preview.segments} height={180} />
|
||||
)}
|
||||
|
||||
default:
|
||||
// 穷尽性检查:未来新增 kind 会在编译期报错
|
||||
return (
|
||||
<div className="text-[10px] text-slate-400 py-4 text-center">
|
||||
未知预览类型
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{preview.kind === 'lightcurve' && (
|
||||
<div className="text-[10px] text-tertiary py-4 text-center">
|
||||
光变曲线预览(开发中)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.kind === 'photometry' && (
|
||||
<div className="text-[10px] text-tertiary py-4 text-center">
|
||||
测光预览(开发中)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.kind === 'image' && (
|
||||
<img
|
||||
src={preview.preview_data_url}
|
||||
alt="cutout"
|
||||
className="w-full rounded bg-black/10 object-contain"
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 悬浮放大触控层 */}
|
||||
{(preview.kind === 'spectrum' || preview.kind === 'image') && (
|
||||
<button
|
||||
onClick={() => setIsZoomed(true)}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded bg-surface/90 hover:bg-surface border border-subtle hover:border-default text-tertiary hover:text-content opacity-0 group-hover:opacity-45 hover:!opacity-100 transition-all shadow-xs cursor-pointer"
|
||||
title="放大查看"
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 全屏放大灯箱 —— 使用 React Portal 挂载到 body 下,彻底解决父组件 transform 裁剪 bug */}
|
||||
{isZoomed &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* 模糊遮罩背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-950/75 backdrop-blur-md transition-opacity duration-300"
|
||||
onClick={() => setIsZoomed(false)}
|
||||
/>
|
||||
|
||||
{/* 放大面板卡片 */}
|
||||
<div className="console-panel rounded-lg w-full max-w-4xl max-h-[90vh] bg-surface flex flex-col z-10 overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
{/* 顶部标题栏 */}
|
||||
<div className="flex items-center justify-between border-b border-subtle px-5 py-4 select-none">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${sourceTheme.badge}`}
|
||||
>
|
||||
{sourceTheme.label}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-border-subtle text-secondary">
|
||||
{productLabel}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-secondary font-mono">
|
||||
ID: {preview.source_id}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsZoomed(false)}
|
||||
className="p-1 rounded-md hover:bg-sunken text-tertiary hover:text-content transition-colors cursor-pointer"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 中部高精大图/光谱图表渲染 */}
|
||||
<div className="p-6 overflow-y-auto flex-1 flex flex-col items-center justify-center bg-sunken/40">
|
||||
{preview.kind === 'spectrum' && (
|
||||
<div className="w-full bg-surface rounded-lg border border-default p-4 shadow-sm">
|
||||
{/* 高度提升至 420px 渲染,大幅扩充图线拉伸区域,交互触感更精准 */}
|
||||
<SpectrumPlot segments={preview.segments} height={420} />
|
||||
</div>
|
||||
)}
|
||||
{preview.kind === 'image' && (
|
||||
<div className="relative rounded-lg border border-default overflow-hidden bg-black flex items-center justify-center p-1.5 shadow-sm max-w-full">
|
||||
<img
|
||||
src={preview.preview_data_url}
|
||||
alt="cutout-enlarged"
|
||||
className="rounded max-w-full max-h-[60vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部详细 FITS 头元数据状态栏 */}
|
||||
{preview.meta && (
|
||||
<div className="border-t border-subtle px-5 py-3.5 bg-surface text-[11px] text-secondary flex flex-wrap gap-x-6 gap-y-1.5 select-none items-center">
|
||||
<div className="flex items-center gap-1 text-tertiary">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span>天体物理参数:</span>
|
||||
</div>
|
||||
{preview.meta.ra !== undefined &&
|
||||
preview.meta.dec !== undefined && (
|
||||
<div className="font-mono">
|
||||
坐标:{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.ra.toFixed(5)}
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.dec.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.meta.z !== undefined && (
|
||||
<div className="font-mono">
|
||||
红移 z:{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.z.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.meta.class && (
|
||||
<div>
|
||||
光谱分类:{' '}
|
||||
<span className="text-content font-bold uppercase">
|
||||
{preview.meta.class}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.meta.snr !== undefined && (
|
||||
<div className="font-mono">
|
||||
信噪比 SNR:{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.snr.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.meta.teff !== undefined && (
|
||||
<div className="font-mono">
|
||||
有效温度 Teff:{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.teff.toFixed(0)} K
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.meta.logg !== undefined && (
|
||||
<div className="font-mono">
|
||||
重力加速度 log g:{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.logg.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.meta.fe_h !== undefined && (
|
||||
<div className="font-mono">
|
||||
金属丰度 [Fe/H]:{' '}
|
||||
<span className="text-content font-bold">
|
||||
{preview.meta.fe_h.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,11 +50,11 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
|
||||
<div className="bg-sunken rounded-lg p-3.5 space-y-3 text-xs">
|
||||
{/* 标题栏:数据源 + 产品类型双标签 */}
|
||||
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
|
||||
<Activity className="w-3.5 h-3.5 text-slate-500" />
|
||||
<div className="flex items-center justify-between border-b border-subtle pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-content text-[11px] uppercase tracking-wide">
|
||||
<Activity className="w-3.5 h-3.5 text-secondary" />
|
||||
<span>观测数据下载</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@ -63,7 +63,7 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
>
|
||||
{theme.label}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-border-subtle text-secondary">
|
||||
{productLabel}
|
||||
{product.subtype ? ` · ${product.subtype}` : ''}
|
||||
</span>
|
||||
@ -71,24 +71,24 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
</div>
|
||||
|
||||
{/* 查询信息 */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-secondary">
|
||||
{ra !== undefined && dec !== undefined && (
|
||||
<span className="font-mono">
|
||||
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
|
||||
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
|
||||
<span className="bg-border-subtle text-secondary px-1.5 py-0.5 rounded font-medium">
|
||||
命中 {matched_count} 条
|
||||
</span>
|
||||
{products.length > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
|
||||
<span className="flex items-center gap-0.5 text-success font-medium">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
下载 {products.length}
|
||||
</span>
|
||||
)}
|
||||
{failures.length > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
|
||||
<span className="flex items-center gap-0.5 text-danger font-medium">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
失败 {failures.length}
|
||||
</span>
|
||||
@ -100,21 +100,18 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
const allCached =
|
||||
p.artifacts.length > 0 && p.artifacts.every((a) => a.cached);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5"
|
||||
>
|
||||
<div key={i} className=" rounded bg-surface p-2.5 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono font-medium text-slate-700">
|
||||
<span className="font-mono font-medium text-secondary">
|
||||
{p.source_label}
|
||||
</span>
|
||||
{allCached ? (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-success">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
缓存
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">新下载</span>
|
||||
<span className="text-[10px] text-tertiary">新下载</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 多 artifact 展示(Gaia 光变 G/BP/RP 三波段) */}
|
||||
@ -138,10 +135,10 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
};
|
||||
return (
|
||||
<div key={ai} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100">
|
||||
<span className="text-slate-500 flex items-center gap-1">
|
||||
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-subtle">
|
||||
<span className="text-secondary flex items-center gap-1">
|
||||
{a.band && (
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium">
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300 font-medium">
|
||||
{a.band}
|
||||
</span>
|
||||
)}
|
||||
@ -152,7 +149,11 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
{previewable && (
|
||||
<button
|
||||
onClick={togglePreview}
|
||||
className="flex items-center gap-0.5 font-medium text-slate-500 hover:text-slate-800 transition-colors"
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-all font-medium text-[10px] ${
|
||||
isOpen
|
||||
? 'text-blueprint bg-blueprint/10 font-bold'
|
||||
: 'text-secondary hover:text-content hover:bg-sunken'
|
||||
}`}
|
||||
title="预览"
|
||||
>
|
||||
{previewState.loading ? (
|
||||
@ -167,7 +168,7 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
href={a.file_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-secondary hover:text-blueprint hover:bg-blueprint/10 transition-all font-medium text-[10px]"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
<span>{a.file_format.toUpperCase()}</span>
|
||||
@ -178,7 +179,7 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
{previewable && isOpen && (
|
||||
<div className="pl-2">
|
||||
{previewState.error ? (
|
||||
<p className="text-[10px] text-rose-500 py-1">
|
||||
<p className="text-[10px] text-danger py-1">
|
||||
{previewState.error}
|
||||
</p>
|
||||
) : previewState.data ? (
|
||||
@ -186,7 +187,7 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
preview={previewState.data}
|
||||
/>
|
||||
) : previewState.loading ? (
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-400 py-4 justify-center">
|
||||
<div className="flex items-center gap-1 text-[10px] text-tertiary py-4 justify-center">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
解析 FITS 中...
|
||||
</div>
|
||||
@ -204,18 +205,20 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
{failures.map((f, i) => (
|
||||
<div
|
||||
key={`f${i}`}
|
||||
className="border border-rose-200 rounded bg-rose-50/50 p-2.5"
|
||||
className="border border-rose-200 dark:border-rose-500/30 rounded bg-rose-50/50 dark:bg-rose-500/10 p-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-rose-700 font-medium">
|
||||
<div className="flex items-center gap-1 text-rose-700 dark:text-rose-300 font-medium">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span className="font-mono text-[10px]">{f.source_label}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
|
||||
<p className="text-[10px] text-rose-600 dark:text-rose-400 mt-1 break-all">
|
||||
{f.error}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!hasResult && (
|
||||
<p className="text-[11px] text-slate-400 italic py-2">
|
||||
<p className="text-[11px] text-tertiary italic py-2">
|
||||
(该区域无{productLabel}数据覆盖)
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
// - 简洁坐标轴 + 网格线
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import type { SpectrumSegment } from './constants';
|
||||
|
||||
interface SpectrumPlotProps {
|
||||
@ -17,7 +18,7 @@ interface SpectrumPlotProps {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// 数据源 → 段配色(与 SOURCE_THEME 协调的线条色)
|
||||
// 数据源 → 段配色(频段语义色,跨主题保持一致以便辨识)
|
||||
const SEGMENT_COLORS: Record<string, string> = {
|
||||
combined: '#64748b', // slate-500
|
||||
xp_merged: '#0ea5e9', // sky-500
|
||||
@ -31,6 +32,23 @@ const SEGMENT_COLORS: Record<string, string> = {
|
||||
|
||||
const DEFAULT_COLOR = '#64748b';
|
||||
|
||||
// 读取 CSS 令牌当前值(随主题切换)
|
||||
function usePlotColors() {
|
||||
const { theme } = useTheme();
|
||||
return useMemo(() => {
|
||||
// theme 仅作为失效依赖:切换浅色/暗色后强制重读 CSS 变量
|
||||
void theme;
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
grid: cs.getPropertyValue('--plot-grid').trim() || '#e2e8f0',
|
||||
axis: cs.getPropertyValue('--plot-axis').trim() || '#64748b',
|
||||
axisStrong: cs.getPropertyValue('--plot-axis-strong').trim() || '#94a3b8',
|
||||
tooltipBg: cs.getPropertyValue('--plot-tooltip-bg').trim() || '#1e293b',
|
||||
tooltipFg: cs.getPropertyValue('--plot-tooltip-fg').trim() || '#ffffff',
|
||||
};
|
||||
}, [theme]);
|
||||
}
|
||||
|
||||
interface PlotPoint {
|
||||
wl: number;
|
||||
flux: number;
|
||||
@ -39,6 +57,8 @@ interface PlotPoint {
|
||||
|
||||
export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
const [hover, setHover] = useState<PlotPoint | null>(null);
|
||||
const plotColors = usePlotColors();
|
||||
const hoveredBand = hover ? (segments[hover.segIdx]?.band ?? '') : '';
|
||||
|
||||
// 合并所有段的数据点,计算全局范围
|
||||
const { points, wlMin, wlMax, fluxMin, fluxMax } = useMemo(() => {
|
||||
@ -52,7 +72,8 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const wl = seg.wavelength[i];
|
||||
const fl = seg.flux[i];
|
||||
if (!isFinite(wl) || !isFinite(fl)) continue;
|
||||
if (wl === null || fl === null || !isFinite(wl) || !isFinite(fl))
|
||||
continue;
|
||||
pts.push({ wl, flux: fl, segIdx: si });
|
||||
if (wl < wMin) wMin = wl;
|
||||
if (wl > wMax) wMax = wl;
|
||||
@ -72,7 +93,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
}, [segments]);
|
||||
|
||||
if (points.length === 0) {
|
||||
return <div className="text-xs text-slate-400 p-2">无有效数据点</div>;
|
||||
return <div className="text-xs text-tertiary p-2">无有效数据点</div>;
|
||||
}
|
||||
|
||||
const width = 600;
|
||||
@ -92,11 +113,14 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
const paths = segments.map((seg) => {
|
||||
const n = Math.min(seg.wavelength.length, seg.flux.length);
|
||||
let d = '';
|
||||
let isFirst = true;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const wl = seg.wavelength[i];
|
||||
const fl = seg.flux[i];
|
||||
if (!isFinite(wl) || !isFinite(fl)) continue;
|
||||
d += `${i === 0 ? 'M' : 'L'}${xScale(wl).toFixed(1)},${yScale(fl).toFixed(1)} `;
|
||||
if (wl === null || fl === null || !isFinite(wl) || !isFinite(fl))
|
||||
continue;
|
||||
d += `${isFirst ? 'M' : 'L'}${xScale(wl).toFixed(1)},${yScale(fl).toFixed(1)} `;
|
||||
isFirst = false;
|
||||
}
|
||||
return {
|
||||
d,
|
||||
@ -121,14 +145,15 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)}k`;
|
||||
return v.toFixed(0);
|
||||
};
|
||||
const fmtFlux = (v: number) => {
|
||||
const fmtFlux = (v: number | null | undefined) => {
|
||||
if (v === null || v === undefined || isNaN(v)) return '—';
|
||||
const abs = Math.abs(v);
|
||||
if (abs !== 0 && (abs < 0.01 || abs >= 1e4)) return v.toExponential(1);
|
||||
return v.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full bg-slate-50/80 border border-slate-200 rounded p-1">
|
||||
<div className="relative w-full bg-sunken rounded p-1">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full"
|
||||
@ -144,7 +169,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
x2={width - padR}
|
||||
y1={yScale(v)}
|
||||
y2={yScale(v)}
|
||||
stroke="#e2e8f0"
|
||||
stroke={plotColors.grid}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
@ -155,7 +180,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
x2={xScale(v)}
|
||||
y1={padT}
|
||||
y2={height - padB}
|
||||
stroke="#e2e8f0"
|
||||
stroke={plotColors.grid}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
@ -180,7 +205,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
y={height - padB + 12}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="#64748b"
|
||||
fill={plotColors.axis}
|
||||
>
|
||||
{fmtWl(v)}
|
||||
</text>
|
||||
@ -193,7 +218,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
y={yScale(v) + 3}
|
||||
textAnchor="end"
|
||||
fontSize="9"
|
||||
fill="#64748b"
|
||||
fill={plotColors.axis}
|
||||
>
|
||||
{fmtFlux(v)}
|
||||
</text>
|
||||
@ -205,7 +230,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
y={height - 4}
|
||||
textAnchor="end"
|
||||
fontSize="8"
|
||||
fill="#94a3b8"
|
||||
fill={plotColors.axisStrong}
|
||||
>
|
||||
λ ({segments[0]?.wavelength_unit ?? 'Å'})
|
||||
</text>
|
||||
@ -214,7 +239,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
y={padT + 4}
|
||||
textAnchor="start"
|
||||
fontSize="8"
|
||||
fill="#94a3b8"
|
||||
fill={plotColors.axisStrong}
|
||||
>
|
||||
flux ({segments[0]?.flux_unit ?? ''})
|
||||
</text>
|
||||
@ -227,7 +252,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
x2={xScale(hover.wl)}
|
||||
y1={padT}
|
||||
y2={height - padB}
|
||||
stroke="#94a3b8"
|
||||
stroke={plotColors.axisStrong}
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
@ -273,13 +298,20 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
{/* hover tooltip */}
|
||||
{hover && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-slate-800 text-white text-[10px] px-1.5 py-0.5 rounded shadow-lg"
|
||||
className="absolute pointer-events-none text-[9px] sm:text-[10px] font-bold px-2 py-1 rounded-md shadow-md border border-white/10 dark:border-black/35 backdrop-blur-[2px]"
|
||||
style={{
|
||||
left: `${((xScale(hover.wl) - padL) / plotW) * 100}%`,
|
||||
top: `${((yScale(hover.flux) - padT) / plotH) * 100}%`,
|
||||
transform: 'translate(-50%, -120%)',
|
||||
left: `${(xScale(hover.wl) / width) * 100}%`,
|
||||
top: `${(yScale(hover.flux) / height) * 100}%`,
|
||||
transform: 'translate(-50%, -125%)',
|
||||
backgroundColor: plotColors.tooltipBg,
|
||||
color: plotColors.tooltipFg,
|
||||
}}
|
||||
>
|
||||
{hoveredBand && (
|
||||
<span className="opacity-75 mr-1 font-extrabold select-none">
|
||||
[{hoveredBand}]
|
||||
</span>
|
||||
)}
|
||||
λ={hover.wl.toFixed(1)} · f={fmtFlux(hover.flux)}
|
||||
</div>
|
||||
)}
|
||||
@ -290,7 +322,7 @@ export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
|
||||
{segments.map((seg, i) => (
|
||||
<span
|
||||
key={`leg${i}`}
|
||||
className="flex items-center gap-1 text-[9px] text-slate-600"
|
||||
className="flex items-center gap-1 text-[9px] text-secondary"
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-0.5"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,52 +7,59 @@ import type { Candidate } from '../../types';
|
||||
|
||||
// ── 数据源主题色(source 轴)──
|
||||
// 与 SOURCE_THEME 在 SpecialToolRenderers.tsx 的取色保持一致,统一视觉
|
||||
// 浅色用 *-100/*-700 徽章对;暗色用 */15 + *-300,保证暗底下仍有辨识度但不过亮
|
||||
export const SOURCE_THEME: Record<
|
||||
string,
|
||||
{ badge: string; label: string; dot: string }
|
||||
> = {
|
||||
lamost: {
|
||||
badge: 'bg-amber-100 text-amber-700',
|
||||
badge:
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
|
||||
label: 'LAMOST',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
gaia: {
|
||||
badge: 'bg-sky-100 text-sky-700',
|
||||
badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300',
|
||||
label: 'Gaia',
|
||||
dot: 'bg-sky-500',
|
||||
},
|
||||
sdss: {
|
||||
badge: 'bg-indigo-100 text-indigo-700',
|
||||
badge:
|
||||
'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300',
|
||||
label: 'SDSS',
|
||||
dot: 'bg-indigo-500',
|
||||
},
|
||||
desi: {
|
||||
badge: 'bg-emerald-100 text-emerald-700',
|
||||
badge:
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
|
||||
label: 'DESI',
|
||||
dot: 'bg-emerald-500',
|
||||
},
|
||||
twomass: {
|
||||
badge: 'bg-orange-100 text-orange-700',
|
||||
badge:
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300',
|
||||
label: '2MASS',
|
||||
dot: 'bg-orange-500',
|
||||
},
|
||||
allwise: {
|
||||
badge: 'bg-rose-100 text-rose-700',
|
||||
badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300',
|
||||
label: 'AllWISE',
|
||||
dot: 'bg-rose-500',
|
||||
},
|
||||
panstarrs: {
|
||||
badge: 'bg-cyan-100 text-cyan-700',
|
||||
badge: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/15 dark:text-cyan-300',
|
||||
label: 'Pan-STARRS',
|
||||
dot: 'bg-cyan-500',
|
||||
},
|
||||
ztf: {
|
||||
badge: 'bg-fuchsia-100 text-fuchsia-700',
|
||||
badge:
|
||||
'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-500/15 dark:text-fuchsia-300',
|
||||
label: 'ZTF',
|
||||
dot: 'bg-fuchsia-500',
|
||||
},
|
||||
tess: {
|
||||
badge: 'bg-violet-100 text-violet-700',
|
||||
badge:
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300',
|
||||
label: 'TESS',
|
||||
dot: 'bg-violet-500',
|
||||
},
|
||||
@ -217,20 +224,26 @@ export interface TargetRequest {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface UnifiedSourceSpec {
|
||||
source: string;
|
||||
product: ProductSpec;
|
||||
release?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/// 统一检索请求体
|
||||
export interface UnifiedSearchRequest {
|
||||
targets: TargetRequest[];
|
||||
/// None/空 = 全部已注册源;非空 = 仅这些 (source, product)
|
||||
sources?: [string, ProductSpec][];
|
||||
release?: string;
|
||||
version?: string;
|
||||
per_target_limit?: number; // 默认 50
|
||||
sources?: UnifiedSourceSpec[];
|
||||
per_target_limit?: number; // 默认 1
|
||||
}
|
||||
|
||||
/// 一个 (source, product) 的聚合候选源组
|
||||
/// 一个 (source, product, release, version) 的聚合候选源组
|
||||
export interface SourceCandidateGroup {
|
||||
source: string;
|
||||
product: ProductSpec;
|
||||
release?: string;
|
||||
version?: string;
|
||||
candidates: Candidate[];
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ interface BilingualViewerProps {
|
||||
highlightCandidates: HighlightCandidate[];
|
||||
hoveredTarget: TargetInfo | null;
|
||||
hoverCardPos: { x: number; y: number; isAbove?: boolean } | null;
|
||||
selectedParagraphIdx?: number | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@ -67,35 +68,56 @@ function PaperMetadataDossier({
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="mb-3 sm:mb-6 rounded-lg p-3 sm:p-5 bg-slate-50 border border-slate-200 relative overflow-hidden select-text"
|
||||
className="mb-3 sm:mb-6 rounded-lg p-3 sm:p-5 bg-sunken relative overflow-hidden select-text"
|
||||
>
|
||||
{/* 科技风背景线段装饰 - 极细学术边框线 */}
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-slate-100/50 pointer-events-none border-l border-b border-slate-200/60" />
|
||||
{/* 科技感文献折角装饰 (Realistic folded page corner) */}
|
||||
<svg
|
||||
className="absolute top-0 right-0 w-8 h-8 pointer-events-none z-10 select-none"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
{/* 用背景色遮罩掉原卡片的直角部分 */}
|
||||
<path d="M 0 0 L 32 0 L 32 32 Z" fill="var(--bg-app)" />
|
||||
{/* 折角阴影 */}
|
||||
<path
|
||||
d="M 0 0 L 0 32 L 32 32 Z"
|
||||
fill="rgba(0, 0, 0, 0.06)"
|
||||
className="dark:fill-[rgba(0,0,0,0.3)]"
|
||||
/>
|
||||
{/* 折角页本身 */}
|
||||
<path
|
||||
d="M 0 0 L 0 32 L 32 32 Z"
|
||||
fill="var(--bg-surface)"
|
||||
stroke="var(--border-strong)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 顶部档案标徽 */}
|
||||
<div className="flex items-center gap-2 mb-2 sm:mb-3 select-none">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold bg-slate-700 text-white uppercase tracking-wider">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold bg-elevated text-content uppercase tracking-wider">
|
||||
{isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'}
|
||||
</span>
|
||||
{metadata.date && (
|
||||
<span className="text-[10px] font-bold text-slate-400 font-mono">
|
||||
<span className="text-[10px] font-bold text-tertiary font-mono">
|
||||
Year: {metadata.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="text-sm font-bold text-slate-900 leading-snug mb-1.5 sm:mb-3 select-text">
|
||||
<h1 className="text-sm font-bold text-content leading-snug mb-1.5 sm:mb-3 select-text">
|
||||
{metadata.title}
|
||||
</h1>
|
||||
|
||||
{/* 作者群 */}
|
||||
{metadata.author && metadata.author.length > 0 && (
|
||||
<div className="text-xs text-slate-600 mb-2 sm:mb-4 font-medium leading-relaxed select-text">
|
||||
<span className="text-slate-400 font-semibold mr-1">
|
||||
<div className="text-xs text-secondary mb-2 sm:mb-4 font-medium leading-relaxed select-text">
|
||||
<span className="text-tertiary font-semibold mr-1">
|
||||
{isChinese ? '作者群体:' : 'Authors:'}
|
||||
</span>
|
||||
<span className="italic text-slate-750">
|
||||
<span className="italic text-secondary">
|
||||
{metadata.author.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
@ -103,11 +125,11 @@ function PaperMetadataDossier({
|
||||
|
||||
{/* 底部期刊与跳转 ADS 按钮 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-3 pt-2 sm:pt-3 border-t border-slate-200/60 select-none">
|
||||
<div className="text-[10px] text-slate-500 font-semibold">
|
||||
<span className="text-slate-400 mr-1">
|
||||
<div className="text-[10px] text-secondary font-semibold">
|
||||
<span className="text-tertiary mr-1">
|
||||
{isChinese ? '发表期刊/预印本:' : 'Publisher:'}
|
||||
</span>
|
||||
<span className="text-slate-700">
|
||||
<span className="text-secondary">
|
||||
{metadata.publisher || 'arXiv Preprint'}
|
||||
</span>
|
||||
</div>
|
||||
@ -117,7 +139,7 @@ function PaperMetadataDossier({
|
||||
href={metadata.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-extrabold px-3 py-1 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 hover:text-sky-850 transition-all cursor-pointer shadow-xs active:scale-95"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-extrabold px-3 py-1 rounded bg-sky-50 dark:bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-500/30 hover:bg-sky-100 hover:text-content transition-all cursor-pointer active:scale-95"
|
||||
title="在 NASA ADS 官方文献数据库中查看源页面"
|
||||
>
|
||||
<span>ADS NASA Portal</span>
|
||||
@ -153,6 +175,7 @@ export function BilingualViewer({
|
||||
highlightCandidates,
|
||||
hoveredTarget,
|
||||
hoverCardPos,
|
||||
selectedParagraphIdx = null,
|
||||
children,
|
||||
}: BilingualViewerProps) {
|
||||
// 监测屏幕宽度以判断是否在平板设备上(宽度 < 1024px)
|
||||
@ -290,18 +313,18 @@ export function BilingualViewer({
|
||||
<div
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseLeave={handleContainerMouseLeave}
|
||||
className="console-panel rounded-lg px-3 pt-3 pb-2 sm:p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden"
|
||||
className="console-panel rounded-lg px-3 pt-3 pb-2 sm:p-6 bg-surface relative flex flex-col min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-4 border-b border-slate-100 pb-1.5 sm:pb-2.5 shrink-0 select-none">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-4 border-b border-subtle pb-1.5 sm:pb-2.5 shrink-0 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
<span className="text-xs font-bold text-content">
|
||||
英文解析正文
|
||||
</span>
|
||||
{selectedPaper.has_pdf && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPdf(!showPdf)}
|
||||
className="text-[9px] font-bold px-2 py-0.5 rounded bg-slate-50 text-blueprint border border-slate-200 hover:bg-slate-100 transition-all cursor-pointer flex items-center gap-1"
|
||||
className="text-[9px] font-bold px-2 py-0.5 rounded bg-sunken text-blueprint hover:bg-sunken transition-all cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
{showPdf ? (
|
||||
<>
|
||||
@ -318,21 +341,21 @@ export function BilingualViewer({
|
||||
</div>
|
||||
|
||||
{showPdf ? (
|
||||
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden bg-sunken">
|
||||
{pdfLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-sky-600" />
|
||||
<div className="flex flex-col items-center justify-center h-full text-secondary space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-blueprint" />
|
||||
<p className="text-xs font-bold">正在加载 PDF 文献...</p>
|
||||
</div>
|
||||
) : pdfError ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-3">
|
||||
<p className="text-xs font-bold text-rose-600">
|
||||
<div className="flex flex-col items-center justify-center h-full text-secondary space-y-3">
|
||||
<p className="text-xs font-bold text-danger">
|
||||
PDF 加载失败: {pdfError}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadPdfBlob(selectedPaper.bibcode)}
|
||||
className="text-[10px] font-bold px-3 py-1 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 cursor-pointer"
|
||||
className="text-[10px] font-bold px-3 py-1 rounded bg-sky-50 dark:bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-500/30 hover:bg-sky-100 cursor-pointer"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
@ -346,8 +369,8 @@ export function BilingualViewer({
|
||||
) : null}
|
||||
</div>
|
||||
) : parsing ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-sky-600" />
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-secondary space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-blueprint" />
|
||||
<p className="text-xs font-bold text-center">
|
||||
正在提取源文献正文排版,并转换至 Markdown 排版...
|
||||
</p>
|
||||
@ -358,7 +381,7 @@ export function BilingualViewer({
|
||||
onScroll={handleEnglishScroll}
|
||||
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
|
||||
>
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-300 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg prose-p:my-1.5 sm:prose-p:my-3 prose-headings:my-2 sm:prose-headings:my-4">
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-content prose-headings:text-content prose-headings:font-bold prose-strong:text-content prose-code:text-blueprint prose-blockquote:border-subtle prose-blockquote:text-secondary prose-img:max-w-full prose-img:rounded-lg prose-p:my-1.5 sm:prose-p:my-3 prose-headings:my-2 sm:prose-headings:my-4">
|
||||
{/* 渲染英文元数据档案面板 */}
|
||||
{engMeta && (
|
||||
<PaperMetadataDossier
|
||||
@ -372,19 +395,37 @@ export function BilingualViewer({
|
||||
const matchedNote = notes.find(
|
||||
(n) => n.paragraph_index === idx
|
||||
);
|
||||
const isFocused = selectedParagraphIdx === idx;
|
||||
const highlightStyle = matchedNote
|
||||
? NOTE_COLORS[matchedNote.highlight_color]
|
||||
: null;
|
||||
|
||||
let leftBorderClass = 'border-l-transparent';
|
||||
if (isFocused) {
|
||||
leftBorderClass = 'border-l-blueprint';
|
||||
} else if (matchedNote) {
|
||||
if (matchedNote.highlight_color === 'green')
|
||||
leftBorderClass = 'border-l-emerald-500';
|
||||
else if (matchedNote.highlight_color === 'cyan')
|
||||
leftBorderClass = 'border-l-cyan-500';
|
||||
else if (matchedNote.highlight_color === 'amber')
|
||||
leftBorderClass = 'border-l-amber-500';
|
||||
else if (matchedNote.highlight_color === 'purple')
|
||||
leftBorderClass = 'border-l-purple-500';
|
||||
}
|
||||
|
||||
const cardStyles = isFocused
|
||||
? 'bg-blueprint/5 dark:bg-blueprint/15 border border-blueprint/20 rounded-r-md shadow-xs'
|
||||
: highlightStyle
|
||||
? `${highlightStyle.bg} border border-slate-200 dark:border-slate-800/40 rounded-r-md`
|
||||
: 'hover:bg-sunken border border-transparent';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
id={`paragraph-block-${idx}`}
|
||||
onMouseUp={() => handleTextSelection(idx)}
|
||||
className={`cursor-text relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 ${
|
||||
highlightStyle
|
||||
? `${highlightStyle.bg} ${highlightStyle.border} border`
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
className={`cursor-text relative px-3.5 -mx-1.5 py-2 transition-all duration-300 border-l-4 ${leftBorderClass} ${cardStyles}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
@ -401,7 +442,7 @@ export function BilingualViewer({
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="celestial-target cursor-pointer font-bold text-sky-600 underline decoration-dotted decoration-2 hover:text-sky-850"
|
||||
className="celestial-target cursor-pointer font-bold text-blueprint underline decoration-dotted decoration-2 hover:text-content"
|
||||
data-target-name={targetName}
|
||||
>
|
||||
{children}
|
||||
@ -411,7 +452,7 @@ export function BilingualViewer({
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-sky-600 hover:underline"
|
||||
className="text-blueprint hover:underline"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@ -428,12 +469,12 @@ export function BilingualViewer({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
|
||||
<FileText className="w-12 h-12 mb-3 text-slate-300" />
|
||||
<p className="text-xs font-bold text-slate-500">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-tertiary select-none">
|
||||
<FileText className="w-12 h-12 mb-3 text-tertiary" />
|
||||
<p className="text-xs font-bold text-secondary">
|
||||
本篇文献暂无已解析的英文源正文
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
请点击上方按钮提取文档结构正文
|
||||
</p>
|
||||
</div>
|
||||
@ -443,15 +484,15 @@ export function BilingualViewer({
|
||||
|
||||
{/* 中文翻译视窗 (或双语对照) */}
|
||||
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
|
||||
<div className="console-panel rounded-lg px-3 pt-2 pb-3 sm:p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0">
|
||||
<div className="hidden sm:flex items-center justify-between mb-2 sm:mb-4 border-b border-slate-100 pb-1.5 sm:pb-2.5 shrink-0 select-none">
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
<div className="console-panel rounded-lg px-3 pt-2 pb-3 sm:p-6 bg-surface relative flex flex-col overflow-hidden min-h-0">
|
||||
<div className="hidden sm:flex items-center justify-between mb-2 sm:mb-4 border-b border-subtle pb-1.5 sm:pb-2.5 shrink-0 select-none">
|
||||
<span className="text-xs font-bold text-content">
|
||||
中文学术对比翻译
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{translating ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-secondary space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-blueprint" />
|
||||
<p className="text-xs font-bold text-center">
|
||||
天文学专属词典加载中,正在通过大模型进行术语修正翻译...
|
||||
@ -463,7 +504,7 @@ export function BilingualViewer({
|
||||
onScroll={handleChineseScroll}
|
||||
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
|
||||
>
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-350 prose-img:max-w-full prose-img:rounded-lg prose-p:my-1.5 sm:prose-p:my-3 prose-headings:my-2 sm:prose-headings:my-4">
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-content prose-headings:text-content prose-strong:text-content prose-code:text-blueprint prose-blockquote:border-subtle prose-img:max-w-full prose-img:rounded-lg prose-p:my-1.5 sm:prose-p:my-3 prose-headings:my-2 sm:prose-headings:my-4">
|
||||
{/* 渲染中文元数据档案面板 */}
|
||||
{mergedChnMeta && (
|
||||
<PaperMetadataDossier
|
||||
@ -473,33 +514,63 @@ export function BilingualViewer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{splitParagraphs(chnPure).map((para: string, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
id={`paragraph-block-${idx}`}
|
||||
className="relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 hover:bg-slate-100"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, safeSchema],
|
||||
rehypeKatex,
|
||||
]}
|
||||
{splitParagraphs(chnPure).map((para: string, idx: number) => {
|
||||
const matchedNote = notes.find(
|
||||
(n) => n.paragraph_index === idx
|
||||
);
|
||||
const isFocused = selectedParagraphIdx === idx;
|
||||
const highlightStyle = matchedNote
|
||||
? NOTE_COLORS[matchedNote.highlight_color]
|
||||
: null;
|
||||
|
||||
let leftBorderClass = 'border-l-transparent';
|
||||
if (isFocused) {
|
||||
leftBorderClass = 'border-l-blueprint';
|
||||
} else if (matchedNote) {
|
||||
if (matchedNote.highlight_color === 'green')
|
||||
leftBorderClass = 'border-l-emerald-500';
|
||||
else if (matchedNote.highlight_color === 'cyan')
|
||||
leftBorderClass = 'border-l-cyan-500';
|
||||
else if (matchedNote.highlight_color === 'amber')
|
||||
leftBorderClass = 'border-l-amber-500';
|
||||
else if (matchedNote.highlight_color === 'purple')
|
||||
leftBorderClass = 'border-l-purple-500';
|
||||
}
|
||||
|
||||
const cardStyles = isFocused
|
||||
? 'bg-blueprint/5 dark:bg-blueprint/15 border border-blueprint/20 rounded-r-md shadow-xs'
|
||||
: highlightStyle
|
||||
? `${highlightStyle.bg} border border-slate-200 dark:border-slate-800/40 rounded-r-md`
|
||||
: 'hover:bg-sunken border border-transparent';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
id={`paragraph-block-${idx}`}
|
||||
className={`relative px-3.5 -mx-1.5 py-2 transition-all duration-300 border-l-4 ${leftBorderClass} ${cardStyles}`}
|
||||
>
|
||||
{para}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
))}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, safeSchema],
|
||||
rehypeKatex,
|
||||
]}
|
||||
>
|
||||
{para}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
|
||||
<Languages className="w-12 h-12 mb-3 text-slate-300" />
|
||||
<p className="text-xs font-bold text-slate-500">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-tertiary select-none">
|
||||
<Languages className="w-12 h-12 mb-3 text-tertiary" />
|
||||
<p className="text-xs font-bold text-secondary">
|
||||
本篇文献暂无已缓存的翻译结果
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
需点击上方“生成智能翻译”按钮启动大模型翻译
|
||||
</p>
|
||||
</div>
|
||||
@ -523,10 +594,10 @@ export function BilingualViewer({
|
||||
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="bg-white rounded-lg p-4 border border-slate-350 shadow-sm w-72 text-[11px] space-y-2 pointer-events-auto select-text animate-in fade-in duration-200"
|
||||
className="bg-surface rounded-lg p-4 shadow-sm w-72 text-[11px] space-y-2 pointer-events-auto select-text animate-in fade-in duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||
<span className="font-bold text-slate-900 text-[13px]">
|
||||
<div className="flex items-center justify-between border-b border-subtle pb-2">
|
||||
<span className="font-bold text-content text-[13px]">
|
||||
{hoveredTarget.target_name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 select-none">
|
||||
@ -534,7 +605,7 @@ export function BilingualViewer({
|
||||
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
|
||||
className="text-[9px] font-extrabold bg-sunken hover:bg-border-subtle text-secondary hover:text-content px-1.5 py-0.5 rounded transition-all cursor-pointer"
|
||||
title="在 SIMBAD 中查询该天体详情"
|
||||
>
|
||||
SIMBAD
|
||||
@ -543,59 +614,59 @@ export function BilingualViewer({
|
||||
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
|
||||
className="text-[9px] font-extrabold bg-sunken hover:bg-border-subtle text-secondary hover:text-content px-1.5 py-0.5 rounded transition-all cursor-pointer"
|
||||
title="在 VizieR 中查询相关文献和表数据"
|
||||
>
|
||||
VizieR
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-600">
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-secondary">
|
||||
{/* 天体类型 & 官方名称 */}
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
{hoveredTarget.otype && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-850 border border-amber-200/70 text-[9px] font-bold font-mono">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border border-amber-200/70 dark:border-amber-500/30 text-[9px] font-bold font-mono">
|
||||
{hoveredTarget.otype}
|
||||
</span>
|
||||
)}
|
||||
{hoveredTarget.oname &&
|
||||
hoveredTarget.oname !== hoveredTarget.target_name && (
|
||||
<span className="text-[10px] text-slate-400 font-medium truncate">
|
||||
<span className="text-[10px] text-tertiary font-medium truncate">
|
||||
aka {hoveredTarget.oname}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 坐标 */}
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
RA (J2000)
|
||||
</span>
|
||||
<div className="font-mono font-bold text-slate-800">
|
||||
<div className="font-mono font-bold text-content">
|
||||
{hoveredTarget.ra || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
Dec (J2000)
|
||||
</span>
|
||||
<div className="font-mono font-bold text-slate-800">
|
||||
<div className="font-mono font-bold text-content">
|
||||
{hoveredTarget.dec || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
{/* 光谱型 & 视星等 */}
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
光谱型
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
<div className="font-bold text-content">
|
||||
{hoveredTarget.spectral_type || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
视星等 (V)
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
<div className="font-bold text-content">
|
||||
{hoveredTarget.v_magnitude !== null &&
|
||||
hoveredTarget.v_magnitude !== undefined
|
||||
? `${hoveredTarget.v_magnitude.toFixed(2)}`
|
||||
@ -604,10 +675,10 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 视差 + 误差 + 估算距离 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
视差 / 估算距离
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
<div className="font-bold text-content">
|
||||
{hoveredTarget.parallax !== null &&
|
||||
hoveredTarget.parallax !== undefined
|
||||
? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
|
||||
@ -616,10 +687,10 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 自行 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
自行
|
||||
</span>
|
||||
<div className="font-bold text-slate-800 text-[10.5px]">
|
||||
<div className="font-bold text-content text-[10.5px]">
|
||||
{hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null
|
||||
? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr`
|
||||
: '未知'}
|
||||
@ -627,10 +698,10 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 视向速度 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block">
|
||||
视向速度
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
<div className="font-bold text-content">
|
||||
{hoveredTarget.radial_velocity != null
|
||||
? `${hoveredTarget.radial_velocity.toFixed(1)} km/s`
|
||||
: '未知'}
|
||||
@ -641,15 +712,15 @@ export function BilingualViewer({
|
||||
{hoveredTarget.photometry &&
|
||||
Object.keys(hoveredTarget.photometry).length > 1 && (
|
||||
<div className="border-t border-slate-200/60 pt-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-1">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block mb-1">
|
||||
多波段星等
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 text-[10px] font-mono text-slate-600">
|
||||
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 text-[10px] font-mono text-secondary">
|
||||
{Object.entries(hoveredTarget.photometry).map(
|
||||
([band, mag]) => (
|
||||
<span key={band} className="whitespace-nowrap">
|
||||
<span className="text-slate-400">{band}</span>{' '}
|
||||
<span className="font-bold text-slate-700">
|
||||
<span className="text-tertiary">{band}</span>{' '}
|
||||
<span className="font-bold text-secondary">
|
||||
{mag.toFixed(3)}
|
||||
</span>
|
||||
</span>
|
||||
@ -660,10 +731,10 @@ export function BilingualViewer({
|
||||
)}
|
||||
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
|
||||
<div className="border-t border-slate-200/60 pt-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-0.5">
|
||||
<span className="text-secondary font-bold tracking-wide text-[9px] uppercase block mb-0.5">
|
||||
常用别名
|
||||
</span>
|
||||
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
|
||||
<div className="text-[10px] text-secondary font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
|
||||
{hoveredTarget.aliases.slice(0, 8).join(', ')}
|
||||
{hoveredTarget.aliases.length > 8 && ' ...'}
|
||||
</div>
|
||||
|
||||
@ -74,32 +74,32 @@ export function ReaderNotesSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNotesPanel(false)}
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
|
||||
aria-label="关闭侧栏"
|
||||
/>
|
||||
|
||||
<div className="console-panel rounded-lg border border-slate-200 bg-slate-50 flex flex-col overflow-hidden shadow-sm h-full fixed top-0 right-0 h-full w-[88vw] sm:w-[380px] z-40 lg:relative lg:top-auto lg:right-auto lg:h-full lg:w-auto lg:z-10">
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
<div className="console-panel rounded-lg bg-sunken flex flex-col overflow-hidden shadow-sm h-full fixed top-0 right-0 h-full w-[88vw] sm:w-[380px] z-40 lg:relative lg:top-auto lg:right-auto lg:h-full lg:w-auto lg:z-10">
|
||||
<div className="px-4 py-3 border-b border-subtle flex items-center justify-between bg-surface shrink-0">
|
||||
<span className="text-xs font-bold text-content">
|
||||
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowNotesPanel(false)}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
className="text-tertiary hover:text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签栏选择器 */}
|
||||
<div className="flex border-b border-slate-200 bg-white select-none shrink-0">
|
||||
<div className="flex border-b border-subtle bg-surface select-none shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarTab('notes')}
|
||||
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
||||
sidebarTab === 'notes'
|
||||
? 'border-blueprint text-blueprint'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
: 'border-transparent text-secondary hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
观测手札 ({notes.length})
|
||||
@ -110,7 +110,7 @@ export function ReaderNotesSidebar({
|
||||
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
||||
sidebarTab === 'ai'
|
||||
? 'border-blueprint text-blueprint'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
: 'border-transparent text-secondary hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
文献 AI 问答
|
||||
@ -121,14 +121,14 @@ export function ReaderNotesSidebar({
|
||||
{sidebarTab === 'notes' ? (
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{/* 天体标识符列表 */}
|
||||
<div className="px-4 py-3 border-b border-slate-200 bg-white space-y-2 shrink-0">
|
||||
<div className="px-4 py-3 border-b border-subtle bg-surface space-y-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">
|
||||
<span className="text-[10px] font-bold text-tertiary tracking-wider uppercase">
|
||||
关联天体 (CDS)
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 select-none">
|
||||
{(loadingTargets || identifyingTargets) && (
|
||||
<Loader className="w-3 h-3 animate-spin text-slate-400" />
|
||||
<Loader className="w-3 h-3 animate-spin text-tertiary" />
|
||||
)}
|
||||
{selectedPaper.has_markdown && (
|
||||
<button
|
||||
@ -137,7 +137,7 @@ export function ReaderNotesSidebar({
|
||||
handleIdentifyTargets(selectedPaper.bibcode)
|
||||
}
|
||||
disabled={identifyingTargets || loadingTargets}
|
||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-0.5"
|
||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-sky-50 dark:bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-500/30 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-0.5"
|
||||
title="从文献正文中自动识别天体目标并查询 CDS Sesame"
|
||||
>
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
@ -148,7 +148,7 @@ export function ReaderNotesSidebar({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{targets.length === 0 ? (
|
||||
<span className="text-[10px] text-slate-400 italic">
|
||||
<span className="text-[10px] text-tertiary italic">
|
||||
暂无自动识别到的天体
|
||||
</span>
|
||||
) : (
|
||||
@ -175,7 +175,7 @@ export function ReaderNotesSidebar({
|
||||
>
|
||||
{t.target_name}
|
||||
{t.spectral_type && (
|
||||
<span className="text-slate-400 font-medium font-mono">
|
||||
<span className="text-tertiary font-medium font-mono">
|
||||
({t.spectral_type})
|
||||
</span>
|
||||
)}
|
||||
@ -193,7 +193,7 @@ export function ReaderNotesSidebar({
|
||||
value={manualTargetName}
|
||||
onChange={(e) => setManualTargetName(e.target.value)}
|
||||
placeholder="手动关联天体(如 M 31)..."
|
||||
className="flex-1 bg-slate-50 border border-slate-200 rounded px-2.5 py-1 text-[10px] text-slate-900 placeholder-slate-400 leading-relaxed font-semibold focus:outline-none focus:bg-white focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10"
|
||||
className="flex-1 bg-sunken border border-default rounded px-2.5 py-1 text-[10px] text-content placeholder-tertiary leading-relaxed font-semibold focus:outline-none focus:bg-surface focus:border-blueprint focus:ring-1 focus:ring-blueprint/10"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@ -207,18 +207,18 @@ export function ReaderNotesSidebar({
|
||||
|
||||
{/* 新建笔记输入区 */}
|
||||
{selectedParagraphIdx !== null && (
|
||||
<div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3 shrink-0">
|
||||
<div className="text-xs font-bold text-slate-700">
|
||||
<div className="px-4 py-4 border-b border-subtle bg-surface space-y-3 shrink-0">
|
||||
<div className="text-xs font-bold text-secondary">
|
||||
正在批注段落 #{selectedParagraphIdx + 1}
|
||||
</div>
|
||||
{selectedText && (
|
||||
<div className="text-xs text-slate-600 italic line-clamp-2 bg-slate-55 px-2.5 py-1.5 rounded border border-slate-200">
|
||||
<div className="text-xs text-secondary italic line-clamp-2 bg-sunken px-2.5 py-1.5 rounded ">
|
||||
"{selectedText}"
|
||||
</div>
|
||||
)}
|
||||
{/* 颜色标记 */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-xs text-slate-500 font-semibold">
|
||||
<span className="text-xs text-secondary font-semibold">
|
||||
标记色:
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
@ -244,7 +244,7 @@ export function ReaderNotesSidebar({
|
||||
onChange={(e) => setNewNoteText(e.target.value)}
|
||||
placeholder="记录段落心得或重点..."
|
||||
rows={3}
|
||||
className="w-full bg-white border border-slate-300 rounded-lg text-xs text-slate-900 placeholder-slate-400 px-3 py-2 resize-none focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed"
|
||||
className="w-full bg-surface border border-default rounded-lg text-xs text-content placeholder-tertiary px-3 py-2 resize-none focus:outline-none focus:border-blueprint focus:ring-1 focus:ring-blueprint/10 leading-relaxed"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@ -270,8 +270,8 @@ export function ReaderNotesSidebar({
|
||||
{/* 笔记列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0 scrollbar-thin">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-center text-slate-400 text-xs py-12 space-y-2 select-none">
|
||||
<Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
|
||||
<div className="text-center text-tertiary text-xs py-12 space-y-2 select-none">
|
||||
<Pencil className="w-8 h-8 mx-auto opacity-30 text-secondary" />
|
||||
<div>选中文献正文的段落文字,即可在这里添加读书笔记。</div>
|
||||
</div>
|
||||
) : (
|
||||
@ -281,31 +281,31 @@ export function ReaderNotesSidebar({
|
||||
return (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-4 rounded-lg border text-xs bg-white border-slate-200 relative group overflow-hidden"
|
||||
className="p-4 rounded-lg border text-xs bg-surface border-subtle relative group overflow-hidden"
|
||||
>
|
||||
{/* 彩色左标记条 */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`}
|
||||
/>
|
||||
|
||||
<div className="text-slate-500 text-[10px] font-bold mb-1">
|
||||
<div className="text-secondary text-[10px] font-bold mb-1">
|
||||
段落批注 #{note.paragraph_index + 1}
|
||||
</div>
|
||||
{note.selected_text && (
|
||||
<div className="text-slate-505 italic line-clamp-2 mb-2 border-l-2 border-slate-200 pl-2">
|
||||
<div className="text-secondary italic line-clamp-2 mb-2 border-l-2 border-subtle pl-2">
|
||||
"{note.selected_text}"
|
||||
</div>
|
||||
)}
|
||||
<p className="text-slate-800 leading-relaxed font-medium">
|
||||
<p className="text-content leading-relaxed font-medium">
|
||||
{note.note_text}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 border-t border-slate-100 pt-2 text-[10px] select-none">
|
||||
<span className="text-slate-400 font-semibold">
|
||||
<div className="flex items-center justify-between mt-3 border-t border-subtle pt-2 text-[10px] select-none">
|
||||
<span className="text-tertiary font-semibold">
|
||||
{note.created_at.split('T')[0]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer"
|
||||
className="text-tertiary hover:text-red-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@ -70,15 +70,15 @@ export function ReaderToolbar({
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between border-b border-slate-200 pb-3 shrink-0 gap-3">
|
||||
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between border-b border-subtle pb-3 shrink-0 gap-3">
|
||||
<div className="min-w-0 w-full xl:flex-1 pr-0 xl:pr-4">
|
||||
<h2
|
||||
className="text-sm font-bold text-slate-900 line-clamp-1 leading-snug"
|
||||
className="text-sm font-bold text-content line-clamp-1 leading-snug"
|
||||
title={selectedPaper.title}
|
||||
>
|
||||
{selectedPaper.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 mt-1 font-semibold">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-secondary mt-1 font-semibold">
|
||||
<span>发表期刊: {selectedPaper.pub_journal || '未标注'}</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>文献编码: {selectedPaper.bibcode}</span>
|
||||
@ -93,7 +93,7 @@ export function ReaderToolbar({
|
||||
className="btn-console btn-console-secondary px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0"
|
||||
title="快速切换文献"
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
|
||||
<BookOpen className="w-3.5 h-3.5 text-blueprint" />
|
||||
<span className="hidden sm:inline">快速切换</span>
|
||||
<span className="sm:hidden">最近</span>
|
||||
<ChevronDown
|
||||
@ -107,12 +107,12 @@ export function ReaderToolbar({
|
||||
className="fixed inset-0 z-45"
|
||||
onClick={() => setShowSwitchMenu(false)}
|
||||
/>
|
||||
<div className="absolute left-0 xl:right-0 xl:left-auto mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
<div className="absolute left-0 xl:right-0 xl:left-auto mt-1.5 w-72 rounded-md bg-surface shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{/* 最近阅读部分 */}
|
||||
{recentlySelected.length > 0 && (
|
||||
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
||||
<History className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div className="border-b border-subtle pb-1.5 mb-1.5">
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-tertiary uppercase flex items-center gap-1">
|
||||
<History className="w-3.5 h-3.5 text-tertiary" />
|
||||
<span>最近阅读</span>
|
||||
</div>
|
||||
{recentlySelected.map((paper) => (
|
||||
@ -122,16 +122,16 @@ export function ReaderToolbar({
|
||||
onSwitchPaper(paper);
|
||||
setShowSwitchMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
className={`w-full px-3 py-2 text-left hover:bg-sunken transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
paper.bibcode === selectedPaper.bibcode
|
||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
: 'border-transparent text-secondary font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
<span className="text-[9px] text-tertiary font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
@ -141,12 +141,12 @@ export function ReaderToolbar({
|
||||
|
||||
{/* 全部馆藏已下载文献 */}
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-tertiary uppercase flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5 text-tertiary" />
|
||||
<span>全部已下载馆藏</span>
|
||||
</div>
|
||||
{library.filter((p) => p.is_downloaded).length === 0 ? (
|
||||
<div className="px-3 py-2 text-slate-400 italic">
|
||||
<div className="px-3 py-2 text-tertiary italic">
|
||||
暂无已下载文献
|
||||
</div>
|
||||
) : (
|
||||
@ -159,16 +159,16 @@ export function ReaderToolbar({
|
||||
onSwitchPaper(paper);
|
||||
setShowSwitchMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
className={`w-full px-3 py-2 text-left hover:bg-sunken transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
paper.bibcode === selectedPaper.bibcode
|
||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
: 'border-transparent text-secondary font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
<span className="text-[9px] text-tertiary font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
@ -182,14 +182,14 @@ export function ReaderToolbar({
|
||||
|
||||
{/* 原文/中文/对照视图切换按钮 */}
|
||||
{(englishText || chineseText) && (
|
||||
<div className="flex gap-0.5 sm:gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200 shrink-0">
|
||||
<div className="flex gap-0.5 sm:gap-1 bg-sunken p-0.5 rounded-lg shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('english')}
|
||||
className={`px-1.5 sm:px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
viewMode === 'english'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'bg-surface text-content shadow-sm'
|
||||
: 'text-secondary hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
原文
|
||||
@ -199,8 +199,8 @@ export function ReaderToolbar({
|
||||
onClick={() => setViewMode('chinese')}
|
||||
className={`px-1.5 sm:px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
viewMode === 'chinese'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'bg-surface text-content shadow-sm'
|
||||
: 'text-secondary hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
中文
|
||||
@ -210,8 +210,8 @@ export function ReaderToolbar({
|
||||
onClick={() => setViewMode('bilingual')}
|
||||
className={`px-1.5 sm:px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
viewMode === 'bilingual'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'bg-surface text-content shadow-sm'
|
||||
: 'text-secondary hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
对照
|
||||
@ -234,10 +234,10 @@ export function ReaderToolbar({
|
||||
}}
|
||||
className={`hidden xl:flex px-3 py-1.5 rounded-lg text-xs font-bold items-center gap-1.5 border transition-all cursor-pointer ${
|
||||
showPdf
|
||||
? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed opacity-70'
|
||||
? 'bg-sunken text-tertiary border-subtle cursor-not-allowed opacity-70'
|
||||
: syncScroll
|
||||
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-55'
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-500/30 shadow-sm'
|
||||
: 'bg-surface text-secondary border-subtle hover:bg-sunken'
|
||||
}`}
|
||||
title={
|
||||
showPdf
|
||||
@ -246,7 +246,7 @@ export function ReaderToolbar({
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${!showPdf && syncScroll ? 'bg-sky-500 animate-pulse' : 'bg-slate-300'}`}
|
||||
className={`w-1.5 h-1.5 rounded-full ${!showPdf && syncScroll ? 'bg-sky-500 dark:bg-sky-400 animate-pulse' : 'bg-slate-300'}`}
|
||||
/>
|
||||
<span>
|
||||
同步滚动:{showPdf ? 'PDF暂不支持' : syncScroll ? '开启' : '关闭'}
|
||||
@ -373,7 +373,7 @@ export function ReaderToolbar({
|
||||
className="btn-console btn-console-secondary px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0"
|
||||
title="更多文献操作"
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-sky-600" />
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-blueprint" />
|
||||
<span className="hidden sm:inline">文献操作</span>
|
||||
<span className="sm:hidden">操作</span>
|
||||
<ChevronDown
|
||||
@ -387,7 +387,7 @@ export function ReaderToolbar({
|
||||
className="fixed inset-0 z-45"
|
||||
onClick={() => setShowActionsMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1.5 w-max min-w-[145px] rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs">
|
||||
<div className="absolute right-0 mt-1.5 w-max min-w-[145px] rounded-md bg-surface shadow-md py-1 z-50 text-xs">
|
||||
{/* 同步滚动 */}
|
||||
{(englishText || chineseText) && viewMode === 'bilingual' && (
|
||||
<button
|
||||
@ -403,11 +403,11 @@ export function ReaderToolbar({
|
||||
className={`w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 font-medium cursor-pointer outline-none ${
|
||||
syncScroll
|
||||
? 'bg-blueprint/5 text-blueprint font-bold border-l-2 border-blueprint'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
: 'text-secondary hover:bg-sunken hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${!showPdf && syncScroll ? 'bg-sky-500 animate-pulse' : 'bg-slate-355'}`}
|
||||
className={`w-1.5 h-1.5 rounded-full ${!showPdf && syncScroll ? 'bg-sky-500 dark:bg-sky-400 animate-pulse' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
<span>同步滚动:{syncScroll ? '已开启' : '已关闭'}</span>
|
||||
</button>
|
||||
@ -421,9 +421,9 @@ export function ReaderToolbar({
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-secondary hover:bg-sunken hover:text-content font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-slate-400" />
|
||||
<FileText className="w-4 h-4 text-tertiary" />
|
||||
<span>{parsing ? '正文解析中...' : '解析源文正文'}</span>
|
||||
</button>
|
||||
) : (
|
||||
@ -439,9 +439,9 @@ export function ReaderToolbar({
|
||||
);
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-secondary hover:bg-sunken hover:text-content font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-400" />
|
||||
<RotateCw className="w-4 h-4 text-tertiary" />
|
||||
<span>重新解析正文</span>
|
||||
</button>
|
||||
)}
|
||||
@ -455,9 +455,9 @@ export function ReaderToolbar({
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
disabled={translating}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-secondary hover:bg-sunken hover:text-content font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<Languages className="w-4 h-4 text-slate-400" />
|
||||
<Languages className="w-4 h-4 text-tertiary" />
|
||||
<span>{translating ? '翻译中...' : '生成智能翻译'}</span>
|
||||
</button>
|
||||
)}
|
||||
@ -475,9 +475,9 @@ export function ReaderToolbar({
|
||||
);
|
||||
}}
|
||||
disabled={translating}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-secondary hover:bg-sunken hover:text-content font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-400" />
|
||||
<RotateCw className="w-4 h-4 text-tertiary" />
|
||||
<span>重新生成翻译</span>
|
||||
</button>
|
||||
)}
|
||||
@ -490,7 +490,7 @@ export function ReaderToolbar({
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-secondary hover:bg-sunken hover:text-content font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-amber-500" />
|
||||
<span>{vectorizing ? '知识入库中...' : '知识入库'}</span>
|
||||
@ -510,9 +510,9 @@ export function ReaderToolbar({
|
||||
);
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-secondary hover:bg-sunken hover:text-content font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-400" />
|
||||
<RotateCw className="w-4 h-4 text-tertiary" />
|
||||
<span>重新知识入库</span>
|
||||
</button>
|
||||
)}
|
||||
@ -528,7 +528,7 @@ export function ReaderToolbar({
|
||||
}}
|
||||
className={`btn-console px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0 ${
|
||||
showNotesPanel
|
||||
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-xs'
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-500/30'
|
||||
: 'btn-console-secondary'
|
||||
}`}
|
||||
title="开启或关闭侧边学术助手(包含阅读手札与 AI 问答)"
|
||||
@ -537,7 +537,7 @@ export function ReaderToolbar({
|
||||
<span className="hidden sm:inline">学术助手</span>
|
||||
<span className="sm:hidden">助手</span>
|
||||
{notesCount > 0 && (
|
||||
<span className="px-1.5 py-0.2 text-[9px] rounded-full bg-sky-100 text-sky-850 font-extrabold select-none shrink-0">
|
||||
<span className="px-1.5 py-0.2 text-[9px] rounded-full bg-sky-100 text-content font-extrabold select-none shrink-0">
|
||||
{notesCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -59,20 +59,20 @@ export function BatchPipelineCard({
|
||||
handleStopBatch,
|
||||
}: BatchPipelineCardProps) {
|
||||
return (
|
||||
<div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-6 relative overflow-hidden shadow-sm">
|
||||
<div className="console-panel p-6 rounded-lg bg-surface space-y-6 relative overflow-hidden shadow-sm">
|
||||
<div className="flex flex-col gap-1 select-none">
|
||||
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-slate-700" />
|
||||
<h3 className="text-xs font-bold text-content flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-secondary" />
|
||||
<span>馆藏文献批量学术流水线任务</span>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
<p className="text-secondary text-xs mt-0.5">
|
||||
针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown
|
||||
结构化排版解析以及中英双语对照翻译。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{batchError && (
|
||||
<div className="p-4 rounded-md bg-rose-50 border border-rose-200/60 flex gap-3 text-xs text-rose-800 items-start">
|
||||
<div className="p-4 rounded-md bg-rose-50 border border-rose-200/60 dark:bg-rose-500/10 dark:border-rose-500/30 flex gap-3 text-xs text-rose-800 dark:text-rose-300 items-start">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>{batchError}</div>
|
||||
</div>
|
||||
@ -81,7 +81,7 @@ export function BatchPipelineCard({
|
||||
{/* 条件设置 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
<label className="text-xs font-bold text-secondary block">
|
||||
目标阶段
|
||||
</label>
|
||||
<CustomSelect
|
||||
@ -104,7 +104,7 @@ export function BatchPipelineCard({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
<label className="text-xs font-bold text-secondary block">
|
||||
批量处理上限
|
||||
</label>
|
||||
<input
|
||||
@ -114,12 +114,12 @@ export function BatchPipelineCard({
|
||||
onChange={(e) =>
|
||||
setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
|
||||
className="w-full px-3 py-2 rounded-md bg-sunken text-content focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
<label className="text-xs font-bold text-secondary block">
|
||||
处理顺序
|
||||
</label>
|
||||
<CustomSelect
|
||||
@ -141,12 +141,12 @@ export function BatchPipelineCard({
|
||||
</div>
|
||||
|
||||
{/* 执行策略 */}
|
||||
<div className="space-y-2 border-t border-slate-100 pt-4">
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
<div className="space-y-2 border-t border-subtle pt-4">
|
||||
<label className="text-xs font-bold text-secondary block">
|
||||
执行策略
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 select-none">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipCompleted}
|
||||
@ -157,7 +157,7 @@ export function BatchPipelineCard({
|
||||
<span>跳过已完成</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipFailed}
|
||||
@ -168,7 +168,7 @@ export function BatchPipelineCard({
|
||||
<span>跳过当前失败</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipPrecedingFailed}
|
||||
@ -179,7 +179,7 @@ export function BatchPipelineCard({
|
||||
<span>跳过前置失败</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipPrecedingUncompleted}
|
||||
@ -193,13 +193,13 @@ export function BatchPipelineCard({
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex justify-end pt-2 border-t border-slate-100">
|
||||
<div className="flex justify-end pt-2 border-t border-subtle">
|
||||
<div className="w-full md:w-1/3 flex">
|
||||
{batchStatus.active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStopBatch}
|
||||
className="w-full py-2 rounded-md bg-rose-750 hover:bg-rose-855 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
|
||||
className="w-full py-2 rounded-md bg-danger hover:opacity-90 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
|
||||
>
|
||||
<StopCircle className="w-3.5 h-3.5" />
|
||||
停止批量任务
|
||||
@ -219,9 +219,9 @@ export function BatchPipelineCard({
|
||||
|
||||
{/* 进度与终端日志展示 */}
|
||||
{(batchStatus.active || batchStatus.total > 0) && (
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200">
|
||||
<div className="space-y-4 pt-4 border-t border-subtle">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
|
||||
<div className="flex justify-between text-xs font-bold text-secondary select-none">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{batchStatus.action === 'download' && (
|
||||
<Download className="w-3.5 h-3.5 text-blueprint" />
|
||||
@ -257,7 +257,7 @@ export function BatchPipelineCard({
|
||||
batchStatus.download_failed > 0) ||
|
||||
(batchStatus.action !== 'download' &&
|
||||
batchStatus.parse_failed > 0)) && (
|
||||
<span className="text-red-500 ml-2 font-bold">
|
||||
<span className="text-danger ml-2 font-bold">
|
||||
(失败{' '}
|
||||
{batchStatus.action === 'download'
|
||||
? batchStatus.download_failed
|
||||
@ -267,7 +267,7 @@ export function BatchPipelineCard({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
||||
<div className="w-full h-2 rounded-md bg-sunken overflow-hidden select-none">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
batchStatus.action === 'download'
|
||||
@ -302,11 +302,11 @@ export function BatchPipelineCard({
|
||||
</div>
|
||||
|
||||
{batchStatus.active && batchStatus.current_bibcode && (
|
||||
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
|
||||
<div className="text-xs font-bold text-secondary flex items-center gap-2">
|
||||
<Loader className="w-3.5 h-3.5 text-blueprint animate-spin" />
|
||||
<span>
|
||||
当前正在处理:{' '}
|
||||
<code className="bg-slate-100 px-2 py-0.5 rounded-md font-mono font-bold text-slate-800 border border-slate-200 select-all">
|
||||
<code className="bg-sunken px-2 py-0.5 rounded-md font-mono font-bold text-content select-all">
|
||||
{batchStatus.current_bibcode}
|
||||
</code>
|
||||
</span>
|
||||
@ -315,15 +315,15 @@ export function BatchPipelineCard({
|
||||
|
||||
{/* 滚动日志终端 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block select-none">
|
||||
<label className="text-xs font-bold text-secondary block select-none">
|
||||
实时处理日志流终端
|
||||
</label>
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-md h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all"
|
||||
className="bg-sunken text-content font-mono text-[11px] p-4 rounded-md h-48 overflow-y-auto space-y-1 scrollbar-thin relative select-all"
|
||||
>
|
||||
{batchStatus.logs.length === 0 ? (
|
||||
<div className="text-slate-400 italic select-none">
|
||||
<div className="text-tertiary italic select-none">
|
||||
等待数据流任务启动,暂无日志输出...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -22,13 +22,13 @@ export function BookmarkletToolCard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
|
||||
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
|
||||
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||||
<div className="console-panel p-6 rounded-lg bg-surface space-y-4 shadow-sm">
|
||||
<div className="flex flex-col gap-1 border-b border-subtle pb-2 select-none">
|
||||
<h3 className="text-xs font-bold text-content flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blueprint" />
|
||||
<span>浏览器快捷直推书签导入工具</span>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
|
||||
<p className="text-secondary text-xs mt-1 leading-relaxed">
|
||||
无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF
|
||||
人机验证或在学校内网成功打开 PDF/HTML
|
||||
页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch
|
||||
@ -36,10 +36,10 @@ export function BookmarkletToolCard() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5 text-xs text-slate-700">
|
||||
<div className="space-y-3.5 text-xs text-secondary">
|
||||
<div className="space-y-1.5 font-bold select-none">
|
||||
<span className="text-slate-500">第一步:安装书签到您的浏览器</span>
|
||||
<p className="text-[11px] text-slate-400 font-normal mt-1 leading-relaxed">
|
||||
<span className="text-secondary">第一步:安装书签到您的浏览器</span>
|
||||
<p className="text-[11px] text-tertiary font-normal mt-1 leading-relaxed">
|
||||
将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其
|
||||
URL 设为复制的 JavaScript 代码(点击右侧按钮复制):
|
||||
</p>
|
||||
@ -48,7 +48,7 @@ export function BookmarkletToolCard() {
|
||||
{/* 可直接拖拽的链接按钮 */}
|
||||
<a
|
||||
ref={linkRef}
|
||||
className="px-4 py-2 bg-blueprint hover:bg-slate-800 text-white rounded-md text-xs font-bold transition-all shadow-2xs cursor-move flex items-center gap-1.5"
|
||||
className="px-4 py-2 bg-blueprint hover:opacity-90 text-white rounded-md text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
|
||||
title="拖拽我到您的书签栏中"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
@ -69,7 +69,7 @@ export function BookmarkletToolCard() {
|
||||
|
||||
<button
|
||||
onClick={handleCopyCode}
|
||||
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-md text-[11px] font-bold transition-all cursor-pointer"
|
||||
className="px-3 py-2 bg-sunken hover:bg-border-subtle text-secondary rounded-md text-[11px] font-bold transition-all cursor-pointer"
|
||||
>
|
||||
复制 JavaScript 书签代码
|
||||
</button>
|
||||
@ -77,23 +77,23 @@ export function BookmarkletToolCard() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 font-bold mt-4 select-none">
|
||||
<span className="text-slate-500">第二步:如何使用书签直推?</span>
|
||||
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1 leading-relaxed">
|
||||
<span className="text-secondary">第二步:如何使用书签直推?</span>
|
||||
<ol className="list-decimal list-inside text-[11px] text-secondary font-normal pl-1 space-y-1.5 mt-1 leading-relaxed">
|
||||
<li>
|
||||
点击详情弹窗内的{' '}
|
||||
<span className="font-bold text-slate-750">
|
||||
<span className="font-bold text-secondary">
|
||||
BIBCODE / DOI / arXiv ID 链接
|
||||
</span>
|
||||
;
|
||||
</li>
|
||||
<li>
|
||||
页面跳转至文献原始网页后,直接
|
||||
<span className="font-bold text-slate-750">点击该浏览器书签</span>
|
||||
<span className="font-bold text-secondary">点击该浏览器书签</span>
|
||||
;
|
||||
</li>
|
||||
<li>
|
||||
书签脚本会自动读取并
|
||||
<span className="font-bold text-slate-750">
|
||||
<span className="font-bold text-secondary">
|
||||
自动填充好 Bibcode
|
||||
</span>
|
||||
,您只需点击确认,文件即可秒级自动上传录入至系统!
|
||||
|
||||
@ -63,15 +63,15 @@ export function MetadataSyncCard({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 控制面板卡片 */}
|
||||
<div className="console-panel p-6 rounded-lg space-y-6 relative overflow-hidden bg-white border border-slate-200">
|
||||
<div className="console-panel p-6 rounded-lg space-y-6 relative overflow-hidden bg-surface ">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
|
||||
<label className="text-xs font-bold text-secondary block flex justify-between items-center">
|
||||
<span>检索关键词 (Query)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBuilder(!showBuilder)}
|
||||
className="text-xs text-slate-655 hover:text-slate-850 font-bold flex items-center gap-1 cursor-pointer underline underline-offset-2"
|
||||
className="text-xs text-secondary hover:text-content font-bold flex items-center gap-1 cursor-pointer underline underline-offset-2"
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
|
||||
@ -83,12 +83,12 @@ export function MetadataSyncCard({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
disabled={status.active}
|
||||
placeholder="例如: hot subdwarf, Gaia BH1..."
|
||||
className="w-full px-4 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full px-4 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
|
||||
<div className="text-[11px] text-tertiary flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
|
||||
<span>高级格式:</span>
|
||||
<span>
|
||||
<code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">
|
||||
<code className="text-secondary bg-sunken px-1 py-0.2 rounded font-mono">
|
||||
author:"Althaus" AND year:2020-2023
|
||||
</code>
|
||||
</span>
|
||||
@ -96,7 +96,7 @@ export function MetadataSyncCard({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
<label className="text-xs font-bold text-secondary block">
|
||||
数据发布平台
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@ -112,7 +112,7 @@ export function MetadataSyncCard({
|
||||
onClick={() => setSource(src.id as 'all' | 'ads' | 'arxiv')}
|
||||
className={`flex-1 py-2 rounded-md text-xs font-bold border transition-all cursor-pointer ${
|
||||
source === src.id
|
||||
? 'bg-slate-100 border-slate-350 text-slate-855 shadow-2xs'
|
||||
? 'bg-sunken border-subtle text-content shadow-sm'
|
||||
: 'btn-console btn-console-secondary'
|
||||
}`}
|
||||
>
|
||||
@ -125,13 +125,13 @@ export function MetadataSyncCard({
|
||||
|
||||
{/* 动态表单生成器 */}
|
||||
{showBuilder && (
|
||||
<div className="p-4 rounded-md bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
|
||||
<div className="text-xs font-bold text-slate-700 flex justify-between items-center select-none">
|
||||
<div className="p-4 rounded-md bg-sunken space-y-3.5 transition-all">
|
||||
<div className="text-xs font-bold text-secondary flex justify-between items-center select-none">
|
||||
<span>高级条件生成器</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddRule}
|
||||
className="text-xs text-slate-655 hover:text-slate-855 font-bold cursor-pointer"
|
||||
className="text-xs text-secondary hover:text-content font-bold cursor-pointer"
|
||||
>
|
||||
+ 添加条件
|
||||
</button>
|
||||
@ -152,7 +152,7 @@ export function MetadataSyncCard({
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 text-center text-xs text-slate-500 font-semibold select-none">
|
||||
<div className="w-24 text-center text-xs text-secondary font-semibold select-none">
|
||||
筛选条件
|
||||
</div>
|
||||
)}
|
||||
@ -183,14 +183,14 @@ export function MetadataSyncCard({
|
||||
? '例如: Althaus'
|
||||
: '请输入检索词...'
|
||||
}
|
||||
className="flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint text-xs font-medium"
|
||||
className="flex-1 px-3 py-1.5 rounded-md bg-surface border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint text-xs font-medium"
|
||||
/>
|
||||
|
||||
{rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(idx)}
|
||||
className="text-slate-500 hover:text-slate-800 text-xs font-bold px-2 py-1.5 cursor-pointer"
|
||||
className="text-secondary hover:text-content text-xs font-bold px-2 py-1.5 cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
@ -201,11 +201,11 @@ export function MetadataSyncCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-subtle">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-secondary block flex items-center justify-between">
|
||||
<span>单次拉取最大上限</span>
|
||||
<span className="text-[11px] text-slate-450 font-normal">
|
||||
<span className="text-[11px] text-tertiary font-normal">
|
||||
防止请求超出频率被封禁 API
|
||||
</span>
|
||||
</label>
|
||||
@ -216,7 +216,7 @@ export function MetadataSyncCard({
|
||||
onChange={(e) =>
|
||||
setLimit(Math.max(1, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-full px-4 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
|
||||
className="w-full px-4 py-2 rounded-md bg-sunken text-content focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -225,7 +225,7 @@ export function MetadataSyncCard({
|
||||
type="button"
|
||||
disabled={status.active || estimating}
|
||||
onClick={handleEstimate}
|
||||
className="flex-1 py-2 rounded-md bg-white border border-slate-300 hover:bg-slate-50 text-slate-750 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer"
|
||||
className="flex-1 py-2 rounded-md bg-surface border border-default hover:bg-sunken text-secondary text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{estimating ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
@ -248,11 +248,11 @@ export function MetadataSyncCard({
|
||||
|
||||
{/* 预估结果 */}
|
||||
{estimatedCount !== null && !status.active && (
|
||||
<div className="p-4 rounded-md bg-slate-50 border border-slate-200 flex gap-3 text-xs text-slate-800 items-center select-none animate-in fade-in duration-250">
|
||||
<Info className="w-4 h-4 shrink-0 text-slate-500" />
|
||||
<div className="p-4 rounded-md bg-sunken flex gap-3 text-xs text-content items-center select-none animate-in fade-in duration-250">
|
||||
<Info className="w-4 h-4 shrink-0 text-secondary" />
|
||||
<div>
|
||||
检索库中估计有约{' '}
|
||||
<strong className="text-slate-900 font-bold">
|
||||
<strong className="text-content font-bold">
|
||||
{estimatedCount}
|
||||
</strong>{' '}
|
||||
篇文献记录。
|
||||
@ -266,10 +266,10 @@ export function MetadataSyncCard({
|
||||
|
||||
{/* 实时同步进度 */}
|
||||
{(status.active || status.synced > 0) && (
|
||||
<div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
|
||||
<div className="console-panel p-6 rounded-lg bg-surface space-y-4 shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-850 flex items-center gap-2">
|
||||
<h3 className="text-xs font-bold text-content flex items-center gap-2">
|
||||
{status.active ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 text-blueprint animate-spin" />
|
||||
@ -282,9 +282,9 @@ export function MetadataSyncCard({
|
||||
</>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold leading-relaxed select-all">
|
||||
<p className="text-secondary text-[11px] mt-1.5 font-semibold leading-relaxed select-all">
|
||||
检索条件:{' '}
|
||||
<code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">
|
||||
<code className="bg-sunken px-1 py-0.5 rounded text-secondary font-mono">
|
||||
{status.query}
|
||||
</code>{' '}
|
||||
• 平台:{' '}
|
||||
@ -295,12 +295,12 @@ export function MetadataSyncCard({
|
||||
: 'arXiv'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
<span className="text-xs font-bold text-content">
|
||||
{status.synced} / {status.total} 篇
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-3 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
||||
<div className="w-full h-3 rounded-md bg-sunken overflow-hidden select-none">
|
||||
<div
|
||||
className="h-full bg-blueprint transition-all duration-500 shadow-sm"
|
||||
style={{ width: `${percent}%` }}
|
||||
@ -309,7 +309,7 @@ export function MetadataSyncCard({
|
||||
|
||||
{status.active &&
|
||||
(status.source === 'all' || status.source === 'arxiv') ? (
|
||||
<div className="p-3 rounded-md bg-slate-50 border border-slate-200 border-l-4 border-l-amber-500 text-xs text-slate-700 flex items-start gap-1.5 select-none">
|
||||
<div className="p-3 rounded-md bg-sunken border-l-4 border-l-amber-500 text-xs text-secondary flex items-start gap-1.5 select-none">
|
||||
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
同步 arXiv 文献时系统会自动加设 3000
|
||||
|
||||
@ -18,7 +18,6 @@ import type {
|
||||
UnifiedSearchRequest,
|
||||
UnifiedSearchResult,
|
||||
ResolvedTarget,
|
||||
ProductSpec,
|
||||
} from '../components/observation/constants';
|
||||
|
||||
interface UseObservationProps {
|
||||
@ -345,18 +344,36 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
*/
|
||||
const downloadUnifiedSelected = useCallback(async () => {
|
||||
if (unifiedSelected.size === 0 || !unifiedResult) return [];
|
||||
// 收集 key → {source, product, subtype, source_id}
|
||||
// 收集 key → {source, product, subtype, release, version, ids}
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ spec: [string, ProductSpec]; ids: string[] }
|
||||
{
|
||||
source: string;
|
||||
product: string;
|
||||
subtype?: string;
|
||||
release?: string;
|
||||
version?: string;
|
||||
ids: string[];
|
||||
}
|
||||
>();
|
||||
for (const key of unifiedSelected) {
|
||||
const [source, product, subtype, ...idParts] = key.split('#');
|
||||
const source_id = idParts.join('#'); // source_id 理论上不含 #,但保守处理
|
||||
const groupKey = `${source}#${product}#${subtype}`;
|
||||
const parts = key.split('#');
|
||||
if (parts.length < 6) continue;
|
||||
const source = parts[0];
|
||||
const product = parts[1];
|
||||
const subtype = parts[2] || undefined;
|
||||
const release = parts[3] || undefined;
|
||||
const version = parts[4] || undefined;
|
||||
const source_id = parts.slice(5).join('#');
|
||||
|
||||
const groupKey = `${source}#${product}#${subtype ?? ''}#${release ?? ''}#${version ?? ''}`;
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, {
|
||||
spec: [source, { product, subtype: subtype || undefined }],
|
||||
source,
|
||||
product,
|
||||
subtype,
|
||||
release,
|
||||
version,
|
||||
ids: [],
|
||||
});
|
||||
}
|
||||
@ -367,28 +384,30 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
setDownloading(true);
|
||||
setDownloadError(null);
|
||||
try {
|
||||
for (const { spec, ids } of groups.values()) {
|
||||
for (const group of groups.values()) {
|
||||
try {
|
||||
const res = await axios.post<ObservationBatchResult>(
|
||||
'/api/observation/download',
|
||||
{
|
||||
source: spec[0],
|
||||
product: spec[1].product,
|
||||
subtype: spec[1].subtype || undefined,
|
||||
source: group.source,
|
||||
product: group.product,
|
||||
subtype: group.subtype,
|
||||
release: group.release,
|
||||
version: group.version,
|
||||
force: false,
|
||||
mode: 'identifiers',
|
||||
source_ids: ids,
|
||||
source_ids: group.ids,
|
||||
}
|
||||
);
|
||||
results.push(res.data);
|
||||
} catch (e: unknown) {
|
||||
// 单组失败不中断,记录为 failures 占位 batch
|
||||
results.push({
|
||||
source: spec[0],
|
||||
product: spec[1],
|
||||
source: group.source,
|
||||
product: { product: group.product, subtype: group.subtype },
|
||||
matched_count: 0,
|
||||
products: [],
|
||||
failures: ids.map((id) => ({
|
||||
failures: group.ids.map((id) => ({
|
||||
source_label: id,
|
||||
error: extractErrorMessage(e, '下载失败'),
|
||||
})),
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
routeToolCall,
|
||||
routeToolResult,
|
||||
routeTextDelta,
|
||||
setGlobalTools,
|
||||
getToolMeta,
|
||||
} from '../components/agent';
|
||||
import type { TimelineItem } from '../components/agent';
|
||||
import type { TimelineItem, AgentToolMeta } from '../components/agent';
|
||||
import type { SessionSummary, MessageRecord } from '../types';
|
||||
import { extractErrorMessage } from '../utils/apiError';
|
||||
|
||||
@ -198,7 +200,7 @@ export function useResearchAgent({
|
||||
(async () => {
|
||||
try {
|
||||
setLoadingSessions(true);
|
||||
const [sessionsRes, modesRes] = await Promise.all([
|
||||
const [sessionsRes, modesRes, toolsRes] = await Promise.all([
|
||||
axios.get<SessionSummary[]>('/api/chat/sessions'),
|
||||
axios.get<AgentMode[]>('/api/chat/modes').catch(() => ({
|
||||
data: [
|
||||
@ -222,10 +224,14 @@ export function useResearchAgent({
|
||||
},
|
||||
] as AgentMode[],
|
||||
})),
|
||||
axios.get<AgentToolMeta[]>('/api/chat/tools').catch(() => ({
|
||||
data: [] as AgentToolMeta[],
|
||||
})),
|
||||
]);
|
||||
if (!active) return;
|
||||
setSessions(sessionsRes.data);
|
||||
setAgentModes(modesRes.data);
|
||||
setGlobalTools(toolsRes.data);
|
||||
} catch (e) {
|
||||
console.error('初始化 Agent 数据失败:', e);
|
||||
} finally {
|
||||
@ -729,7 +735,9 @@ export function useResearchAgent({
|
||||
event.step,
|
||||
event.id,
|
||||
event.name,
|
||||
event.arguments
|
||||
event.arguments,
|
||||
event.is_internal,
|
||||
event.display_name
|
||||
),
|
||||
};
|
||||
});
|
||||
@ -749,7 +757,9 @@ export function useResearchAgent({
|
||||
event.name,
|
||||
event.output,
|
||||
event.is_error,
|
||||
event.metadata
|
||||
event.metadata,
|
||||
event.is_internal,
|
||||
event.display_name
|
||||
),
|
||||
};
|
||||
});
|
||||
@ -827,6 +837,25 @@ export function useResearchAgent({
|
||||
|
||||
const turns = groupMessagesIntoTurns(messages);
|
||||
|
||||
// Scan turns backwards to find the latest todo list from a successful todo_write call
|
||||
const activeTodos = (() => {
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turn = turns[i];
|
||||
const todoCall = turn.timeline.find(
|
||||
(item) => item.type === 'tool_call' && item.name === 'todo_write'
|
||||
);
|
||||
if (
|
||||
todoCall &&
|
||||
todoCall.type === 'tool_call' &&
|
||||
todoCall.arguments &&
|
||||
Array.isArray(todoCall.arguments.todos)
|
||||
) {
|
||||
return todoCall.arguments.todos;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
const toggleThought = (key: string) =>
|
||||
setExpandedThoughts((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
const toggleArgs = (tcId: string) =>
|
||||
@ -891,6 +920,7 @@ export function useResearchAgent({
|
||||
handleStop,
|
||||
handleSend,
|
||||
turns,
|
||||
activeTodos,
|
||||
toggleThought,
|
||||
toggleArgs,
|
||||
toggleResult,
|
||||
@ -981,12 +1011,15 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const meta = getToolMeta(tc.function.name);
|
||||
turn.timeline.push({
|
||||
type: 'tool_call',
|
||||
step: stepNum,
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
display_name: meta?.display_name || tc.function.name,
|
||||
arguments: parsedArgs,
|
||||
is_internal: meta?.is_internal || false,
|
||||
});
|
||||
}
|
||||
} else if (hasThought && hasContent) {
|
||||
@ -1081,12 +1114,15 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const meta = getToolMeta(`[sub] ${tc.function.name}`);
|
||||
children.push({
|
||||
type: 'tool_call',
|
||||
step: stepNum,
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
display_name: meta?.display_name || tc.function.name,
|
||||
arguments: parsedArgs,
|
||||
is_internal: meta?.is_internal || false,
|
||||
});
|
||||
}
|
||||
} else if (hasThought && hasContent) {
|
||||
|
||||
69
dashboard/src/hooks/useTheme.ts
Normal file
69
dashboard/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// dashboard/src/hooks/useTheme.ts
|
||||
//
|
||||
// 浅色 / 暗色双主题管理。
|
||||
// - main.tsx 已在渲染前根据 localStorage('astro_theme') / 系统偏好设置 .dark 类(防 FOUC)。
|
||||
// - 本 hook 同步 React 状态与 DOM 类,并提供切换/持久化。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
const STORAGE_KEY = 'astro_theme';
|
||||
|
||||
function readInitialTheme(): Theme {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(readInitialTheme);
|
||||
|
||||
// 写入 DOM + localStorage
|
||||
const applyTheme = useCallback((next: Theme) => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', next === 'dark');
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* localStorage 不可用时静默 */
|
||||
}
|
||||
setTheme(next);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next: Theme = prev === 'dark' ? 'light' : 'dark';
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', next === 'dark');
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 跟随系统偏好变化(仅当用户未显式选择时)
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
if (!stored) {
|
||||
document.documentElement.classList.toggle('dark', e.matches);
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return { theme, setTheme: applyTheme, toggleTheme };
|
||||
}
|
||||
@ -2,215 +2,429 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--color-main: var(--text-main);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-card: var(--bg-card);
|
||||
--color-sidebar: var(--bg-sidebar);
|
||||
--color-blueprint: var(--accent-blueprint);
|
||||
--color-star: var(--accent-star);
|
||||
--color-precision: var(--border-precision);
|
||||
/* 暗色变体跟随 .dark 类(手动切换),而非仅 prefers-color-scheme */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 设计令牌:三层架构(primitive → semantic → component)
|
||||
* :root 浅色语义层(暖纸张学术风)
|
||||
* .dark 暗色语义层覆盖(深空天文风 · Night Indigo)
|
||||
* @theme 将语义令牌桥接为 Tailwind 工具类
|
||||
* 旧令牌名(--accent-blueprint 等)作为别名保留,向后兼容
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color-scheme: light;
|
||||
|
||||
/* 极极简白蓝学术科技风配色 - 升级为暖调纸张感学术护眼色 */
|
||||
--bg-main: #faf8f5; /* 学术纸张底色 */
|
||||
--bg-card: #ffffff;
|
||||
--bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */
|
||||
--text-main: #1e293b; /* 优雅深石板灰,对比度更佳 */
|
||||
--text-muted: #475569; /* 中度石板灰,提高可读性 */
|
||||
/* ── 浅色语义层:暖纸张学术(统一暖中性 stone 色温,消除暖底+冷蓝灰线的割裂)── */
|
||||
--bg-app: #faf9f6; /* 暖白纸底 */
|
||||
--bg-surface: #ffffff; /* 卡片/面板(纯白,与暖底仅微差) */
|
||||
--bg-elevated: #ffffff; /* 悬浮面板/弹层 */
|
||||
--bg-sunken: #f4f2ed; /* 凹陷输入框/次级容器(暖浅灰) */
|
||||
|
||||
--accent-blueprint: #0369a1; /* 低饱和度学术蓝 */
|
||||
--text-primary: #292524; /* 暖墨黑(stone-800,去蓝调) */
|
||||
--text-secondary: #57534e; /* 暖深灰(stone-600) */
|
||||
--text-tertiary: #a8a29e; /* 暖中灰(stone-400,占位/禁用) */
|
||||
|
||||
--border-default: #4e4b45; /* 卡片/分隔线:调浅,弱化描边感 */
|
||||
--border-subtle: #f5f2ec; /* 更轻的暖分割线(近乎隐形) */
|
||||
--border-strong: #ddd7cc; /* 暖灰强调线(hover/激活) */
|
||||
|
||||
--accent-primary: #0369a1; /* 学术蓝 blueprint */
|
||||
--accent-primary-hover: #0d5988;
|
||||
--accent-on-primary: #ffffff;
|
||||
--accent-star: #b45309; /* 经典低饱和度琥珀橙 */
|
||||
--border-precision: #e2e8f0; /* 极细轻量级分割线 */
|
||||
|
||||
--success: #047857;
|
||||
--warning: #b45309;
|
||||
--danger: #dc2626;
|
||||
|
||||
/* 装饰光斑(登录页) */
|
||||
--blob-a: rgba(186, 230, 253, 0.35); /* sky-200 */
|
||||
--blob-b: rgba(199, 210, 254, 0.3); /* indigo-200 */
|
||||
|
||||
/* 引用星系 canvas 语义色(JS 读取) */
|
||||
--canvas-active: #0369a1;
|
||||
--canvas-inactive: #57534e;
|
||||
--canvas-reference: #b45309;
|
||||
--canvas-citation: #0e7490;
|
||||
--canvas-link: rgba(120, 113, 108, 0.22);
|
||||
--canvas-ring: rgba(120, 113, 108, 0.1);
|
||||
--canvas-pulse: rgba(3, 105, 161, 0.04);
|
||||
--canvas-label: #57534e;
|
||||
--canvas-label-hover: #0369a1;
|
||||
|
||||
/* 光谱图 SVG 语义色 */
|
||||
--plot-grid: #e7e3da;
|
||||
--plot-axis: #78716c;
|
||||
--plot-axis-strong: #a8a29e;
|
||||
--plot-tooltip-bg: #292524;
|
||||
--plot-tooltip-fg: #faf9f6;
|
||||
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* ── 旧令牌名别名(向后兼容,使现有 text-blueprint/bg-card 等零改动适配双主题)── */
|
||||
--bg-main: var(--bg-app);
|
||||
--bg-card: var(--bg-surface);
|
||||
--bg-sidebar: var(--bg-surface);
|
||||
--text-main: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--border-precision: var(--border-default);
|
||||
--accent-blueprint: var(--accent-primary);
|
||||
--accent-on-blueprint: var(--accent-on-primary);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── 暗色语义层:深空天文 · Night Indigo(取自 UI/UX 技能推荐)── */
|
||||
--bg-app: #0a0f1e; /* 深空底色 */
|
||||
--bg-surface: #131a2f; /* 卡片(深靛) */
|
||||
--bg-elevated: #1b2440; /* 悬浮面板/弹层 */
|
||||
--bg-sunken: #0d1426; /* 凹陷输入 */
|
||||
|
||||
--text-primary: #e8edf5; /* 柔和高对比白(去纯白刺眼) */
|
||||
--text-secondary: #8b98b3; /* 中性靛灰 */
|
||||
--text-tertiary: #5e6b85; /* 暗灰蓝 */
|
||||
|
||||
/* 边框压暗至极低不透明度,隐入深空底,避免"白色描边"感 */
|
||||
--border-default: rgba(207, 211, 225, 0.699);
|
||||
--border-subtle: rgba(139, 152, 179, 0.06);
|
||||
--border-strong: rgba(139, 152, 179, 0.18);
|
||||
|
||||
--accent-primary: #38bdf8; /* 亮天蓝,暗色下高对比 */
|
||||
--accent-primary-hover: #0ea5e9;
|
||||
--accent-on-primary: #0a0f1e; /* 深底文字 */
|
||||
--accent-star: #f59e0b; /* 提亮的琥珀 */
|
||||
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f87171;
|
||||
|
||||
--blob-a: rgba(56, 189, 248, 0.12);
|
||||
--blob-b: rgba(99, 102, 241, 0.12);
|
||||
|
||||
--canvas-active: #38bdf8;
|
||||
--canvas-inactive: #5e6b85;
|
||||
--canvas-reference: #f59e0b;
|
||||
--canvas-citation: #22d3ee;
|
||||
--canvas-link: rgba(139, 152, 179, 0.14);
|
||||
--canvas-ring: rgba(139, 152, 179, 0.07);
|
||||
--canvas-pulse: rgba(56, 189, 248, 0.05);
|
||||
--canvas-label: #8b98b3;
|
||||
--canvas-label-hover: #7dd3fc;
|
||||
|
||||
--plot-grid: rgba(139, 152, 179, 0.12);
|
||||
--plot-axis: #8b98b3;
|
||||
--plot-axis-strong: #5e6b85;
|
||||
--plot-tooltip-bg: #1b2440;
|
||||
--plot-tooltip-fg: #e8edf5;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* @theme:语义令牌 → Tailwind 工具类桥接
|
||||
* 可直接写 bg-surface / text-content / text-secondary / border-default / text-danger 等
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-surface: var(--bg-surface);
|
||||
--color-elevated: var(--bg-elevated);
|
||||
--color-sunken: var(--bg-sunken);
|
||||
--color-app: var(--bg-app);
|
||||
|
||||
--color-content: var(--text-primary);
|
||||
--color-secondary: var(--text-secondary);
|
||||
--color-tertiary: var(--text-tertiary);
|
||||
|
||||
--color-border-default: var(--border-default);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-strong: var(--border-strong);
|
||||
|
||||
--color-blueprint: var(--accent-primary);
|
||||
--color-star: var(--accent-star);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-danger: var(--danger);
|
||||
|
||||
/* 旧桥接(保留,向后兼容) */
|
||||
--color-main: var(--text-main);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-card: var(--bg-card);
|
||||
--color-sidebar: var(--bg-sidebar);
|
||||
--color-precision: var(--border-precision);
|
||||
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 自定义 border 实用工具类以匹配 components 里的直接引用 */
|
||||
@utility border-default {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
@utility border-subtle {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
@utility border-strong {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 基础排版与全局滚动条
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Custom premium scrollbar styling for light mode */
|
||||
/* 平滑主题切换过渡 */
|
||||
body,
|
||||
.btn-console,
|
||||
.select-console {
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f4f6f9;
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
background: var(--border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Premium clean panel cards */
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 组件类(全部令牌化,自动适配双主题)
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* 侧边栏右侧分隔:用极淡阴影代替硬边框,避免"竖黑线"感 */
|
||||
.sidebar-seam {
|
||||
box-shadow: 1px 0 0 0 var(--border-subtle);
|
||||
}
|
||||
|
||||
/* 精致学术卡片面板(微弱暖灰边框,配合石色双层阴影,支持高阶悬停微动与暗色顶部高光) */
|
||||
.console-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-precision);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); /* 弱化阴影,更显扁平学术感 */
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(120, 113, 108, 0.04),
|
||||
0 4px 12px 0 rgba(120, 113, 108, 0.03);
|
||||
transition:
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.dark .console-panel {
|
||||
border-color: var(--border-subtle);
|
||||
box-shadow:
|
||||
0 4px 20px 0 rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* 悬停微动交互:针对可点击卡片或指定交互面板 */
|
||||
.console-panel[class*='cursor-pointer']:hover,
|
||||
.console-panel-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow:
|
||||
0 6px 16px -2px rgba(120, 113, 108, 0.08),
|
||||
0 12px 24px -4px rgba(120, 113, 108, 0.06);
|
||||
}
|
||||
.dark .console-panel[class*='cursor-pointer']:hover,
|
||||
.dark .console-panel-interactive:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow:
|
||||
0 12px 28px -4px rgba(0, 0, 0, 0.5),
|
||||
0 8px 12px -6px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.console-panel-active {
|
||||
border-color: var(--accent-blueprint);
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent-blueprint),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
0 0 0 1px var(--accent-primary),
|
||||
0 4px 12px 0 rgba(3, 105, 161, 0.08);
|
||||
}
|
||||
.dark .console-panel-active {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent-primary),
|
||||
0 4px 24px 0 rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* High contrast clean console button */
|
||||
/* 按钮:默认 */
|
||||
.btn-console {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-precision);
|
||||
color: var(--text-main);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-console:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: var(--text-main);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background: var(--bg-sunken);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 按钮:主要 */
|
||||
.btn-console-primary {
|
||||
background: var(--accent-blueprint);
|
||||
border: 1px solid var(--accent-blueprint);
|
||||
color: #ffffff;
|
||||
background: var(--accent-primary);
|
||||
border: 1px solid var(--accent-primary);
|
||||
color: var(--accent-on-primary);
|
||||
}
|
||||
|
||||
.btn-console-primary:hover:not(:disabled) {
|
||||
background: #0d5988;
|
||||
border-color: #0d5988;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 4px 0 rgba(16, 107, 163, 0.2);
|
||||
background: var(--accent-primary-hover);
|
||||
border-color: var(--accent-primary-hover);
|
||||
color: var(--accent-on-primary);
|
||||
box-shadow: 0 2px 4px 0
|
||||
color-mix(in srgb, var(--accent-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* 按钮:次要 */
|
||||
.btn-console-secondary {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid var(--border-precision);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-console-secondary:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
color: var(--text-main);
|
||||
background: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Premium clean console select dropdown styling */
|
||||
/* 原生 select(已基本被 CustomSelect 取代,保留令牌化) */
|
||||
.select-console {
|
||||
display: inline-block;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid var(--border-precision);
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
padding: 0.5rem 1.75rem 0.5rem 0.625rem; /* padding-right leaves space for custom arrow */
|
||||
color: var(--text-main);
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1.75rem 0.5rem 0.625rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2364748b' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.25rem;
|
||||
}
|
||||
|
||||
.select-console:hover:not(:disabled) {
|
||||
border-color: #94a3b8;
|
||||
color: var(--text-main);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.select-console:focus {
|
||||
border-color: var(--accent-blueprint);
|
||||
box-shadow: 0 0 0 1px var(--accent-blueprint);
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px var(--accent-primary);
|
||||
}
|
||||
|
||||
.select-console:disabled {
|
||||
background-color: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
background-color: var(--bg-sunken);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* FTS search result highlights */
|
||||
/* FTS 搜索结果高亮 */
|
||||
mark {
|
||||
background-color: rgba(254, 240, 138, 0.7); /* translucent yellow bg */
|
||||
color: #854d0e; /* text-yellow-800 */
|
||||
background-color: color-mix(in srgb, var(--warning) 22%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* LaTeX-style academic publishing typography overrides */
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* LaTeX 学术排版覆盖(prose 表格/引用/公式)
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
.prose {
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-main);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Academic Three-Line Table (LaTeX booktabs style) */
|
||||
/* 统一代码块与行内代码的学术控制台主题配色 */
|
||||
.prose pre {
|
||||
background-color: var(--bg-sunken) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1rem !important;
|
||||
margin: 0.75rem 0 !important;
|
||||
}
|
||||
.prose code {
|
||||
color: var(--accent-primary) !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 0.85em !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
/* 去掉 Tailwind Typography 默认加在 code 前后的反引号 */
|
||||
.prose code::before,
|
||||
.prose code::after {
|
||||
content: '' !important;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent !important;
|
||||
color: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* 学术三线表(LaTeX booktabs) */
|
||||
.prose table {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
border-top: 2px solid #334155 !important; /* thick top rule */
|
||||
border-bottom: 2px solid #334155 !important; /* thick bottom rule */
|
||||
border-top: 2px solid var(--border-strong) !important;
|
||||
border-bottom: 2px solid var(--border-strong) !important;
|
||||
margin-top: 1.5em !important;
|
||||
margin-bottom: 1.5em !important;
|
||||
font-size: 0.85em !important;
|
||||
}
|
||||
|
||||
.prose thead {
|
||||
border-bottom: 1.2px solid #475569 !important; /* mid rule */
|
||||
border-bottom: 1.2px solid var(--text-tertiary) !important;
|
||||
}
|
||||
|
||||
.prose thead th {
|
||||
font-weight: 700 !important;
|
||||
color: #1e293b !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: 0.5em 0.75em !important;
|
||||
text-align: left !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.prose tbody tr {
|
||||
border-bottom: 0.5px solid #e2e8f0 !important; /* light horizontal line */
|
||||
border-bottom: 0.5px solid var(--border-subtle) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom: none !important; /* last row doesn't have thin border because of the thick table bottom rule */
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.prose tbody td {
|
||||
padding: 0.5em 0.75em !important;
|
||||
color: #334155 !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Remove vertical borders and striping that are generated by default */
|
||||
.prose table,
|
||||
.prose th,
|
||||
.prose td {
|
||||
@ -218,25 +432,24 @@ mark {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
/* LaTeX Blockquote */
|
||||
/* LaTeX 引用块 */
|
||||
.prose blockquote {
|
||||
font-style: italic !important;
|
||||
color: #475569 !important;
|
||||
border-left: 3px solid #64748b !important; /* solid charcoal left line */
|
||||
background-color: #fdfcf9 !important; /* warmer background for blockquotes */
|
||||
color: var(--text-secondary) !important;
|
||||
border-left: 3px solid var(--text-tertiary) !important;
|
||||
background-color: var(--bg-sunken) !important;
|
||||
padding: 0.75em 1.25em !important;
|
||||
margin: 1.25em 0 !important;
|
||||
border-radius: 2px !important;
|
||||
font-weight: 500 !important;
|
||||
quotes: none !important;
|
||||
}
|
||||
|
||||
.prose blockquote p::before,
|
||||
.prose blockquote p::after {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
/* Formulas and Math spacing */
|
||||
/* 公式间距 */
|
||||
.prose .katex-display {
|
||||
margin: 1.2em 0 !important;
|
||||
padding: 0.5em !important;
|
||||
|
||||
@ -3,6 +3,21 @@ import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
// ── 防 FOUC:在任何 React 渲染前确定主题 ──
|
||||
// localStorage 优先,其次跟随系统 prefers-color-scheme,默认浅色。
|
||||
(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('astro_theme');
|
||||
const prefersDark = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
const isDark = stored ? stored === 'dark' : prefersDark;
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
} catch {
|
||||
/* localStorage 不可用时静默回退到浅色 */
|
||||
}
|
||||
})();
|
||||
|
||||
// 注册 PWA Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
||||
@ -56,12 +56,12 @@ export function CitationPanel({
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between border-b border-slate-200 pb-3 gap-3">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between border-b border-subtle pb-3 gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
<h2 className="text-sm font-bold tracking-wider text-content uppercase">
|
||||
引用星系拓扑图谱
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
通过图谱层级快速 visual 展现当前文献的“参考文献 - 被引文献”关联脉络
|
||||
</p>
|
||||
</div>
|
||||
@ -88,12 +88,12 @@ export function CitationPanel({
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowSwitchMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
<div className="absolute right-0 mt-1.5 w-72 rounded-md bg-surface shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{/* 最近文献 */}
|
||||
{recentlySelected.length > 0 && (
|
||||
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
||||
<History className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div className="border-b border-subtle pb-1.5 mb-1.5">
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-tertiary uppercase flex items-center gap-1">
|
||||
<History className="w-3.5 h-3.5 text-tertiary" />
|
||||
<span>最近选择</span>
|
||||
</div>
|
||||
{recentlySelected.map((paper) => (
|
||||
@ -103,16 +103,16 @@ export function CitationPanel({
|
||||
onSwitchPaper(paper);
|
||||
setShowSwitchMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
className={`w-full px-3 py-2 text-left hover:bg-sunken transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
paper.bibcode === selectedPaper.bibcode
|
||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
: 'border-transparent text-secondary font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
<span className="text-[9px] text-tertiary font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
@ -122,12 +122,12 @@ export function CitationPanel({
|
||||
|
||||
{/* 全部已下载文献 */}
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-tertiary uppercase flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5 text-tertiary" />
|
||||
<span>全部已下载馆藏</span>
|
||||
</div>
|
||||
{library.filter((p) => p.is_downloaded).length === 0 ? (
|
||||
<div className="px-3 py-2 text-slate-400 italic">
|
||||
<div className="px-3 py-2 text-tertiary italic">
|
||||
暂无已下载文献
|
||||
</div>
|
||||
) : (
|
||||
@ -140,16 +140,16 @@ export function CitationPanel({
|
||||
onSwitchPaper(paper);
|
||||
setShowSwitchMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
className={`w-full px-3 py-2 text-left hover:bg-sunken transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||
paper.bibcode === selectedPaper.bibcode
|
||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
: 'border-transparent text-secondary font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
<span className="text-[9px] text-tertiary font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
@ -161,8 +161,8 @@ export function CitationPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-md shadow-sm">
|
||||
<span className="text-[11px] text-slate-500 font-bold">
|
||||
<div className="flex items-center gap-2 bg-surface px-3 py-1.5 rounded-md shadow-sm">
|
||||
<span className="text-[11px] text-secondary font-bold">
|
||||
展示节点上限:
|
||||
</span>
|
||||
<input
|
||||
@ -172,7 +172,7 @@ export function CitationPanel({
|
||||
step="5"
|
||||
value={nodeLimit}
|
||||
onChange={(e) => setNodeLimit(Number(e.target.value))}
|
||||
className="w-20 h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blueprint focus:outline-none"
|
||||
className="w-20 h-1.5 bg-border-subtle rounded-lg appearance-none cursor-pointer accent-blueprint focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs font-bold text-blueprint w-6 text-right">
|
||||
{nodeLimit}
|
||||
@ -193,16 +193,16 @@ export function CitationPanel({
|
||||
</div>
|
||||
|
||||
{loadingCitations ? (
|
||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
|
||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-secondary bg-surface">
|
||||
<Loader className="w-8 h-8 animate-spin text-blueprint mb-2" />
|
||||
<p className="text-xs font-bold text-slate-600">
|
||||
<p className="text-xs font-bold text-secondary">
|
||||
正在从馆藏数据库中提取引用拓扑数据...
|
||||
</p>
|
||||
</div>
|
||||
) : citationNetwork ? (
|
||||
<div className="flex-1 console-panel rounded-lg border border-slate-200 overflow-hidden relative flex flex-col md:flex-row bg-white min-h-0">
|
||||
<div className="flex-1 console-panel rounded-lg overflow-hidden relative flex flex-col md:flex-row bg-surface min-h-0">
|
||||
{/* 可视化画布 */}
|
||||
<div className="flex-1 relative bg-slate-50/30 min-h-[350px] md:min-h-0">
|
||||
<div className="flex-1 relative bg-sunken/60 min-h-[350px] md:min-h-0">
|
||||
<CitationGalaxyCanvas
|
||||
networks={citationHistory}
|
||||
activeNetwork={citationNetwork}
|
||||
@ -210,27 +210,27 @@ export function CitationPanel({
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧关系详情面板 */}
|
||||
<div className="w-full md:w-80 h-64 md:h-auto border-t md:border-t-0 md:border-l border-slate-200 bg-slate-50 p-6 space-y-6 overflow-y-auto scrollbar-thin">
|
||||
{/* 右侧关系详情面板(靠背景色阶与图谱区分层,无需描边) */}
|
||||
<div className="w-full md:w-80 h-64 md:h-auto bg-surface p-6 space-y-6 overflow-y-auto scrollbar-thin">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-blueprint tracking-wider block mb-2">
|
||||
● 中心焦点文献
|
||||
</span>
|
||||
<h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">
|
||||
<h4 className="text-xs font-bold text-content leading-relaxed font-sans">
|
||||
{citationNetwork.title}
|
||||
</h4>
|
||||
<p className="text-[10px] text-slate-600 font-mono mt-2 bg-slate-200/50 border border-slate-300/60 px-2 py-1.5 rounded select-all truncate">
|
||||
<p className="text-[10px] text-secondary font-mono mt-2 bg-border-subtle px-2 py-1.5 rounded select-all truncate">
|
||||
{citationNetwork.bibcode}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<span className="text-xs font-bold text-slate-700 block mb-2">
|
||||
<div className="border-t border-subtle pt-4">
|
||||
<span className="text-xs font-bold text-secondary block mb-2">
|
||||
参考文献 (引出 // 共 {citationNetwork.references.length} 篇)
|
||||
</span>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
||||
{citationNetwork.references.length === 0 ? (
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
<span className="text-xs text-tertiary italic">
|
||||
暂无参考文献数据
|
||||
</span>
|
||||
) : (
|
||||
@ -238,7 +238,7 @@ export function CitationPanel({
|
||||
<div
|
||||
key={bib}
|
||||
onClick={() => handleNodeClick(bib)}
|
||||
className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
||||
className="text-[10px] text-secondary hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-surface hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
||||
>
|
||||
{bib}
|
||||
</div>
|
||||
@ -247,13 +247,13 @@ export function CitationPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<span className="text-xs font-bold text-slate-700 block mb-2">
|
||||
<div className="border-t border-subtle pt-4">
|
||||
<span className="text-xs font-bold text-secondary block mb-2">
|
||||
被引文献 (引入 // 共 {citationNetwork.citations.length} 篇)
|
||||
</span>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
||||
{citationNetwork.citations.length === 0 ? (
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
<span className="text-xs text-tertiary italic">
|
||||
暂无被引文献数据
|
||||
</span>
|
||||
) : (
|
||||
@ -261,7 +261,7 @@ export function CitationPanel({
|
||||
<div
|
||||
key={bib}
|
||||
onClick={() => handleNodeClick(bib)}
|
||||
className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
||||
className="text-[10px] text-secondary hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-surface hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
||||
>
|
||||
{bib}
|
||||
</div>
|
||||
@ -272,9 +272,9 @@ export function CitationPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
|
||||
<GitFork className="w-12 h-12 mb-3 text-slate-300" />
|
||||
<p className="text-xs font-bold text-slate-500">
|
||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-secondary bg-surface">
|
||||
<GitFork className="w-12 h-12 mb-3 text-tertiary" />
|
||||
<p className="text-xs font-bold text-secondary">
|
||||
无法拉取当前选定目标的星系引用关系
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -16,6 +16,7 @@ import type { StandardPaper } from '../types';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { PaperCard } from '../components/PaperCard';
|
||||
import type { TabId } from '../components/layout/Sidebar';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LibraryPanelProps {
|
||||
library: StandardPaper[];
|
||||
@ -197,12 +198,12 @@ export function LibraryPanel({
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4 select-none">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-subtle pb-4 select-none">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
<h2 className="text-sm font-bold tracking-wider text-content uppercase">
|
||||
本地文献馆藏库
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
查看和整理已同步离线保存的本地文献资源
|
||||
</p>
|
||||
</div>
|
||||
@ -223,8 +224,8 @@ export function LibraryPanel({
|
||||
<div
|
||||
className={`px-4 py-2.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all select-none ${
|
||||
syncFeedback.type === 'success'
|
||||
? 'bg-emerald-50 border border-emerald-200/60 text-emerald-850'
|
||||
: 'bg-rose-50 border border-rose-200/60 text-rose-850'
|
||||
? 'bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200/60 dark:border-emerald-500/30 text-success dark:text-emerald-300'
|
||||
: 'bg-rose-50 dark:bg-rose-500/10 border border-rose-200/60 dark:border-rose-500/30 text-danger dark:text-rose-300'
|
||||
}`}
|
||||
>
|
||||
{syncFeedback.type === 'success' ? (
|
||||
@ -238,15 +239,15 @@ export function LibraryPanel({
|
||||
|
||||
{/* 搜索、筛选与排序工具栏 */}
|
||||
{(!isLibraryEmpty || !isNoFilters) && (
|
||||
<div className="flex flex-col gap-3.5 bg-white p-4 rounded-lg border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
|
||||
<div className="flex flex-col gap-3.5 bg-surface p-4 rounded-lg shadow-sm text-xs font-semibold text-secondary">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* 本地检索 */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
馆藏内检索
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary w-3.5 h-3.5" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
@ -255,7 +256,7 @@ export function LibraryPanel({
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="搜索文献标题、作者、摘要、Bibcode、arXiv ID 或 DOI..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full pl-9 pr-8 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
@ -264,7 +265,7 @@ export function LibraryPanel({
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-all p-0.5 rounded-full hover:bg-slate-100 cursor-pointer flex items-center justify-center"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-tertiary hover:text-secondary transition-all p-0.5 rounded-full hover:bg-sunken cursor-pointer flex items-center justify-center"
|
||||
title="清空检索内容"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
@ -275,7 +276,7 @@ export function LibraryPanel({
|
||||
|
||||
{/* 状态过滤 */}
|
||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
任务状态筛选
|
||||
</label>
|
||||
<CustomSelect
|
||||
@ -300,7 +301,7 @@ export function LibraryPanel({
|
||||
|
||||
{/* 文献类型筛选 */}
|
||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
文献类型筛选
|
||||
</label>
|
||||
<CustomSelect
|
||||
@ -330,7 +331,7 @@ export function LibraryPanel({
|
||||
|
||||
{/* 排序方式 */}
|
||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
列表排序方式
|
||||
</label>
|
||||
<CustomSelect
|
||||
@ -357,7 +358,7 @@ export function LibraryPanel({
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={`w-full sm:w-auto px-4 py-2 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center justify-center gap-1.5 cursor-pointer ${
|
||||
showAdvanced
|
||||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||||
? 'bg-sunken border-subtle text-content'
|
||||
: 'btn-console btn-console-secondary'
|
||||
}`}
|
||||
>
|
||||
@ -369,10 +370,10 @@ export function LibraryPanel({
|
||||
|
||||
{/* 展开的元数据细化筛选字段 */}
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3.5 border-t border-slate-100 w-full transition-all">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3.5 border-t border-subtle w-full transition-all">
|
||||
{/* 作者过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
按特定作者过滤
|
||||
</label>
|
||||
<input
|
||||
@ -383,13 +384,13 @@ export function LibraryPanel({
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="如: Althaus..."
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full px-3 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 年份过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
按特定发表年份过滤
|
||||
</label>
|
||||
<input
|
||||
@ -400,13 +401,13 @@ export function LibraryPanel({
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="如: 2023..."
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full px-3 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 期刊过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
按特定出版期刊过滤
|
||||
</label>
|
||||
<input
|
||||
@ -417,7 +418,7 @@ export function LibraryPanel({
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="如: ApJ 或 MNRAS..."
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full px-3 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -426,47 +427,60 @@ export function LibraryPanel({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="console-panel p-24 text-center border border-slate-200 bg-white rounded-lg select-none">
|
||||
<div className="console-panel p-24 text-center bg-surface rounded-lg select-none">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-blueprint mx-auto mb-4" />
|
||||
<p className="text-xs text-slate-500 font-bold">
|
||||
<p className="text-xs text-secondary font-bold">
|
||||
正在为您读取馆藏数据库并进行筛选...
|
||||
</p>
|
||||
</div>
|
||||
) : isLibraryEmpty ? (
|
||||
<div className="console-panel p-16 rounded-lg text-center border-2 border-dashed border-slate-300">
|
||||
<Library className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||
<h3 className="font-bold text-slate-800 text-sm mb-2">
|
||||
<div className="console-panel p-16 rounded-lg text-center border-2 border-dashed border-subtle relative overflow-hidden select-none">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05] pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at center, var(--color-blueprint) 0%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -6, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
|
||||
className="w-16 h-16 rounded-2xl bg-blueprint/10 dark:bg-blueprint/15 border border-blueprint/20 flex items-center justify-center text-blueprint mx-auto mb-5 relative z-10"
|
||||
>
|
||||
<Library className="w-8 h-8" />
|
||||
</motion.div>
|
||||
<h3 className="font-bold text-content text-sm mb-2 relative z-10">
|
||||
本地文献馆藏为空
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">
|
||||
<p className="text-xs text-secondary mb-6 max-w-md mx-auto relative z-10 font-medium leading-relaxed">
|
||||
您在检索控制台下载的天体文献会自动同步离线保存到这里,方便进行无网双语解析阅读。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className="btn-console btn-console-primary px-6 py-2.5 rounded-md text-xs font-bold cursor-pointer"
|
||||
className="btn-console btn-console-primary px-6 py-2.5 rounded-md text-xs font-bold cursor-pointer relative z-10 hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
前往检索文献资源
|
||||
</button>
|
||||
</div>
|
||||
) : library.length === 0 ? (
|
||||
<div className="console-panel p-12 rounded-lg text-center border border-slate-200 bg-white">
|
||||
<SlidersHorizontal className="w-10 h-10 text-slate-350 mx-auto mb-3" />
|
||||
<h3 className="font-bold text-slate-700 text-xs mb-1.5">
|
||||
<div className="console-panel p-12 rounded-lg text-center bg-surface">
|
||||
<SlidersHorizontal className="w-10 h-10 text-tertiary mx-auto mb-3" />
|
||||
<h3 className="font-bold text-secondary text-xs mb-1.5">
|
||||
未找到符合筛选条件的文献
|
||||
</h3>
|
||||
<p className="text-xs text-slate-455 max-w-sm mx-auto mb-4">
|
||||
<p className="text-xs text-tertiary max-w-sm mx-auto mb-4">
|
||||
请尝试修改您的馆藏检索关键词或放宽离线状态及高级元数据筛选条件。
|
||||
</p>
|
||||
<button
|
||||
onClick={handleResetFilters}
|
||||
className="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-655 rounded-md text-xs font-bold transition-all cursor-pointer"
|
||||
className="px-4 py-1.5 bg-sunken hover:bg-border-subtle text-secondary rounded-md text-xs font-bold transition-all cursor-pointer"
|
||||
>
|
||||
重置全部检索与过滤条件
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center text-xs text-slate-500 font-bold px-1 select-none">
|
||||
<div className="flex justify-between items-center text-xs text-secondary font-bold px-1 select-none">
|
||||
<span>正在显示当前分页文献 / 筛选后共 {totalCount} 篇</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页显示</span>
|
||||
@ -509,19 +523,19 @@ export function LibraryPanel({
|
||||
}
|
||||
className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${
|
||||
paper.has_vector
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200/60'
|
||||
? 'bg-amber-50 dark:bg-amber-500/10 text-warning dark:text-amber-300 border-amber-200/60 dark:border-amber-500/30'
|
||||
: paper.has_translation
|
||||
? 'bg-emerald-50 text-emerald-850 border-emerald-200/60'
|
||||
? 'bg-emerald-50 dark:bg-emerald-500/10 text-success dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-500/30'
|
||||
: paper.has_markdown
|
||||
? 'bg-blue-50 text-blue-800 border-blue-200/60'
|
||||
: paper.is_downloaded
|
||||
? 'bg-slate-100 text-slate-800 border-slate-250'
|
||||
? 'bg-sunken text-content border-subtle'
|
||||
: paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource'
|
||||
? 'bg-slate-50 text-slate-550 border-slate-200 cursor-help'
|
||||
? 'bg-sunken text-secondary border-subtle cursor-help'
|
||||
: paper.pdf_error || paper.html_error
|
||||
? 'bg-rose-50 text-rose-850 border-rose-200/60 cursor-help'
|
||||
: 'bg-slate-50 text-slate-550 border-slate-100'
|
||||
? 'bg-rose-50 dark:bg-rose-500/10 text-danger dark:text-rose-300 border-rose-200/60 dark:border-rose-500/30 cursor-help'
|
||||
: 'bg-sunken text-secondary border-subtle'
|
||||
}`}
|
||||
>
|
||||
{paper.has_vector
|
||||
@ -549,7 +563,7 @@ export function LibraryPanel({
|
||||
e.stopPropagation();
|
||||
onOpenReader(paper);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded bg-slate-100 hover:bg-slate-200 text-slate-800 border border-slate-250 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in"
|
||||
className="px-3 py-2 rounded bg-sunken hover:bg-border-subtle text-content font-bold transition-all text-[9px] flex items-center gap-1 shadow-sm cursor-pointer animate-fade-in min-h-[34px]"
|
||||
>
|
||||
<BookOpen className="w-3 h-3" />
|
||||
<span>阅读</span>
|
||||
@ -560,7 +574,7 @@ export function LibraryPanel({
|
||||
e.stopPropagation();
|
||||
onOpenCitation(paper);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded bg-slate-50 hover:bg-slate-150 text-slate-655 border border-slate-250 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in"
|
||||
className="px-3 py-2 rounded bg-sunken hover:bg-sunken text-secondary font-bold transition-all text-[9px] flex items-center gap-1 shadow-sm cursor-pointer animate-fade-in min-h-[34px]"
|
||||
>
|
||||
<GitFork className="w-3 h-3" />
|
||||
<span>图谱</span>
|
||||
@ -572,17 +586,17 @@ export function LibraryPanel({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
<span className="text-slate-400 text-[9px]">
|
||||
<span className="text-tertiary text-[9px]">
|
||||
{paper.has_translation ? '翻译' : '未翻译'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_vector ? 'bg-amber-500' : 'bg-slate-200'}`}
|
||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_vector ? 'bg-amber-500' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
<span className="text-slate-400 text-[9px]">
|
||||
<span className="text-tertiary text-[9px]">
|
||||
{paper.has_vector ? '向量库' : '未向量化'}
|
||||
</span>
|
||||
</div>
|
||||
@ -595,20 +609,20 @@ export function LibraryPanel({
|
||||
|
||||
{/* 分页控制面板 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border border-slate-200 bg-white px-5 py-4 rounded-xl shadow-xs select-none">
|
||||
<div className="flex items-center justify-between bg-surface px-5 py-4 rounded-xl select-none">
|
||||
{/* 移动端两键分页 */}
|
||||
<div className="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
className="relative inline-flex items-center rounded-lg border border-slate-250 bg-white px-4 py-2 text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-50 transition-all cursor-pointer"
|
||||
className="relative inline-flex items-center rounded-lg bg-surface px-4 py-2 text-xs font-bold text-secondary hover:bg-sunken disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
className="relative ml-3 inline-flex items-center rounded-lg border border-slate-250 bg-white px-4 py-2 text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-50 transition-all cursor-pointer"
|
||||
className="relative ml-3 inline-flex items-center rounded-lg bg-surface px-4 py-2 text-xs font-bold text-secondary hover:bg-sunken disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
@ -617,19 +631,17 @@ export function LibraryPanel({
|
||||
{/* 桌面端完整分页 */}
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-semibold">
|
||||
<p className="text-xs text-secondary font-semibold">
|
||||
显示第{' '}
|
||||
<span className="font-bold text-slate-900">
|
||||
<span className="font-bold text-content">
|
||||
{(currentPage - 1) * pageSize + 1}
|
||||
</span>{' '}
|
||||
至{' '}
|
||||
<span className="font-bold text-slate-900">
|
||||
<span className="font-bold text-content">
|
||||
{Math.min(currentPage * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
篇文献,共{' '}
|
||||
<span className="font-bold text-slate-900">
|
||||
{totalCount}
|
||||
</span>{' '}
|
||||
<span className="font-bold text-content">{totalCount}</span>{' '}
|
||||
篇文献
|
||||
</p>
|
||||
</div>
|
||||
@ -645,8 +657,8 @@ export function LibraryPanel({
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
className={`h-8 px-3 rounded-lg flex items-center justify-center text-xs font-bold transition-all ${
|
||||
currentPage <= 1
|
||||
? 'bg-slate-50 border border-slate-100 text-slate-400 cursor-not-allowed opacity-50'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 text-slate-650 hover:text-slate-800 cursor-pointer shadow-3xs hover:shadow-2xs'
|
||||
? 'bg-sunken text-tertiary cursor-not-allowed opacity-50'
|
||||
: 'bg-surface hover:bg-sunken text-secondary hover:text-content cursor-pointer hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<span>上一页</span>
|
||||
@ -666,8 +678,8 @@ export function LibraryPanel({
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`h-8 min-w-8 rounded-lg flex items-center justify-center text-xs font-bold transition-all cursor-pointer ${
|
||||
currentPage === pageNum
|
||||
? 'bg-blueprint text-white shadow-xs'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 text-slate-655 hover:text-slate-800 shadow-3xs'
|
||||
? 'bg-blueprint text-white'
|
||||
: 'bg-surface hover:bg-sunken text-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
@ -680,7 +692,7 @@ export function LibraryPanel({
|
||||
return (
|
||||
<span
|
||||
key={pageNum}
|
||||
className="h-8 min-w-8 flex items-center justify-center text-slate-400 text-xs font-bold"
|
||||
className="h-8 min-w-8 flex items-center justify-center text-tertiary text-xs font-bold"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
@ -694,8 +706,8 @@ export function LibraryPanel({
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
className={`h-8 px-3 rounded-lg flex items-center justify-center text-xs font-bold transition-all ${
|
||||
currentPage >= totalPages
|
||||
? 'bg-slate-50 border border-slate-100 text-slate-400 cursor-not-allowed opacity-50'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 text-slate-655 hover:text-slate-800 cursor-pointer shadow-3xs hover:shadow-2xs'
|
||||
? 'bg-sunken text-tertiary cursor-not-allowed opacity-50'
|
||||
: 'bg-surface hover:bg-sunken text-secondary hover:text-content cursor-pointer hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<span>下一页</span>
|
||||
@ -703,8 +715,8 @@ export function LibraryPanel({
|
||||
</nav>
|
||||
|
||||
{/* 快捷跳转输入框 */}
|
||||
<div className="flex items-center gap-1.5 border-l border-slate-200 pl-4 h-6">
|
||||
<span className="text-slate-450 text-[11px] font-semibold">
|
||||
<div className="flex items-center gap-1.5 border-l border-subtle pl-4 h-6">
|
||||
<span className="text-tertiary text-[11px] font-semibold">
|
||||
前往第
|
||||
</span>
|
||||
<input
|
||||
@ -713,9 +725,9 @@ export function LibraryPanel({
|
||||
onChange={handlePageInputChange}
|
||||
onKeyDown={handlePageInputKeyDown}
|
||||
onBlur={handlePageInputBlur}
|
||||
className="w-10 h-7 text-center rounded-md border border-slate-250 bg-slate-55/50 text-slate-800 text-xs font-bold focus:outline-none focus:border-blueprint focus:bg-white focus:ring-1 focus:ring-blueprint transition-all shadow-3xs"
|
||||
className="w-10 h-7 text-center rounded-md bg-sunken/50 text-content text-xs font-bold focus:outline-none focus:border-blueprint focus:bg-surface focus:ring-1 focus:ring-blueprint transition-all"
|
||||
/>
|
||||
<span className="text-slate-455 text-[11px] font-semibold">
|
||||
<span className="text-tertiary text-[11px] font-semibold">
|
||||
/ {totalPages} 页
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -118,23 +118,23 @@ export function ObservationPanel(props: ObservationPanelProps) {
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto space-y-6">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4 select-none">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-subtle pb-4 select-none">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
<h2 className="text-sm font-bold tracking-wider text-content uppercase">
|
||||
观测数据
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
检索、下载与浏览多源观测数据(光谱 / 光变曲线 / 测光 / 图像)
|
||||
</p>
|
||||
</div>
|
||||
{/* 二级视图切换 SegControl */}
|
||||
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-1 bg-sunken p-1 rounded-lg ">
|
||||
<button
|
||||
onClick={() => setView('search')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
|
||||
view === 'search'
|
||||
? 'bg-white text-slate-800 shadow-xs'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'bg-surface text-content'
|
||||
: 'text-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
@ -144,8 +144,8 @@ export function ObservationPanel(props: ObservationPanelProps) {
|
||||
onClick={() => setView('unified')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
|
||||
view === 'unified'
|
||||
? 'bg-white text-slate-800 shadow-xs'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'bg-surface text-content'
|
||||
: 'text-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
@ -155,8 +155,8 @@ export function ObservationPanel(props: ObservationPanelProps) {
|
||||
onClick={() => setView('library')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
|
||||
view === 'library'
|
||||
? 'bg-white text-slate-800 shadow-xs'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'bg-surface text-content'
|
||||
: 'text-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
@ -304,16 +304,16 @@ function SearchView({
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 表单卡片 */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-xs space-y-4">
|
||||
<div className="console-panel rounded-lg p-4 space-y-4">
|
||||
{/* 输入模式切换 */}
|
||||
<div className="flex items-center gap-2 text-xs font-bold">
|
||||
<span className="text-slate-500">输入模式:</span>
|
||||
<span className="text-secondary">输入模式:</span>
|
||||
<button
|
||||
onClick={() => setInputMode('coordinates')}
|
||||
className={`px-2.5 py-1 rounded-md transition-all ${
|
||||
inputMode === 'coordinates'
|
||||
? 'bg-blueprint text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-sunken text-secondary hover:bg-border-subtle'
|
||||
}`}
|
||||
>
|
||||
坐标锥形检索
|
||||
@ -323,7 +323,7 @@ function SearchView({
|
||||
className={`px-2.5 py-1 rounded-md transition-all ${
|
||||
inputMode === 'identifiers'
|
||||
? 'bg-blueprint text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-sunken text-secondary hover:bg-border-subtle'
|
||||
}`}
|
||||
>
|
||||
标识符直接下载
|
||||
@ -385,7 +385,7 @@ function SearchView({
|
||||
}
|
||||
>
|
||||
{releaseOptions.length === 0 ? (
|
||||
<div className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-200 text-slate-400 text-xs font-medium select-none">
|
||||
<div className="w-full px-2.5 py-2 rounded-md bg-sunken text-tertiary text-xs font-medium select-none">
|
||||
该源不区分版本
|
||||
</div>
|
||||
) : (
|
||||
@ -431,7 +431,7 @@ function SearchView({
|
||||
|
||||
{/* Internal DR 需登录提示 */}
|
||||
{currentReleaseNeedsAuth && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-2.5">
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 dark:text-rose-300 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-md p-2.5">
|
||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
{searchForm.release.toUpperCase()} 当前为 Internal 数据发布,需经
|
||||
@ -442,7 +442,7 @@ function SearchView({
|
||||
|
||||
{/* 交叉约束提示:当前 release 不支持该 subtype */}
|
||||
{subtypeBlocked && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-md p-2.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{subtypeBlocked}</span>
|
||||
{currentSpec?.subtypes && currentSpec.subtypes.length > 0 && (
|
||||
@ -476,7 +476,7 @@ function SearchView({
|
||||
setSearchForm((f) => ({ ...f, ra: e.target.value }))
|
||||
}
|
||||
placeholder="0~360"
|
||||
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
|
||||
className="w-full px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Dec(度)">
|
||||
@ -488,7 +488,7 @@ function SearchView({
|
||||
setSearchForm((f) => ({ ...f, dec: e.target.value }))
|
||||
}
|
||||
placeholder="-90~90"
|
||||
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
|
||||
className="w-full px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@ -503,7 +503,7 @@ function SearchView({
|
||||
setSearchForm((f) => ({ ...f, radius: e.target.value }))
|
||||
}
|
||||
placeholder={`默认 0.1,建议 ≤${currentSpec?.suggested_max_radius_deg ?? 1}°`}
|
||||
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
|
||||
className="w-full px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@ -514,7 +514,7 @@ function SearchView({
|
||||
currentSpec.suggested_max_radius_deg &&
|
||||
parseFloat(searchForm.radius) <=
|
||||
currentSpec.hard_max_radius_deg && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-md p-2.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
当前半径 {searchForm.radius}° 超出建议值(≤
|
||||
@ -536,7 +536,7 @@ function SearchView({
|
||||
)}
|
||||
{searching ? '检索中...' : '检索候选源'}
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
<span className="text-[10px] text-tertiary">
|
||||
先检索候选源,勾选后再下载;或直接"按坐标下载全部"
|
||||
</span>
|
||||
<button
|
||||
@ -564,7 +564,7 @@ function SearchView({
|
||||
? `格式:${identifierFormat},如 65214031805717376`
|
||||
: '输入源标识符,多个用逗号或换行分隔'
|
||||
}
|
||||
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
|
||||
className="w-full px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
@ -585,7 +585,7 @@ function SearchView({
|
||||
|
||||
{/* 检索错误 */}
|
||||
{searchError && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 dark:text-rose-300 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-md p-3">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{searchError}</span>
|
||||
</div>
|
||||
@ -595,10 +595,10 @@ function SearchView({
|
||||
{inputMode === 'coordinates' && searchResults.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||
<h3 className="text-xs font-bold text-secondary flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-blueprint" />
|
||||
候选源列表
|
||||
<span className="text-slate-400 font-normal">
|
||||
<span className="text-tertiary font-normal">
|
||||
(共 {searchResults.length} 条命中)
|
||||
</span>
|
||||
</h3>
|
||||
@ -609,14 +609,14 @@ function SearchView({
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="text-tertiary">|</span>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="text-slate-500 hover:underline font-bold"
|
||||
className="text-secondary hover:underline font-bold"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<span className="text-slate-400 ml-2">
|
||||
<span className="text-tertiary ml-2">
|
||||
已选 {selectedSourceIds.size}
|
||||
</span>
|
||||
</div>
|
||||
@ -632,7 +632,7 @@ function SearchView({
|
||||
className={`flex items-start gap-2 p-2.5 rounded-md border cursor-pointer transition-all ${
|
||||
checked
|
||||
? 'border-blueprint bg-blueprint/5 ring-1 ring-blueprint'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||
: 'border-subtle bg-surface hover:border-subtle'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
@ -648,11 +648,11 @@ function SearchView({
|
||||
>
|
||||
{theme.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-slate-700 truncate font-semibold">
|
||||
<span className="font-mono text-[10px] text-secondary truncate font-semibold">
|
||||
{c.source_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-slate-500">
|
||||
<div className="flex items-center gap-2 text-[10px] text-secondary">
|
||||
{c.ra != null && c.dec != null && (
|
||||
<span className="font-mono">
|
||||
({c.ra.toFixed(4)}, {c.dec.toFixed(4)})
|
||||
@ -692,15 +692,15 @@ function SearchView({
|
||||
!searchError &&
|
||||
searchForm.ra &&
|
||||
searchForm.dec && (
|
||||
<div className="text-center py-8 text-xs text-slate-400">
|
||||
<Telescope className="w-8 h-8 mx-auto mb-2 text-slate-300" />
|
||||
<div className="text-center py-8 text-xs text-tertiary">
|
||||
<Telescope className="w-8 h-8 mx-auto mb-2 text-tertiary" />
|
||||
该天区无匹配候选源,可尝试调整半径或更换数据源
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下载错误 */}
|
||||
{downloadError && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 dark:text-rose-300 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-md p-3">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{downloadError}</span>
|
||||
</div>
|
||||
@ -710,13 +710,13 @@ function SearchView({
|
||||
{downloadResult && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<h3 className="text-xs font-bold text-secondary flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-success" />
|
||||
下载结果
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setDownloadResult(null)}
|
||||
className="text-[10px] text-slate-400 hover:text-slate-600"
|
||||
className="text-[10px] text-tertiary hover:text-secondary"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
@ -776,25 +776,25 @@ function LibraryView({
|
||||
</div>
|
||||
|
||||
{/* 筛选工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 bg-white p-4 rounded-lg border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
|
||||
<div className="console-panel flex flex-col sm:flex-row gap-3 p-4 rounded-lg text-xs font-semibold text-secondary">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">
|
||||
<label className="block text-secondary font-bold">
|
||||
source_id 检索
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary w-3.5 h-3.5" />
|
||||
<input
|
||||
type="text"
|
||||
value={libSearch}
|
||||
onChange={(e) => setLibSearch(e.target.value)}
|
||||
placeholder="按 source_id 模糊匹配..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
className="w-full pl-9 pr-8 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-medium"
|
||||
/>
|
||||
{libSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-tertiary hover:text-secondary p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
@ -865,7 +865,7 @@ function LibraryView({
|
||||
|
||||
{/* 错误提示 */}
|
||||
{libraryError && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 dark:text-rose-300 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-md p-3">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{libraryError}</span>
|
||||
</div>
|
||||
@ -873,7 +873,7 @@ function LibraryView({
|
||||
|
||||
{/* 加载态 */}
|
||||
{libraryLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center py-12 text-secondary">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blueprint mb-2" />
|
||||
<span className="text-xs">正在加载缓存库...</span>
|
||||
</div>
|
||||
@ -881,8 +881,8 @@ function LibraryView({
|
||||
|
||||
{/* 空态 */}
|
||||
{!libraryLoading && libraryItems.length === 0 && !libraryError && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Database className="w-10 h-10 mb-3 text-slate-300" />
|
||||
<div className="flex flex-col items-center justify-center py-16 text-tertiary">
|
||||
<Database className="w-10 h-10 mb-3 text-tertiary" />
|
||||
<p className="text-xs font-medium">缓存库为空</p>
|
||||
<p className="text-[10px] mt-1">
|
||||
切换到「检索下载」视图获取观测数据后会自动缓存到此
|
||||
@ -893,17 +893,42 @@ function LibraryView({
|
||||
{/* 列表 */}
|
||||
{!libraryLoading && libraryItems.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* 移动端单栏列表 */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
{libraryItems.map((rec) => (
|
||||
<LibraryCard
|
||||
key={`${rec.source}|${rec.product}|${rec.source_id}`}
|
||||
key={`mob-${rec.source}|${rec.product}|${rec.source_id}`}
|
||||
rec={rec}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 桌面端双栏瀑布流 */}
|
||||
<div className="hidden md:flex md:flex-row gap-3 items-start">
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{libraryItems
|
||||
.filter((_, idx) => idx % 2 === 0)
|
||||
.map((rec) => (
|
||||
<LibraryCard
|
||||
key={`desk-l-${rec.source}|${rec.product}|${rec.source_id}`}
|
||||
rec={rec}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{libraryItems
|
||||
.filter((_, idx) => idx % 2 !== 0)
|
||||
.map((rec) => (
|
||||
<LibraryCard
|
||||
key={`desk-r-${rec.source}|${rec.product}|${rec.source_id}`}
|
||||
rec={rec}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分页栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 pt-4 border-t border-slate-200 text-xs font-semibold text-slate-600">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 pt-4 border-t border-subtle text-xs font-semibold text-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页</span>
|
||||
<CustomSelect
|
||||
@ -925,14 +950,14 @@ function LibraryView({
|
||||
<button
|
||||
onClick={() => setLibPage(1)}
|
||||
disabled={libPage <= 1}
|
||||
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
|
||||
className="btn-console px-2.5 py-1.5 rounded-md disabled:opacity-40 text-[10px] font-bold"
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLibPage(libPage - 1)}
|
||||
disabled={libPage <= 1}
|
||||
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
|
||||
className="btn-console px-2.5 py-1.5 rounded-md disabled:opacity-40 text-[10px] font-bold"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
@ -942,14 +967,14 @@ function LibraryView({
|
||||
<button
|
||||
onClick={() => setLibPage(libPage + 1)}
|
||||
disabled={libPage >= totalPages}
|
||||
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
|
||||
className="btn-console px-2.5 py-1.5 rounded-md disabled:opacity-40 text-[10px] font-bold"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLibPage(totalPages)}
|
||||
disabled={libPage >= totalPages}
|
||||
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
|
||||
className="btn-console px-2.5 py-1.5 rounded-md disabled:opacity-40 text-[10px] font-bold"
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
@ -980,7 +1005,7 @@ function Field({
|
||||
compact ? 'w-full sm:w-36 space-y-1.5 font-bold' : 'space-y-1.5'
|
||||
}
|
||||
>
|
||||
<label className="block text-slate-500 font-bold text-xs">{label}</label>
|
||||
<label className="block text-secondary font-bold text-xs">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@ -996,12 +1021,12 @@ function StatCard({
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-3 shadow-xs">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-500 tracking-wider uppercase mb-1.5">
|
||||
<div className="console-panel rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-secondary tracking-wider uppercase mb-1.5">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="text-base font-bold text-slate-800">{value}</div>
|
||||
<div className="text-base font-bold text-content">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1031,31 +1056,66 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
const { fetchPreview, getPreview } = useObservationPreview();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 点击卡片主体切换第一个产物的预览状态(排除按钮或下载链接的点击)
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest('button') ||
|
||||
target.closest('a') ||
|
||||
target.closest('input')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (artifacts.length > 0) {
|
||||
const firstIdx = 0;
|
||||
const previewKey = `${rec.source_id}#${firstIdx}`;
|
||||
const previewable = canPreview(rec.product, artifacts[firstIdx].format);
|
||||
if (!previewable) return;
|
||||
|
||||
const isOpen = expanded[previewKey] ?? false;
|
||||
const previewState = getPreview(rec.source_id, firstIdx);
|
||||
const next = !isOpen;
|
||||
setExpanded((m) => ({ ...m, [previewKey]: next }));
|
||||
if (next && !previewState.data && !previewState.loading) {
|
||||
fetchPreview(
|
||||
rec.source,
|
||||
rec.product,
|
||||
undefined,
|
||||
rec.source_id,
|
||||
firstIdx
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-3.5 shadow-xs hover:shadow-md hover:border-slate-300 transition-all">
|
||||
<div className="flex items-center justify-between mb-2 pb-2 border-b border-slate-100">
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="console-panel rounded-lg p-3.5 cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 pb-2 border-b border-subtle">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}
|
||||
>
|
||||
{theme.label}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-border-subtle text-secondary">
|
||||
{productLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-slate-400 font-mono">
|
||||
<span className="text-[9px] text-tertiary font-mono">
|
||||
{rec.created_at.replace('T', ' ').slice(0, 19)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
|
||||
<div className="text-[9px] font-bold text-tertiary tracking-widest uppercase">
|
||||
Source ID
|
||||
</div>
|
||||
<div
|
||||
className="font-mono text-xs text-slate-800 font-semibold truncate"
|
||||
className="font-mono text-xs text-content font-semibold truncate"
|
||||
title={rec.source_id}
|
||||
>
|
||||
{rec.source_id}
|
||||
@ -1063,10 +1123,10 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
</div>
|
||||
{rec.ra != null && rec.dec != null && (
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
|
||||
<div className="text-[9px] font-bold text-tertiary tracking-widest uppercase">
|
||||
坐标
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-slate-600">
|
||||
<div className="font-mono text-[10px] text-secondary">
|
||||
{rec.ra.toFixed(4)}, {rec.dec.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
@ -1074,7 +1134,7 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
</div>
|
||||
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-400 italic">产物清单缺失</p>
|
||||
<p className="text-[10px] text-tertiary italic">产物清单缺失</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{artifacts.map((a, i) => {
|
||||
@ -1098,8 +1158,8 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
};
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100">
|
||||
<span className="text-slate-500 flex items-center gap-1 min-w-0">
|
||||
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-subtle">
|
||||
<span className="text-secondary flex items-center gap-1 min-w-0">
|
||||
{a.band && (
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium shrink-0">
|
||||
{a.band}
|
||||
@ -1108,7 +1168,7 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
<span className="font-mono uppercase shrink-0">
|
||||
{a.format}
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
<span className="text-tertiary">
|
||||
·{' '}
|
||||
{typeof a.size === 'number'
|
||||
? formatFileSize(a.size)
|
||||
@ -1119,13 +1179,17 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
{previewable && (
|
||||
<button
|
||||
onClick={togglePreview}
|
||||
className="flex items-center gap-0.5 font-medium text-slate-500 hover:text-slate-800 transition-colors"
|
||||
className={`p-1 rounded transition-all flex items-center justify-center cursor-pointer ${
|
||||
isOpen
|
||||
? 'text-blueprint bg-blueprint/10 font-bold'
|
||||
: 'text-secondary hover:text-content hover:bg-sunken'
|
||||
}`}
|
||||
title="预览"
|
||||
>
|
||||
{previewState.loading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<LineChart className="w-3 h-3" />
|
||||
<LineChart className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@ -1133,9 +1197,10 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
href={`/api/files/${a.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
|
||||
className="p-1 rounded text-secondary hover:text-blueprint hover:bg-blueprint/10 transition-all flex items-center justify-center cursor-pointer"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@ -1143,13 +1208,13 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
{previewable && isOpen && (
|
||||
<div className="pl-2">
|
||||
{previewState.error ? (
|
||||
<p className="text-[10px] text-rose-500 py-1">
|
||||
<p className="text-[10px] text-danger py-1">
|
||||
{previewState.error}
|
||||
</p>
|
||||
) : previewState.data ? (
|
||||
<ObservationPreviewRenderer preview={previewState.data} />
|
||||
) : previewState.loading ? (
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-400 py-4 justify-center">
|
||||
<div className="flex items-center gap-1 text-[10px] text-tertiary py-4 justify-center">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
解析 FITS 中...
|
||||
</div>
|
||||
@ -1160,7 +1225,7 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
);
|
||||
})}
|
||||
{artifacts.length > 1 && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-400 pt-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-tertiary pt-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
<span>
|
||||
共 {artifacts.length} 个文件 · 合计 {formatFileSize(totalSize)}
|
||||
|
||||
@ -199,6 +199,7 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
highlightCandidates={state.highlightCandidates}
|
||||
hoveredTarget={state.hoveredTarget}
|
||||
hoverCardPos={state.hoverCardPos}
|
||||
selectedParagraphIdx={selectedParagraphIdx}
|
||||
>
|
||||
{/* 学术助手侧边栏 */}
|
||||
<ReaderNotesSidebar
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useResearchAgent } from '../hooks/useResearchAgent';
|
||||
import { AgentSessionSidebar } from '../components/agent/AgentSessionSidebar';
|
||||
import { AgentMessageList } from '../components/agent/AgentMessageList';
|
||||
@ -29,9 +30,12 @@ export function ResearchAgentPanel({
|
||||
library,
|
||||
}: ResearchAgentPanelProps) {
|
||||
const state = useResearchAgent({ showConfirm, showAlert });
|
||||
const [showTaskSidebar, setShowTaskSidebar] = useState(false);
|
||||
|
||||
const hasTodos = state.activeTodos && state.activeTodos.length > 0;
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-lg border border-slate-200 h-full lg:h-[calc(100vh-130px)] shadow-sm">
|
||||
<div className="w-full flex-1 flex overflow-hidden bg-sunken rounded-lg h-full lg:h-[calc(100vh-130px)]">
|
||||
{/* 左侧会话列表侧栏 */}
|
||||
<AgentSessionSidebar
|
||||
sessions={state.sessions}
|
||||
@ -51,7 +55,7 @@ export function ResearchAgentPanel({
|
||||
/>
|
||||
|
||||
{/* 右侧会话对话展示及输入面板 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-surface relative">
|
||||
<AgentMessageList
|
||||
turns={state.turns}
|
||||
activeTurn={state.activeTurn}
|
||||
@ -91,6 +95,10 @@ export function ResearchAgentPanel({
|
||||
setActiveTab={setActiveTab}
|
||||
citations={citations}
|
||||
library={library}
|
||||
showTaskSidebar={showTaskSidebar}
|
||||
setShowTaskSidebar={setShowTaskSidebar}
|
||||
hasTodos={hasTodos}
|
||||
activeTodos={state.activeTodos}
|
||||
/>
|
||||
|
||||
<AgentInputArea
|
||||
|
||||
@ -133,11 +133,11 @@ export function SearchPanel({
|
||||
return (
|
||||
<div className="space-y-6 w-full max-w-5xl mx-auto">
|
||||
{/* 标题 */}
|
||||
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
<div className="flex flex-col gap-1.5 border-b border-subtle pb-3">
|
||||
<h2 className="text-sm font-bold tracking-wider text-content uppercase">
|
||||
统一文献检索平台
|
||||
</h2>
|
||||
<p className="text-slate-500 text-xs">
|
||||
<p className="text-secondary text-xs">
|
||||
快速检索 NASA ADS 和 arXiv
|
||||
预印本学术文献,支持一键下载、格式转换与星系引用分析。
|
||||
</p>
|
||||
@ -148,13 +148,13 @@ export function SearchPanel({
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-tertiary w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')"
|
||||
className="w-full pl-12 pr-4 py-3 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 focus:bg-white transition-all text-sm font-medium"
|
||||
className="w-full pl-12 pr-4 py-3 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-secondary focus:bg-surface transition-all text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@ -163,7 +163,7 @@ export function SearchPanel({
|
||||
onClick={() => setShowBuilder(!showBuilder)}
|
||||
className={`px-4 py-3 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
|
||||
showBuilder
|
||||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||||
? 'bg-sunken border-subtle text-content'
|
||||
: 'btn-console'
|
||||
}`}
|
||||
>
|
||||
@ -187,13 +187,13 @@ export function SearchPanel({
|
||||
|
||||
{/* 条件构造器 */}
|
||||
{showBuilder && (
|
||||
<div className="p-4 rounded-md bg-slate-50 border border-slate-200 space-y-3 transition-all">
|
||||
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
|
||||
<div className="p-4 rounded-md bg-sunken space-y-3 transition-all">
|
||||
<div className="text-xs font-bold text-secondary flex justify-between items-center">
|
||||
<span>高级检索式条件构造器</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddRule}
|
||||
className="text-xs text-slate-700 hover:text-slate-900 font-bold"
|
||||
className="text-xs text-secondary hover:text-content font-bold"
|
||||
>
|
||||
+ 添加检索条件
|
||||
</button>
|
||||
@ -203,7 +203,7 @@ export function SearchPanel({
|
||||
{rules.map((rule, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-2 pb-3.5 sm:pb-0 border-b border-slate-200/60 sm:border-0 last:border-0 last:pb-0"
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-2 pb-3.5 sm:pb-0 border-b border-subtle sm:border-0 last:border-0 last:pb-0"
|
||||
>
|
||||
{idx > 0 ? (
|
||||
<CustomSelect
|
||||
@ -219,7 +219,7 @@ export function SearchPanel({
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full sm:w-24 text-left sm:text-center text-[10px] sm:text-xs text-slate-400 sm:text-slate-500 font-bold sm:font-semibold py-1 uppercase tracking-wider select-none">
|
||||
<div className="w-full sm:w-24 text-left sm:text-center text-[10px] sm:text-xs text-tertiary sm:text-secondary font-bold sm:font-semibold py-1 uppercase tracking-wider select-none">
|
||||
条件过滤
|
||||
</div>
|
||||
)}
|
||||
@ -239,27 +239,42 @@ export function SearchPanel({
|
||||
]}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={rule.val}
|
||||
onChange={(e) =>
|
||||
handleRuleChange(idx, 'val', e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
rule.field === 'year'
|
||||
? '例如: 2020-2023 或 2022'
|
||||
: rule.field === 'author'
|
||||
? '例如: Althaus'
|
||||
: '请输入检索词...'
|
||||
}
|
||||
className="w-full sm:flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
|
||||
/>
|
||||
<div className="w-full sm:flex-1 flex flex-col gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.val}
|
||||
onChange={(e) =>
|
||||
handleRuleChange(idx, 'val', e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
rule.field === 'year'
|
||||
? '例如: 2020-2023 或 2022'
|
||||
: rule.field === 'author'
|
||||
? '例如: Althaus'
|
||||
: '请输入检索词...'
|
||||
}
|
||||
className={`w-full px-3 py-1.5 rounded-md bg-surface border text-content placeholder-tertiary focus:outline-none text-xs font-medium ${
|
||||
rule.field === 'year' &&
|
||||
rule.val.trim() !== '' &&
|
||||
!/^\d{4}(-\d{4})?$/.test(rule.val.trim())
|
||||
? 'border-rose-400 focus:border-rose-500 bg-rose-50/5 dark:bg-rose-500/5'
|
||||
: 'border-default focus:border-secondary'
|
||||
}`}
|
||||
/>
|
||||
{rule.field === 'year' &&
|
||||
rule.val.trim() !== '' &&
|
||||
!/^\d{4}(-\d{4})?$/.test(rule.val.trim()) && (
|
||||
<span className="text-[10px] text-rose-500 font-bold ml-1 animate-pulse">
|
||||
建议格式: 2022 或 2020-2023
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(idx)}
|
||||
className="text-rose-600 hover:text-rose-800 text-xs font-bold px-3 py-1.5 self-end sm:self-auto border border-rose-100 rounded-md sm:border-0 bg-rose-50/20 sm:bg-transparent mt-1 sm:mt-0 transition-all cursor-pointer"
|
||||
className="text-danger hover:text-danger text-xs font-bold px-3 py-1.5 self-end sm:self-auto border border-rose-100 rounded-md sm:border-0 bg-rose-50/20 sm:bg-transparent mt-1 sm:mt-0 transition-all cursor-pointer"
|
||||
>
|
||||
删除条件
|
||||
</button>
|
||||
@ -270,32 +285,32 @@ export function SearchPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[11px] text-slate-500 flex flex-wrap gap-x-4 gap-y-1 px-1 items-center">
|
||||
<span className="font-semibold text-slate-650 flex items-center gap-1">
|
||||
<div className="text-[11px] text-secondary flex flex-wrap gap-x-4 gap-y-1 px-1 items-center">
|
||||
<span className="font-semibold text-secondary flex items-center gap-1">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
|
||||
检索语法小提示:
|
||||
</span>
|
||||
<span>
|
||||
作者检索:{' '}
|
||||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||||
<code className="bg-border-subtle px-1 py-0.5 rounded text-content font-mono text-[10px]">
|
||||
author:"Althaus"
|
||||
</code>
|
||||
</span>
|
||||
<span>
|
||||
标题检索:{' '}
|
||||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||||
<code className="bg-border-subtle px-1 py-0.5 rounded text-content font-mono text-[10px]">
|
||||
title:"hot subdwarf"
|
||||
</code>
|
||||
</span>
|
||||
<span>
|
||||
年份范围:{' '}
|
||||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||||
<code className="bg-border-subtle px-1 py-0.5 rounded text-content font-mono text-[10px]">
|
||||
year:2020-2023
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-slate-200">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-subtle">
|
||||
{/* 数据源平台 */}
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
@ -305,7 +320,7 @@ export function SearchPanel({
|
||||
].map((src) => (
|
||||
<label
|
||||
key={src.id}
|
||||
className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700"
|
||||
className="flex items-center gap-2 cursor-pointer text-xs font-bold text-secondary"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@ -319,8 +334,8 @@ export function SearchPanel({
|
||||
<span
|
||||
className={
|
||||
searchSource === src.id
|
||||
? 'text-slate-900 font-bold'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
? 'text-content font-bold'
|
||||
: 'text-secondary hover:text-secondary'
|
||||
}
|
||||
>
|
||||
{src.label}
|
||||
@ -332,7 +347,7 @@ export function SearchPanel({
|
||||
{/* 排序及每页条数 */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
<span className="text-xs font-semibold text-secondary">
|
||||
排序:
|
||||
</span>
|
||||
<CustomSelect
|
||||
@ -348,7 +363,7 @@ export function SearchPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
<span className="text-xs font-semibold text-secondary">
|
||||
每页显示:
|
||||
</span>
|
||||
<CustomSelect
|
||||
@ -383,11 +398,11 @@ export function SearchPanel({
|
||||
|
||||
{/* BibTeX 导出显示 */}
|
||||
{bibtexContent && (
|
||||
<div className="console-panel p-5 rounded-lg border border-slate-200 relative overflow-hidden bg-slate-50/20">
|
||||
<h3 className="text-xs font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX 导出成功
|
||||
<div className="console-panel p-5 rounded-lg relative overflow-hidden bg-slate-50/20">
|
||||
<h3 className="text-xs font-bold text-secondary mb-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-success" /> BibTeX 导出成功
|
||||
</h3>
|
||||
<pre className="bg-white p-4 rounded-md border border-slate-200 text-xs text-slate-800 font-mono overflow-x-auto whitespace-pre-wrap max-h-60 leading-relaxed shadow-inner">
|
||||
<pre className="bg-surface p-4 rounded-md text-xs text-content font-mono overflow-x-auto whitespace-pre-wrap max-h-60 leading-relaxed shadow-inner">
|
||||
{bibtexContent}
|
||||
</pre>
|
||||
<button
|
||||
@ -406,9 +421,9 @@ export function SearchPanel({
|
||||
<div className="relative min-h-[200px]">
|
||||
{searching && (
|
||||
<div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-lg">
|
||||
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-lg shadow-sm border border-slate-200 text-center">
|
||||
<Loader className="w-8 h-8 text-slate-600 animate-spin" />
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
<div className="flex flex-col items-center gap-3 bg-surface p-6 rounded-lg shadow-sm text-center">
|
||||
<Loader className="w-8 h-8 text-secondary animate-spin" />
|
||||
<span className="text-xs font-bold text-secondary">
|
||||
正在与学术服务器通讯,检索文献数据中...
|
||||
</span>
|
||||
</div>
|
||||
@ -432,12 +447,12 @@ export function SearchPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowDetail(paper)}
|
||||
className="px-3 py-1.5 rounded-md bg-white border border-slate-250 text-slate-655 hover:bg-slate-50 hover:border-slate-350 transition-all text-xs font-bold flex items-center cursor-pointer"
|
||||
className="px-3.5 py-2.5 min-h-[38px] rounded-md bg-surface border border-default text-secondary hover:bg-sunken hover:border-border-strong transition-all text-xs font-bold flex items-center cursor-pointer"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
{paper.is_downloaded ? (
|
||||
<span className="px-2.5 py-1 rounded-md bg-emerald-50 text-emerald-800 border border-emerald-200/60 text-xs font-bold flex items-center gap-1">
|
||||
<span className="px-2.5 py-1 rounded-md bg-emerald-50 dark:bg-emerald-500/10 text-emerald-800 dark:text-emerald-300 border border-emerald-200/60 dark:border-emerald-500/30 text-xs font-bold flex items-center gap-1">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> 已下载
|
||||
</span>
|
||||
) : (
|
||||
@ -446,18 +461,18 @@ export function SearchPanel({
|
||||
paper.html_error === 'no_resource' ? (
|
||||
<span
|
||||
title="已手动标记为【无有效全文资源】,批量下载时自动跳过"
|
||||
className="px-2 py-1 bg-slate-50 text-slate-600 border border-slate-200 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||||
className="px-2 py-1 bg-sunken text-secondary text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-slate-455" />{' '}
|
||||
<AlertTriangle className="w-3 h-3 text-tertiary" />{' '}
|
||||
无资源
|
||||
</span>
|
||||
) : (
|
||||
(paper.pdf_error || paper.html_error) && (
|
||||
<span
|
||||
title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`}
|
||||
className="px-2 py-1 bg-rose-50 text-rose-800 border border-rose-200/60 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||||
className="px-2 py-1 bg-rose-50 dark:bg-rose-500/10 text-rose-800 dark:text-rose-300 border border-rose-200/60 dark:border-rose-500/30 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-rose-600" />{' '}
|
||||
<AlertTriangle className="w-3 h-3 text-danger" />{' '}
|
||||
下载失败
|
||||
</span>
|
||||
)
|
||||
@ -465,7 +480,7 @@ export function SearchPanel({
|
||||
<button
|
||||
onClick={() => handleDownload(paper.bibcode)}
|
||||
disabled={isDownloading}
|
||||
className="btn-console btn-console-primary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1 transition-all"
|
||||
className="btn-console btn-console-primary px-3.5 py-2.5 min-h-[38px] rounded-md text-xs font-bold flex items-center gap-1 transition-all"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
@ -477,19 +492,19 @@ export function SearchPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer px-2.5 py-1.5 rounded-md bg-sunken hover:bg-sunken text-secondary transition-all text-xs font-semibold">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportingList.includes(paper.bibcode)}
|
||||
onChange={() => toggleExportItem(paper.bibcode)}
|
||||
className="rounded text-slate-700 border-slate-300"
|
||||
className="rounded text-secondary border-subtle"
|
||||
/>
|
||||
<span>选择</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
abstract={
|
||||
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal">
|
||||
<p className="text-xs text-secondary line-clamp-3 leading-relaxed mb-4 font-normal">
|
||||
{paper.abstract_text || '暂无文献摘要数据。'}
|
||||
</p>
|
||||
}
|
||||
@ -497,7 +512,7 @@ export function SearchPanel({
|
||||
<>
|
||||
<button
|
||||
onClick={() => openReader(paper)}
|
||||
className="px-4 py-1.5 rounded-md bg-slate-100 border border-slate-250 text-xs font-bold text-slate-800 hover:bg-slate-200 transition-all"
|
||||
className="px-4.5 py-2.5 min-h-[38px] rounded-md bg-sunken border border-default text-xs font-bold text-content hover:bg-border-subtle transition-all"
|
||||
>
|
||||
双语阅读对照
|
||||
</button>
|
||||
@ -507,7 +522,7 @@ export function SearchPanel({
|
||||
setActiveTab('citation');
|
||||
loadCitations(paper.bibcode, true);
|
||||
}}
|
||||
className="px-4 py-1.5 rounded-md bg-slate-50 border border-slate-250 text-xs font-bold text-slate-600 hover:border-slate-400 hover:text-slate-800 transition-all"
|
||||
className="px-4.5 py-2.5 min-h-[38px] rounded-md bg-sunken text-xs font-bold text-secondary hover:border-border-strong hover:text-content transition-all"
|
||||
>
|
||||
引用星系图
|
||||
</button>
|
||||
@ -518,14 +533,14 @@ export function SearchPanel({
|
||||
{paper.doi && <span>DOI: {paper.doi}</span>}
|
||||
<span>
|
||||
Bibcode:{' '}
|
||||
<span className="font-semibold text-slate-700">
|
||||
<span className="font-semibold text-secondary">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</span>
|
||||
{paper.citation_count > 0 && (
|
||||
<span>
|
||||
被引次数:{' '}
|
||||
<span className="text-sky-750 font-bold">
|
||||
<span className="text-secondary font-bold">
|
||||
{paper.citation_count}
|
||||
</span>
|
||||
</span>
|
||||
@ -540,16 +555,16 @@ export function SearchPanel({
|
||||
|
||||
{/* 分页控制栏 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-lg max-w-5xl mx-auto shadow-sm">
|
||||
<div className="flex items-center justify-between p-4 bg-surface rounded-lg max-w-5xl mx-auto shadow-sm">
|
||||
<button
|
||||
onClick={() => handlePageChange(searchStart - searchRows)}
|
||||
disabled={!hasPreviousPage || searching}
|
||||
className="px-4 py-2 border border-slate-250 rounded-md bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
|
||||
className="px-4 py-2 rounded-md bg-surface text-xs font-bold text-secondary hover:bg-sunken disabled:opacity-40 disabled:hover:bg-surface flex items-center gap-1 transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> 上一页
|
||||
</button>
|
||||
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
<span className="text-xs font-bold text-secondary">
|
||||
第 {currentPage} 页 (当前显示 {searchStart + 1} -{' '}
|
||||
{searchStart + searchResults.length} 条)
|
||||
</span>
|
||||
@ -557,7 +572,7 @@ export function SearchPanel({
|
||||
<button
|
||||
onClick={() => handlePageChange(searchStart + searchRows)}
|
||||
disabled={!hasNextPage || searching}
|
||||
className="px-4 py-2 border border-slate-250 rounded-md bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
|
||||
className="px-4 py-2 rounded-md bg-surface text-xs font-bold text-secondary hover:bg-sunken disabled:opacity-40 disabled:hover:bg-surface flex items-center gap-1 transition-all"
|
||||
>
|
||||
下一页 <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@ -11,11 +11,11 @@ export function SyncPanel() {
|
||||
return (
|
||||
<div className="space-y-6 w-full max-w-3xl mx-auto">
|
||||
{/* 标题 */}
|
||||
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3 select-none">
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
<div className="flex flex-col gap-1.5 border-b border-subtle pb-3 select-none">
|
||||
<h2 className="text-sm font-bold tracking-wider text-content uppercase">
|
||||
批量任务管理器
|
||||
</h2>
|
||||
<p className="text-slate-500 text-xs">
|
||||
<p className="text-secondary text-xs">
|
||||
设定检索关键词,在 NASA ADS 和 arXiv
|
||||
平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。
|
||||
</p>
|
||||
@ -23,7 +23,7 @@ export function SyncPanel() {
|
||||
|
||||
{/* 全局错误显示 */}
|
||||
{state.errorMsg && (
|
||||
<div className="p-4 rounded-md bg-rose-50 border border-rose-200/60 flex gap-3 text-xs text-rose-800 items-start">
|
||||
<div className="p-4 rounded-md bg-rose-50 dark:bg-rose-500/10 border border-rose-200/60 dark:border-rose-500/30 flex gap-3 text-xs text-rose-800 dark:text-rose-300 items-start">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div className="font-semibold">{state.errorMsg}</div>
|
||||
</div>
|
||||
@ -74,36 +74,36 @@ export function SyncPanel() {
|
||||
/>
|
||||
|
||||
{/* 3. 常用批量同步检索配置 */}
|
||||
<div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
|
||||
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
|
||||
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-slate-700" />
|
||||
<div className="console-panel p-6 rounded-lg bg-surface space-y-4 shadow-sm">
|
||||
<div className="flex flex-col gap-1 border-b border-subtle pb-2 select-none">
|
||||
<h3 className="text-xs font-bold text-content flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-secondary" />
|
||||
<span>常用批量同步检索配置</span>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
<p className="text-secondary text-xs mt-0.5">
|
||||
保存的历史批量元数据同步检索规则。可在此快速一键再次启动同步或加载检索参数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state.syncQueries.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-slate-400 italic select-none">
|
||||
<div className="text-center py-6 text-xs text-tertiary italic select-none">
|
||||
暂无已存检索配置。执行一次批量元数据同步后将自动去重记录在此。
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
|
||||
<div className="divide-y divide-border-subtle max-h-80 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{state.syncQueries.map((sq) => (
|
||||
<div
|
||||
key={sq.id}
|
||||
className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-slate-800 break-all select-all font-mono">
|
||||
<div className="font-bold text-content break-all select-all font-mono">
|
||||
{sq.query}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
|
||||
<div className="text-[10px] text-secondary flex items-center gap-2.5 font-semibold">
|
||||
<span>
|
||||
数据源:{' '}
|
||||
<strong className="text-slate-700">
|
||||
<strong className="text-secondary">
|
||||
{sq.source === 'all'
|
||||
? '全部'
|
||||
: sq.source === 'ads'
|
||||
@ -113,13 +113,13 @@ export function SyncPanel() {
|
||||
</span>
|
||||
<span>
|
||||
数量限制:{' '}
|
||||
<strong className="text-slate-700">
|
||||
<strong className="text-secondary">
|
||||
{sq.limit_count}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
最近同步:{' '}
|
||||
<strong className="text-slate-700">{sq.last_run}</strong>
|
||||
<strong className="text-secondary">{sq.last_run}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -127,7 +127,7 @@ export function SyncPanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.handleReuseQuery(sq)}
|
||||
className="px-2.5 py-1.5 rounded-md bg-slate-50 border border-slate-250 text-slate-655 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
|
||||
className="px-2.5 py-1.5 rounded-md bg-sunken border border-default text-secondary hover:bg-sunken hover:text-content transition-all text-xs font-bold cursor-pointer"
|
||||
>
|
||||
加载
|
||||
</button>
|
||||
@ -135,14 +135,14 @@ export function SyncPanel() {
|
||||
type="button"
|
||||
disabled={state.status.active}
|
||||
onClick={() => state.handleQuickSync(sq)}
|
||||
className="px-2.5 py-1.5 rounded-md bg-slate-100 border border-slate-250 text-slate-800 hover:bg-slate-200 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
|
||||
className="px-2.5 py-1.5 rounded-md bg-sunken border border-default text-content hover:bg-border-subtle transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
一键同步
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.handleDeleteQuery(sq.id)}
|
||||
className="px-2.5 py-1.5 rounded-md bg-rose-50 border border-rose-200/60 text-rose-855 hover:bg-rose-100 transition-all text-xs font-bold cursor-pointer"
|
||||
className="px-2.5 py-1.5 rounded-md bg-rose-50 dark:bg-rose-500/10 border border-rose-200/60 dark:border-rose-500/30 text-rose-700 dark:text-rose-300 hover:bg-rose-100 transition-all text-xs font-bold cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
|
||||
@ -6,89 +6,110 @@ export const getDoctypeBadge = (doctype: string) => {
|
||||
const typeMap: Record<string, { label: string; style: string }> = {
|
||||
article: {
|
||||
label: '期刊文章',
|
||||
style: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
style:
|
||||
'bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-500/30',
|
||||
},
|
||||
eprint: {
|
||||
label: '预印本',
|
||||
style: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
style:
|
||||
'bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-500/30',
|
||||
},
|
||||
inproceedings: {
|
||||
label: '会议论文',
|
||||
style: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
style:
|
||||
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
|
||||
},
|
||||
proceedings: {
|
||||
label: '会议集',
|
||||
style: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
style:
|
||||
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
|
||||
},
|
||||
proposal: {
|
||||
label: '观测提案',
|
||||
style: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
style:
|
||||
'bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-300 border-rose-200 dark:border-rose-500/30',
|
||||
},
|
||||
abstract: {
|
||||
label: '会议摘要',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
style:
|
||||
'bg-sunken dark:bg-slate-500/10 text-secondary dark:text-slate-300 border-subtle dark:border-slate-500/30',
|
||||
},
|
||||
catalog: {
|
||||
label: '星表数据',
|
||||
style: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
style:
|
||||
'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-500/30',
|
||||
},
|
||||
dataset: {
|
||||
label: '星表数据',
|
||||
style: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
style:
|
||||
'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-500/30',
|
||||
},
|
||||
software: {
|
||||
label: '软件代码',
|
||||
style: 'bg-teal-50 text-teal-700 border-teal-200',
|
||||
style:
|
||||
'bg-teal-50 dark:bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-500/30',
|
||||
},
|
||||
phdthesis: {
|
||||
label: '博士论文',
|
||||
style: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
style:
|
||||
'bg-cyan-50 dark:bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 border-cyan-200 dark:border-cyan-500/30',
|
||||
},
|
||||
mastersthesis: {
|
||||
label: '硕士论文',
|
||||
style: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
style:
|
||||
'bg-cyan-50 dark:bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 border-cyan-200 dark:border-cyan-500/30',
|
||||
},
|
||||
circular: {
|
||||
label: '天文电报',
|
||||
style: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||
style:
|
||||
'bg-orange-50 dark:bg-orange-500/10 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-500/30',
|
||||
},
|
||||
inbook: {
|
||||
label: '图书章节',
|
||||
style: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
style:
|
||||
'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-500/30',
|
||||
},
|
||||
book: {
|
||||
label: '学术专著',
|
||||
style: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
style:
|
||||
'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-500/30',
|
||||
},
|
||||
editorial: {
|
||||
label: '期刊社论',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
style:
|
||||
'bg-sunken dark:bg-slate-500/10 text-secondary dark:text-slate-300 border-subtle dark:border-slate-500/30',
|
||||
},
|
||||
erratum: {
|
||||
label: '勘误说明',
|
||||
style: 'bg-red-50 text-red-700 border-red-200',
|
||||
style:
|
||||
'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border-red-200 dark:border-red-500/30',
|
||||
},
|
||||
misc: {
|
||||
label: '其他文献',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
style:
|
||||
'bg-sunken dark:bg-slate-500/10 text-secondary dark:text-slate-300 border-subtle dark:border-slate-500/30',
|
||||
},
|
||||
newsletter: {
|
||||
label: '简报新闻',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
style:
|
||||
'bg-sunken dark:bg-slate-500/10 text-secondary dark:text-slate-300 border-subtle dark:border-slate-500/30',
|
||||
},
|
||||
techreport: {
|
||||
label: '技术报告',
|
||||
style: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
style:
|
||||
'bg-cyan-50 dark:bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 border-cyan-200 dark:border-cyan-500/30',
|
||||
},
|
||||
obituary: {
|
||||
label: '讣告',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
style:
|
||||
'bg-sunken dark:bg-slate-500/10 text-secondary dark:text-slate-300 border-subtle dark:border-slate-500/30',
|
||||
},
|
||||
};
|
||||
const val = doctype ? doctype.toLowerCase() : 'article';
|
||||
const match = typeMap[val] || {
|
||||
label: '文献',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
style:
|
||||
'bg-sunken dark:bg-slate-500/10 text-secondary dark:text-slate-300 border-subtle dark:border-slate-500/30',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
|
||||
22
deploy.sh
22
deploy.sh
@ -15,6 +15,23 @@ IMAGE_NAME="astroresearch"
|
||||
TAG="latest"
|
||||
TAR_NAME="astroresearch.tar.gz"
|
||||
|
||||
# SSH ControlMaster 配置 —— 复用同一连接,只需输入一次密码
|
||||
SSH_CONTROL_DIR="$HOME/.ssh/cm"
|
||||
SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}"
|
||||
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_PATH} -o ControlPersist=1800"
|
||||
|
||||
mkdir -p "${SSH_CONTROL_DIR}"
|
||||
|
||||
cleanup_control_master() {
|
||||
# 关闭 master 连接
|
||||
ssh -p ${REMOTE_PORT} -o ControlPath=${SSH_CONTROL_PATH} ${REMOTE_USER}@${REMOTE_IP} -O exit 2>/dev/null || true
|
||||
}
|
||||
trap cleanup_control_master EXIT
|
||||
|
||||
# 立即建立 SSH master 连接,提前输入密码,后续步骤复用此连接
|
||||
echo "=== 连接服务器 (请立即输入密码) ==="
|
||||
ssh -p ${REMOTE_PORT} ${SSH_OPTS} -fN ${REMOTE_USER}@${REMOTE_IP}
|
||||
|
||||
echo "=== 1. 开始在本地构建 Docker 镜像 (基于 Dockerfile.modeB) ==="
|
||||
docker build -f Dockerfile.modeB -t ${IMAGE_NAME}:${TAG} .
|
||||
|
||||
@ -22,11 +39,12 @@ echo "=== 2. 将镜像导出并压缩为本地临时文件 ${TAR_NAME} ==="
|
||||
docker save ${IMAGE_NAME}:${TAG} | gzip > ${TAR_NAME}
|
||||
|
||||
echo "=== 3. 上传镜像包到服务器目标目录 ==="
|
||||
scp -P ${REMOTE_PORT} ${TAR_NAME} ${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/
|
||||
# 首次连接会要求输入密码,后续 scp/ssh 复用此连接
|
||||
scp -P ${REMOTE_PORT} ${SSH_OPTS} ${TAR_NAME} ${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/
|
||||
|
||||
echo "=== 4. 连接服务器:载入镜像并使用 Docker Compose 启动 ==="
|
||||
# 使用 'EOF' 锁死环境,防止本地变量混淆远程环境变量
|
||||
ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_IP} << 'EOF'
|
||||
ssh -p ${REMOTE_PORT} ${SSH_OPTS} ${REMOTE_USER}@${REMOTE_IP} << 'EOF'
|
||||
set -e
|
||||
|
||||
# 远程运行参数
|
||||
|
||||
@ -719,7 +719,7 @@ fn spawn_memory_extraction(
|
||||
crate::agent::subagent::SubAgentRunner::new_with_registry(app.clone(), tool_registry)
|
||||
.with_parent_session(sid.clone())
|
||||
.with_thinking(false)
|
||||
.with_llm_client(app.fast_llm.clone());
|
||||
.with_llm_client(app.llm.fast.clone());
|
||||
|
||||
let prompt = format!(
|
||||
"以下内容来自上下文压缩时被丢弃的对话片段。请从中提取值得持久化保存的关键信息。\n\n\
|
||||
|
||||
@ -175,7 +175,7 @@ pub async fn run_extraction(
|
||||
|
||||
// 构建子代理
|
||||
let runner = SubAgentRunner::new_with_registry(app_state.clone(), tool_registry)
|
||||
.with_llm_client(app_state.fast_llm.clone());
|
||||
.with_llm_client(app_state.llm.fast.clone());
|
||||
|
||||
// 构建提示词
|
||||
let prompt = build_extraction_prompt(20, &existing_manifest);
|
||||
|
||||
@ -21,8 +21,12 @@ pub enum AgentStreamEvent {
|
||||
/// LLM 生成的工具调用 ID,用于全链路关联(前端/审计/持久化)
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
arguments: serde_json::Value,
|
||||
step: usize,
|
||||
#[serde(default)]
|
||||
is_internal: bool,
|
||||
},
|
||||
/// 工具执行结果(Observation)
|
||||
#[serde(rename = "tool_result")]
|
||||
@ -30,10 +34,14 @@ pub enum AgentStreamEvent {
|
||||
/// 对应的工具调用 ID,前端凭此精确匹配 tool_call 条目
|
||||
tool_call_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
metadata: serde_json::Value,
|
||||
step: usize,
|
||||
#[serde(default)]
|
||||
is_internal: bool,
|
||||
},
|
||||
/// 文本增量流式输出(最终回答或工具流式输出)
|
||||
#[serde(rename = "text_delta")]
|
||||
|
||||
@ -111,19 +111,28 @@ pub(super) async fn process_single_result(
|
||||
additional_contexts: &mut Vec<String>,
|
||||
db: &SqlitePool,
|
||||
turn_index: i32,
|
||||
tool_registry: &crate::agent::tools::ToolRegistry,
|
||||
) {
|
||||
use crate::agent::tools::persist::maybe_persist_tool_result;
|
||||
|
||||
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
|
||||
|
||||
let (is_internal, display_name) = if let Some(tool) = tool_registry.get(tool_name) {
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, tool_name.to_string())
|
||||
};
|
||||
|
||||
// SSE 事件 — 立即推送到前端
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
name: tool_name.to_string(),
|
||||
display_name,
|
||||
output: output.content.clone(),
|
||||
is_error: output.is_error,
|
||||
metadata: output.metadata.clone(),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
|
||||
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
|
||||
|
||||
@ -41,6 +41,7 @@ pub fn validate_and_prepare(
|
||||
duplicate_detector: &mut DuplicateDetector,
|
||||
duplicate_threshold: usize,
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
tool_registry: &ToolRegistry,
|
||||
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
|
||||
db: &SqlitePool,
|
||||
session_id: &str,
|
||||
@ -88,13 +89,20 @@ pub fn validate_and_prepare(
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let error_output = format!("工具参数 JSON 解析失败: {}", e);
|
||||
let (is_internal, display_name) = if let Some(tool) = tool_registry.get(tool_name) {
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, tool_name.clone())
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: call_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
display_name,
|
||||
output: error_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
let tool_msg = ChatMessage::tool_result(&call_id, &error_output);
|
||||
save_tool_message_sync(db, session_id, turn_index, step, &tool_msg);
|
||||
@ -158,11 +166,18 @@ pub async fn execute_parallel(
|
||||
|
||||
// Phase 1: 发送 ToolCall SSE 事件
|
||||
for prep in prepared_calls {
|
||||
let (is_internal, display_name) = if let Some(tool) = tool_registry.get(&prep.tool_name) {
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, prep.tool_name.clone())
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolCall {
|
||||
id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
display_name,
|
||||
arguments: prep.args.clone(),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
}
|
||||
|
||||
@ -260,9 +275,16 @@ pub async fn execute_parallel(
|
||||
hardline_result.reason
|
||||
);
|
||||
let err_output = hardline_result.reason.clone();
|
||||
let (is_internal, display_name) = if let Some(tool) = tool_registry.get(&prep.tool_name)
|
||||
{
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, prep.tool_name.clone())
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
display_name,
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({
|
||||
@ -270,6 +292,7 @@ pub async fn execute_parallel(
|
||||
"hardline_category": hardline_result.category,
|
||||
}),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
@ -364,13 +387,21 @@ pub async fn execute_parallel(
|
||||
);
|
||||
let err_output =
|
||||
format!("工具 {} 被权限规则拒绝执行: {}", prep.tool_name, reason);
|
||||
let (is_internal, display_name) =
|
||||
if let Some(tool) = tool_registry.get(&prep.tool_name) {
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, prep.tool_name.clone())
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
display_name,
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
@ -413,7 +444,7 @@ pub async fn execute_parallel(
|
||||
|
||||
// 存储待处理的权限请求
|
||||
{
|
||||
let mut perms = app_state.pending_permissions.lock().await;
|
||||
let mut perms = app_state.session.pending_permissions.lock().await;
|
||||
perms.insert(
|
||||
perm_id.clone(),
|
||||
PendingPermission {
|
||||
@ -422,6 +453,7 @@ pub async fn execute_parallel(
|
||||
message: message.clone(),
|
||||
arguments: prep.args.clone(),
|
||||
response_tx: resp_tx,
|
||||
created_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -431,7 +463,12 @@ pub async fn execute_parallel(
|
||||
let perm_result = tokio::time::timeout(timeout_dur, resp_rx).await;
|
||||
|
||||
// 清理待处理的权限请求
|
||||
app_state.pending_permissions.lock().await.remove(&perm_id);
|
||||
app_state
|
||||
.session
|
||||
.pending_permissions
|
||||
.lock()
|
||||
.await
|
||||
.remove(&perm_id);
|
||||
|
||||
match perm_result {
|
||||
Ok(Ok(response)) if response.allowed => {
|
||||
@ -451,13 +488,21 @@ pub async fn execute_parallel(
|
||||
// 用户拒绝
|
||||
info!("[Executor] 用户拒绝了工具 {}", prep.tool_name);
|
||||
let err_output = format!("用户拒绝了工具 {} 的执行", prep.tool_name);
|
||||
let (is_internal, display_name) =
|
||||
if let Some(tool) = tool_registry.get(&prep.tool_name) {
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, prep.tool_name.clone())
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
display_name,
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
@ -482,13 +527,21 @@ pub async fn execute_parallel(
|
||||
warn!("[Executor] 权限请求超时或取消: {}", prep.tool_name);
|
||||
let err_output =
|
||||
format!("权限请求超时 (120s): {} 未获得用户确认", prep.tool_name);
|
||||
let (is_internal, display_name) =
|
||||
if let Some(tool) = tool_registry.get(&prep.tool_name) {
|
||||
(tool.is_internal(), tool.display_name().to_string())
|
||||
} else {
|
||||
(false, prep.tool_name.clone())
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
display_name,
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
@ -538,7 +591,7 @@ pub async fn execute_parallel(
|
||||
let cancel_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
if app_state_ref.cancelled_runs.contains_key(&sid_ref) {
|
||||
if app_state_ref.session.cancelled_runs.contains_key(&sid_ref) {
|
||||
cancel_flag.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
@ -658,7 +711,7 @@ pub async fn execute_parallel(
|
||||
exec_start,
|
||||
tx,
|
||||
hook_registry,
|
||||
&app_state.config.library_dir,
|
||||
&app_state.config.storage.library_dir,
|
||||
&sid,
|
||||
agent_name,
|
||||
step,
|
||||
@ -667,6 +720,7 @@ pub async fn execute_parallel(
|
||||
&mut additional_contexts,
|
||||
db,
|
||||
turn_index,
|
||||
tool_registry,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@ -713,7 +767,7 @@ pub async fn execute_parallel(
|
||||
exec_start,
|
||||
tx,
|
||||
hook_registry,
|
||||
&app_state.config.library_dir,
|
||||
&app_state.config.storage.library_dir,
|
||||
&sid,
|
||||
agent_name,
|
||||
step,
|
||||
@ -722,6 +776,7 @@ pub async fn execute_parallel(
|
||||
&mut additional_contexts,
|
||||
db,
|
||||
turn_index,
|
||||
tool_registry,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ impl AgentRuntime {
|
||||
));
|
||||
|
||||
// 视觉模型可用时注册 analyze_image 工具
|
||||
if app_state.vision_llm.is_some() {
|
||||
if app_state.llm.vision.is_some() {
|
||||
tool_registry.add_tool(Box::new(crate::agent::tools::astro::AnalyzeImageTool));
|
||||
}
|
||||
|
||||
@ -143,7 +143,7 @@ impl AgentRuntime {
|
||||
|
||||
// 初始化 checkpoint 管理器(存储在 library_dir/.checkpoints 下)
|
||||
let checkpoint_enabled = true;
|
||||
let checkpoint_store = app_state.config.library_dir.join(".checkpoints");
|
||||
let checkpoint_store = app_state.config.storage.library_dir.join(".checkpoints");
|
||||
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
|
||||
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
|
||||
checkpoint_enabled,
|
||||
@ -326,7 +326,7 @@ impl AgentRuntime {
|
||||
tx: mpsc::UnboundedSender<AgentStreamEvent>,
|
||||
) -> anyhow::Result<String> {
|
||||
let db = &self.app_state.db;
|
||||
let llm = &self.app_state.llm;
|
||||
let llm = &self.app_state.llm.primary;
|
||||
|
||||
// Phase 1: 创建或恢复会话
|
||||
let session_info =
|
||||
@ -336,7 +336,7 @@ impl AgentRuntime {
|
||||
// 构建 hook 注册表(注入依赖,复用 AgentRuntime 的 metrics_data)
|
||||
let hook_registry = HookRegistry::with_builtins(
|
||||
db.clone(),
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.app_state.session.cancelled_runs.clone(),
|
||||
Some(self.metrics_data.clone()),
|
||||
);
|
||||
|
||||
@ -404,7 +404,7 @@ impl AgentRuntime {
|
||||
|
||||
// Phase 4: 会话收尾(传入实际的终止原因 + trajectory 导出参数)
|
||||
let system_prompt = self.system_prompt();
|
||||
let model_name = self.app_state.llm.model().await;
|
||||
let model_name = self.app_state.llm.primary.model().await;
|
||||
finalize::finalize_turn(
|
||||
db,
|
||||
&session_info.session_id,
|
||||
@ -413,7 +413,7 @@ impl AgentRuntime {
|
||||
&tx,
|
||||
&hook_registry,
|
||||
loop_terminal,
|
||||
Some(&self.app_state.config.library_dir),
|
||||
Some(&self.app_state.config.storage.library_dir),
|
||||
Some(&model_name),
|
||||
Some(&system_prompt),
|
||||
Some(self.app_state.clone()),
|
||||
@ -434,12 +434,13 @@ impl AgentRuntime {
|
||||
hook_registry: &HookRegistry,
|
||||
) -> anyhow::Result<(AgentMetrics, Option<TurnTerminal>)> {
|
||||
let db = &self.app_state.db;
|
||||
let llm = &self.app_state.llm;
|
||||
let llm = &self.app_state.llm.primary;
|
||||
let sid = &session_info.session_id;
|
||||
let turn_index = session_info.turn_index;
|
||||
|
||||
// 注册当前会话的权限检查器(如不存在则从全局配置初始化)
|
||||
self.app_state
|
||||
.session
|
||||
.session_permission_checkers
|
||||
.entry(sid.clone())
|
||||
.or_insert_with(|| (*self.permission_checker).clone());
|
||||
@ -468,7 +469,7 @@ impl AgentRuntime {
|
||||
self.checkpoint_manager.new_turn();
|
||||
|
||||
// 检查用户取消
|
||||
let is_cancelled = self.app_state.cancelled_runs.remove(sid).is_some();
|
||||
let is_cancelled = self.app_state.session.cancelled_runs.remove(sid).is_some();
|
||||
|
||||
if is_cancelled {
|
||||
warn!("[AgentRuntime] 用户手动中止了会话 {} 的智能体执行", sid);
|
||||
@ -794,6 +795,7 @@ impl AgentRuntime {
|
||||
&mut duplicate_detector,
|
||||
self.config.duplicate_call_threshold,
|
||||
messages,
|
||||
&self.tool_registry,
|
||||
tx,
|
||||
db,
|
||||
sid,
|
||||
@ -813,6 +815,7 @@ impl AgentRuntime {
|
||||
// 并行执行工具(带权限检查、checkpoint 和分区器)
|
||||
let session_checker_snapshot = self
|
||||
.app_state
|
||||
.session
|
||||
.session_permission_checkers
|
||||
.get(sid)
|
||||
.map(|r| r.value().clone());
|
||||
@ -887,7 +890,7 @@ impl AgentRuntime {
|
||||
}
|
||||
|
||||
if exec_result.was_cancelled {
|
||||
self.app_state.cancelled_runs.remove(sid);
|
||||
self.app_state.session.cancelled_runs.remove(sid);
|
||||
warn!(
|
||||
"[AgentRuntime] 工具执行期间被用户手动中止,会话 ID: {}",
|
||||
sid
|
||||
@ -947,7 +950,7 @@ impl AgentRuntime {
|
||||
tx,
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.app_state.session.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
@ -955,7 +958,7 @@ impl AgentRuntime {
|
||||
match output.status {
|
||||
StreamStatus::Success => return Some(output),
|
||||
StreamStatus::Cancelled => {
|
||||
self.app_state.cancelled_runs.remove(session_id);
|
||||
self.app_state.session.cancelled_runs.remove(session_id);
|
||||
warn!(
|
||||
"[AgentRuntime] 流式调用期间被用户手动中止,会话 ID: {}",
|
||||
session_id
|
||||
@ -1009,7 +1012,7 @@ impl AgentRuntime {
|
||||
if matches!(error_kind, ErrorKind::Overloaded) {
|
||||
consecutive_overloads += 1;
|
||||
if consecutive_overloads >= 3 {
|
||||
let fallback = &self.app_state.config.llm_fallback_model;
|
||||
let fallback = &self.app_state.config.llm.fallback_model;
|
||||
if !fallback.is_empty() {
|
||||
warn!(
|
||||
"[AgentRuntime] 连续 {} 次过载,切换到备用模型: {}",
|
||||
@ -1017,11 +1020,11 @@ impl AgentRuntime {
|
||||
);
|
||||
llm.set_model(fallback.clone()).await;
|
||||
consecutive_overloads = 0;
|
||||
} else if !self.app_state.config.llm_fallback_chain.is_empty() {
|
||||
} else if !self.app_state.config.llm.fallback_chain.is_empty() {
|
||||
let idx = ((consecutive_overloads as usize - 3)
|
||||
% self.app_state.config.llm_fallback_chain.len())
|
||||
.min(self.app_state.config.llm_fallback_chain.len() - 1);
|
||||
let alt = &self.app_state.config.llm_fallback_chain[idx];
|
||||
% self.app_state.config.llm.fallback_chain.len())
|
||||
.min(self.app_state.config.llm.fallback_chain.len() - 1);
|
||||
let alt = &self.app_state.config.llm.fallback_chain[idx];
|
||||
warn!(
|
||||
"[AgentRuntime] 连续 {} 次过载,从链中切换: {}",
|
||||
consecutive_overloads, alt
|
||||
@ -1033,7 +1036,12 @@ impl AgentRuntime {
|
||||
}
|
||||
|
||||
// 检查用户取消
|
||||
if self.app_state.cancelled_runs.contains_key(session_id) {
|
||||
if self
|
||||
.app_state
|
||||
.session
|
||||
.cancelled_runs
|
||||
.contains_key(session_id)
|
||||
{
|
||||
warn!("[AgentRuntime] 退避重试期间被用户取消");
|
||||
let _ = tx.send(AgentStreamEvent::Error {
|
||||
message: "用户已手动中止执行。".to_string(),
|
||||
@ -1049,7 +1057,7 @@ impl AgentRuntime {
|
||||
tx,
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.app_state.session.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
@ -1060,7 +1068,7 @@ impl AgentRuntime {
|
||||
return Some(retry_output);
|
||||
}
|
||||
StreamStatus::Cancelled => {
|
||||
self.app_state.cancelled_runs.remove(session_id);
|
||||
self.app_state.session.cancelled_runs.remove(session_id);
|
||||
return None;
|
||||
}
|
||||
StreamStatus::Error(_) => {
|
||||
@ -1155,7 +1163,7 @@ impl AgentRuntime {
|
||||
tx,
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.app_state.session.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
@ -1168,7 +1176,7 @@ impl AgentRuntime {
|
||||
return Some(retry_output);
|
||||
}
|
||||
StreamStatus::Cancelled => {
|
||||
self.app_state.cancelled_runs.remove(session_id);
|
||||
self.app_state.session.cancelled_runs.remove(session_id);
|
||||
warn!("[AgentRuntime] 恢复期间被用户中止");
|
||||
let _ = tx.send(AgentStreamEvent::Error {
|
||||
message: "用户已手动中止执行。".to_string(),
|
||||
@ -1335,7 +1343,7 @@ impl AgentRuntime {
|
||||
};
|
||||
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let model_name = self.app_state.llm.model_name_snapshot();
|
||||
let model_name = self.app_state.llm.primary.model_name_snapshot();
|
||||
|
||||
let mut lines = vec![
|
||||
"# 环境信息".to_string(),
|
||||
|
||||
@ -587,58 +587,68 @@ mod tests {
|
||||
db: pool,
|
||||
dict: Dictionary::default(),
|
||||
qiniu,
|
||||
ads,
|
||||
arxiv,
|
||||
vizier,
|
||||
lamost: crate::clients::lamost::LamostClient::new("https://www.lamost.org", 60)
|
||||
.unwrap(),
|
||||
gaia: crate::clients::gaia::GaiaClient::new(
|
||||
"https://gea.esac.esa.int/tap-server/tap",
|
||||
"https://gea.esac.esa.int/data-server",
|
||||
90,
|
||||
)
|
||||
.unwrap(),
|
||||
sdss: crate::clients::sdss::SdssClient::new("https://datalab.noirlab.edu/tap/sync", 90)
|
||||
.unwrap(),
|
||||
desi: crate::clients::desi::DesiClient::new(
|
||||
"https://datalab.noirlab.edu/tap/sync",
|
||||
120,
|
||||
)
|
||||
.unwrap(),
|
||||
irsa: crate::clients::irsa::IrsaClient::new("https://irsa.ipac.caltech.edu", 60)
|
||||
.unwrap(),
|
||||
mast: crate::clients::mast::MastClient::new("https://mast.stsci.edu", 90).unwrap(),
|
||||
observation_registry: std::sync::Arc::new(
|
||||
crate::services::observation::ObservationRegistry::default(),
|
||||
),
|
||||
llm: llm.clone(),
|
||||
medium_llm: llm.clone(),
|
||||
fast_llm: llm.clone(),
|
||||
vision_llm: None,
|
||||
embedding,
|
||||
downloader: Downloader::new().expect("downloader"),
|
||||
http_client: reqwest::Client::new(),
|
||||
harvest_status: Arc::new(tokio::sync::Mutex::new(MetaSyncStatus::default())),
|
||||
batch_status: Arc::new(tokio::sync::Mutex::new(AssetBatchStatus::default())),
|
||||
active_bibcode: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
cancelled_runs: Arc::new(dashmap::DashMap::new()),
|
||||
skill_registry: Arc::new(tokio::sync::RwLock::new(SkillRegistry::new(PathBuf::from(
|
||||
"/tmp/sk",
|
||||
)))),
|
||||
pending_questions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
pending_permissions: Arc::new(
|
||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
),
|
||||
session_permission_checkers: Arc::new(dashmap::DashMap::new()),
|
||||
sse_broadcast: None,
|
||||
memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from(
|
||||
"/tmp/test_mem",
|
||||
)))),
|
||||
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||||
login_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
upload_rate_limiter: Arc::new(
|
||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
),
|
||||
login_rate_limiter: Arc::new(dashmap::DashMap::new()),
|
||||
upload_rate_limiter: Arc::new(dashmap::DashMap::new()),
|
||||
llm: crate::api::LlmState {
|
||||
primary: llm.clone(),
|
||||
medium: llm.clone(),
|
||||
fast: llm.clone(),
|
||||
vision: None,
|
||||
embedding,
|
||||
},
|
||||
sources: crate::api::DataSourceState {
|
||||
ads,
|
||||
arxiv,
|
||||
vizier,
|
||||
lamost: crate::clients::lamost::LamostClient::new("https://www.lamost.org", 60)
|
||||
.unwrap(),
|
||||
gaia: crate::clients::gaia::GaiaClient::new(
|
||||
"https://gea.esac.esa.int/tap-server/tap",
|
||||
"https://gea.esac.esa.int/data-server",
|
||||
90,
|
||||
)
|
||||
.unwrap(),
|
||||
sdss: crate::clients::sdss::SdssClient::new(
|
||||
"https://datalab.noirlab.edu/tap/sync",
|
||||
90,
|
||||
)
|
||||
.unwrap(),
|
||||
desi: crate::clients::desi::DesiClient::new(
|
||||
"https://datalab.noirlab.edu/tap/sync",
|
||||
120,
|
||||
)
|
||||
.unwrap(),
|
||||
irsa: crate::clients::irsa::IrsaClient::new("https://irsa.ipac.caltech.edu", 60)
|
||||
.unwrap(),
|
||||
mast: crate::clients::mast::MastClient::new("https://mast.stsci.edu", 90).unwrap(),
|
||||
observation_registry: std::sync::Arc::new(
|
||||
crate::services::observation::ObservationRegistry::default(),
|
||||
),
|
||||
},
|
||||
session: crate::api::SessionState {
|
||||
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||||
session_last_active: Arc::new(dashmap::DashMap::new()),
|
||||
cancelled_runs: Arc::new(dashmap::DashMap::new()),
|
||||
session_permission_checkers: Arc::new(dashmap::DashMap::new()),
|
||||
pending_questions: Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
pending_permissions: Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
},
|
||||
});
|
||||
|
||||
ToolContext::new(app_state)
|
||||
|
||||
@ -38,7 +38,7 @@ pub struct SubAgentRunner {
|
||||
progress_tx: Option<UnboundedSender<AgentStreamEvent>>,
|
||||
/// 父代理的会话 ID(用于子代理消息 of 数据库持久化)
|
||||
parent_session_id: String,
|
||||
/// 覆盖子代理使用的模型客户端,若不指定则默认使用 app_state.llm (主推理模型)
|
||||
/// 覆盖子代理使用的模型客户端,若不指定则默认使用 app_state.llm.primary (主推理模型)
|
||||
llm_client_override: Option<LlmClient>,
|
||||
}
|
||||
|
||||
@ -276,7 +276,7 @@ impl SubAgentRunner {
|
||||
let llm = self
|
||||
.llm_client_override
|
||||
.as_ref()
|
||||
.unwrap_or(&self.app_state.llm);
|
||||
.unwrap_or(&self.app_state.llm.primary);
|
||||
let tool_defs = self.tool_registry.definitions();
|
||||
|
||||
// 全新上下文
|
||||
@ -469,11 +469,19 @@ impl SubAgentRunner {
|
||||
|
||||
// ── 向父代理发送进度事件 ──
|
||||
if let Some(ref tx) = self.progress_tx {
|
||||
let (is_internal, display_name) =
|
||||
if let Some(tool) = self.tool_registry.get(tool_name) {
|
||||
(tool.is_internal(), format!("[sub] {}", tool.display_name()))
|
||||
} else {
|
||||
(false, format!("[sub] {}", tool_name))
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolCall {
|
||||
id: tool_call.id.clone(),
|
||||
name: format!("[sub] {}", tool_name),
|
||||
display_name,
|
||||
arguments: args.clone(),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
}
|
||||
|
||||
@ -607,13 +615,21 @@ impl SubAgentRunner {
|
||||
// 向父代理发送工具结果进度
|
||||
if let Some(ref tx) = self.progress_tx {
|
||||
let preview: String = final_output_content.chars().take(200).collect();
|
||||
let (is_internal, display_name) =
|
||||
if let Some(tool) = self.tool_registry.get(tool_name) {
|
||||
(tool.is_internal(), format!("[sub] {}", tool.display_name()))
|
||||
} else {
|
||||
(false, format!("[sub] {}", tool_name))
|
||||
};
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
name: format!("[sub] {}", tool_name),
|
||||
display_name,
|
||||
output: preview,
|
||||
is_error: output.is_error,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
is_internal,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ pub async fn run_teammate_loop(
|
||||
status: Arc<Mutex<MemberStatus>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
let llm = &app_state.llm;
|
||||
let llm = &app_state.llm.primary;
|
||||
// 队友的工具注册表排除 subagent(防止无限委托链)
|
||||
let queue = Arc::new(BgNotificationQueue::new());
|
||||
let tool_registry =
|
||||
|
||||
@ -58,6 +58,10 @@ impl AgentTool for AskUserTool {
|
||||
"ask_user"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"询问用户"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"当任务需求不明确、缺少关键参数、或需要在多个方案之间选择时,向用户提问。\
|
||||
支持预定义选项(单选/多选)。会暂停当前任务等待用户回复。\
|
||||
@ -169,12 +173,13 @@ impl AgentTool for AskUserTool {
|
||||
|
||||
// 将通道发送端存储到 AppState 的待处理问题列表
|
||||
{
|
||||
let mut pending = ctx.app_state.pending_questions.lock().await;
|
||||
let mut pending = ctx.app_state.session.pending_questions.lock().await;
|
||||
pending.insert(
|
||||
short_id.clone(),
|
||||
crate::api::PendingQuestion {
|
||||
question_json: question_json.clone(),
|
||||
answer_tx: tx,
|
||||
created_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -197,6 +202,7 @@ impl AgentTool for AskUserTool {
|
||||
|
||||
// 清理
|
||||
ctx.app_state
|
||||
.session
|
||||
.pending_questions
|
||||
.lock()
|
||||
.await
|
||||
@ -226,6 +232,7 @@ impl AgentTool for AskUserTool {
|
||||
Ok(Err(_)) => {
|
||||
// 通道关闭(发送端被 drop)
|
||||
ctx.app_state
|
||||
.session
|
||||
.pending_questions
|
||||
.lock()
|
||||
.await
|
||||
@ -235,6 +242,7 @@ impl AgentTool for AskUserTool {
|
||||
Err(_) => {
|
||||
// 超时
|
||||
ctx.app_state
|
||||
.session
|
||||
.pending_questions
|
||||
.lock()
|
||||
.await
|
||||
|
||||
@ -61,6 +61,10 @@ impl AgentTool for AnalyzeImageTool {
|
||||
"analyze_image"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"图片分析"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"使用专用视觉模型分析图片。可传入本地图片路径(相对于 library 目录)或 http/https URL。分析结果会实时流式输出。"
|
||||
}
|
||||
@ -112,7 +116,7 @@ impl AgentTool for AnalyzeImageTool {
|
||||
Some(q) => q.to_string(),
|
||||
None => return ToolOutput::error("缺少必需参数 'question'"),
|
||||
};
|
||||
let vision_llm = match ctx.app_state.vision_llm.as_ref() {
|
||||
let vision_llm = match ctx.app_state.llm.vision.as_ref() {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return ToolOutput::error(
|
||||
@ -133,7 +137,7 @@ impl AgentTool for AnalyzeImageTool {
|
||||
let resolved: PathBuf = if Path::new(&image_path).is_absolute() {
|
||||
PathBuf::from(&image_path)
|
||||
} else {
|
||||
ctx.app_state.config.library_dir.join(&image_path)
|
||||
ctx.app_state.config.storage.library_dir.join(&image_path)
|
||||
};
|
||||
if !is_path_allowed(&resolved, ctx) {
|
||||
return ToolOutput::error(format!("路径不在允许的沙箱范围内: {}", image_path));
|
||||
@ -144,7 +148,7 @@ impl AgentTool for AnalyzeImageTool {
|
||||
&image_path,
|
||||
&question,
|
||||
vision_llm,
|
||||
&ctx.app_state.config.library_dir,
|
||||
&ctx.app_state.config.storage.library_dir,
|
||||
ctx.sse_tx.as_ref(),
|
||||
ctx.tool_call_id.clone(),
|
||||
)
|
||||
@ -173,22 +177,22 @@ mod tests {
|
||||
async fn test_execute_with_progress_real() {
|
||||
let config = crate::Config::from_env();
|
||||
|
||||
if config.llm_vision_model.is_empty() {
|
||||
if config.vision.model.is_empty() {
|
||||
eprintln!("跳过: 未配置 LLM_VISION_MODEL 环境变量");
|
||||
return;
|
||||
}
|
||||
|
||||
let key = if config.llm_vision_api_key.is_empty() {
|
||||
config.llm_api_key.clone()
|
||||
let key = if config.vision.api_key.is_empty() {
|
||||
config.llm.api_key.clone()
|
||||
} else {
|
||||
config.llm_vision_api_key.clone()
|
||||
config.vision.api_key.clone()
|
||||
};
|
||||
let base = if config.llm_vision_api_base.is_empty() {
|
||||
config.llm_api_base.clone()
|
||||
let base = if config.vision.api_base.is_empty() {
|
||||
config.llm.api_base.clone()
|
||||
} else {
|
||||
config.llm_vision_api_base.clone()
|
||||
config.vision.api_base.clone()
|
||||
};
|
||||
let vision_llm = LlmClient::new(key, base, config.llm_vision_model.clone()).unwrap();
|
||||
let vision_llm = LlmClient::new(key, base, config.vision.model.clone()).unwrap();
|
||||
|
||||
// 写入测试图片到临时目录
|
||||
let tmp_dir = std::env::temp_dir().join("astro_vision_test");
|
||||
|
||||
@ -19,6 +19,10 @@ impl AgentTool for SearchLocalLibraryTool {
|
||||
"search_local_library"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"本地文献库检索"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"在本地文献库中进行全文检索。搜索范围包括标题、作者、关键词、摘要和期刊名。\
|
||||
使用 BM25 相关性排序,返回匹配度最高的结果。适用于:查找已入库的文献、按关键词或作者浏览本地馆藏。"
|
||||
@ -148,6 +152,10 @@ impl AgentTool for GetCitationNetworkTool {
|
||||
"get_citation_network"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文献引用网络"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"浏览和查找文献的引用关系。两种用法:\
|
||||
1. 引用查找——提供 query 参数(如 'Lei 2023'),在参考文献/被引列表中按作者+年份模糊匹配,返回匹配的文献信息;\
|
||||
@ -237,7 +245,7 @@ impl AgentTool for GetCitationNetworkTool {
|
||||
// 验证源文献存在
|
||||
let paper = match crate::services::paper::get_paper_from_db(
|
||||
&state.db,
|
||||
&state.config.library_dir,
|
||||
&state.config.storage.library_dir,
|
||||
&bibcode,
|
||||
)
|
||||
.await
|
||||
|
||||
@ -15,6 +15,10 @@ impl AgentTool for GetPaperMetadataTool {
|
||||
"get_paper_metadata"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"获取文献信息"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"获取指定文献的完整元数据信息(包括完整标题、所有作者、出版期刊、关键字、引用数、完整摘要等)。\
|
||||
适用于:需要查看某篇文献的详细信息、阅读完整摘要以评估文献相关性。"
|
||||
@ -56,7 +60,7 @@ impl AgentTool for GetPaperMetadataTool {
|
||||
|
||||
match crate::services::paper::get_paper_from_db(
|
||||
&state.db,
|
||||
&state.config.library_dir,
|
||||
&state.config.storage.library_dir,
|
||||
&bibcode,
|
||||
)
|
||||
.await
|
||||
|
||||
@ -15,6 +15,10 @@ impl AgentTool for SaveNoteTool {
|
||||
"save_note"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"保存笔记"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"将研究中间结果或最终结论保存为 Markdown 笔记文件。适用于记录阅读摘要、研究思路、文献分析等。"
|
||||
}
|
||||
@ -57,8 +61,12 @@ impl AgentTool for SaveNoteTool {
|
||||
info!("[SaveNote] 保存笔记: {}", title);
|
||||
let state = &ctx.app_state;
|
||||
|
||||
match crate::services::note::save_note_service(&state.config.library_dir, &title, &content)
|
||||
.await
|
||||
match crate::services::note::save_note_service(
|
||||
&state.config.storage.library_dir,
|
||||
&title,
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((filename, filepath, size)) => ToolOutput::success(
|
||||
format!("笔记已保存: {}", filename),
|
||||
|
||||
@ -44,6 +44,10 @@ impl AgentTool for FindObservationTool {
|
||||
"find_observation"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"观测数据获取"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"下载天文观测数据(LAMOST/Gaia/SDSS/DESI 的光谱/光变/测光/图像)。\n\
|
||||
坐标模式:给 ra/dec + source + product,自动 cone 检索并下载最近(或全部)命中源。\n\
|
||||
@ -225,7 +229,9 @@ impl AgentTool for FindObservationTool {
|
||||
}
|
||||
};
|
||||
|
||||
match download_observation(state, &state.observation_registry, &request, force).await {
|
||||
match download_observation(state, &state.sources.observation_registry, &request, force)
|
||||
.await
|
||||
{
|
||||
Ok(batch) => {
|
||||
let content = render_batch(&batch);
|
||||
ToolOutput::success(content, json!(batch))
|
||||
|
||||
@ -21,6 +21,10 @@ impl AgentTool for GetPaperOutlineTool {
|
||||
"get_paper_outline"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文献提纲提取"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"获取文献的章节大纲目录,列出所有章节的序号和标题。\n\
|
||||
在读取文献内容前,强烈建议先使用此工具了解章节结构,以便按需读取,节约 token。"
|
||||
@ -52,7 +56,7 @@ impl AgentTool for GetPaperOutlineTool {
|
||||
|
||||
match crate::services::paper::read_paper_content(
|
||||
&state.db,
|
||||
&state.config.library_dir,
|
||||
&state.config.storage.library_dir,
|
||||
&bibcode,
|
||||
mode,
|
||||
)
|
||||
@ -108,6 +112,10 @@ impl AgentTool for GetPaperContentTool {
|
||||
"get_paper_content"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文献内容读取"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"获取文献的具体内容(全文或指定章节内容)。\n\
|
||||
可传入 section_index 或 section_name 指定仅读取某一章节。\n\
|
||||
@ -176,7 +184,7 @@ impl AgentTool for GetPaperContentTool {
|
||||
|
||||
match crate::services::paper::read_paper_content(
|
||||
&state.db,
|
||||
&state.config.library_dir,
|
||||
&state.config.storage.library_dir,
|
||||
&bibcode,
|
||||
mode,
|
||||
)
|
||||
|
||||
@ -15,6 +15,10 @@ impl AgentTool for RagSearchTool {
|
||||
"rag_search"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文献库RAG检索"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"在向量化文献库中执行混合语义检索。同时利用稠密向量(语义相似)和稀疏 BM25(关键词精确匹配),\
|
||||
用 RRF(倒数秩融合)算法合并两路结果,兼顾同义表达和精确术语。\
|
||||
@ -64,8 +68,13 @@ impl AgentTool for RagSearchTool {
|
||||
);
|
||||
let state = &ctx.app_state;
|
||||
|
||||
match crate::services::rag::retrieve_hybrid(&state.db, &state.embedding, &question, top_k)
|
||||
.await
|
||||
match crate::services::rag::retrieve_hybrid(
|
||||
&state.db,
|
||||
&state.llm.embedding,
|
||||
&question,
|
||||
top_k,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(chunks) => {
|
||||
if chunks.is_empty() {
|
||||
|
||||
@ -15,6 +15,10 @@ impl AgentTool for QueryTargetTool {
|
||||
"query_target"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"天体解析"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"查询天体目标的基本物理参数(坐标 RA/Dec、视星等、光谱类型、视差等)。数据来源为 CDS SIMBAD/Sesame 名称解析服务,结果自动缓存。\
|
||||
支持常见天体名称,如 'NGC 6752'、'GD 358'、'HD 209458'、'M 31' 等。"
|
||||
|
||||
@ -90,6 +90,10 @@ impl AgentTool for CatalogOperationTool {
|
||||
"catalog_operation"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"星表检索"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"VizieR 星表统一操作。通过 action 选择:\n\
|
||||
search — 按关键词搜索星表目录;\n\
|
||||
@ -196,7 +200,8 @@ async fn do_catalog_search(state: &crate::api::AppState, args: &serde_json::Valu
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 30) as usize;
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
|
||||
let catalog =
|
||||
crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.sources.vizier);
|
||||
let results = match catalog.search(keyword, limit).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return ToolOutput::error(format!("搜索失败: {}", e)),
|
||||
@ -229,7 +234,8 @@ async fn do_catalog_describe(state: &crate::api::AppState, args: &serde_json::Va
|
||||
None => return ToolOutput::error("describe 需要 'table' 参数"),
|
||||
};
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
|
||||
let catalog =
|
||||
crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.sources.vizier);
|
||||
let columns = match catalog.describe(table).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return ToolOutput::error(format!("查询表结构失败: {}", e)),
|
||||
@ -266,7 +272,13 @@ async fn do_catalog_query(state: &crate::api::AppState, args: &serde_json::Value
|
||||
limit,
|
||||
adql.chars().take(150).collect::<String>()
|
||||
);
|
||||
crate::services::cds::vizier::query_adql_cached(&state.db, &state.vizier, adql, limit).await
|
||||
crate::services::cds::vizier::query_adql_cached(
|
||||
&state.db,
|
||||
&state.sources.vizier,
|
||||
adql,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
} else if let Some(table) = args.get("table").and_then(|v| v.as_str()) {
|
||||
let columns: Vec<String> = args
|
||||
.get("columns")
|
||||
@ -274,8 +286,14 @@ async fn do_catalog_query(state: &crate::api::AppState, args: &serde_json::Value
|
||||
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
info!("[CatalogOp:query] table={} limit={}", table, limit);
|
||||
crate::services::cds::vizier::query_table(&state.db, &state.vizier, table, &columns, limit)
|
||||
.await
|
||||
crate::services::cds::vizier::query_table(
|
||||
&state.db,
|
||||
&state.sources.vizier,
|
||||
table,
|
||||
&columns,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
return ToolOutput::error("query 需要 'adql' 或 'table' 参数之一");
|
||||
};
|
||||
@ -328,7 +346,7 @@ async fn do_catalog_cone(state: &crate::api::AppState, args: &serde_json::Value)
|
||||
|
||||
match crate::services::cds::vizier::cone_search(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
&state.sources.vizier,
|
||||
ra,
|
||||
dec,
|
||||
radius,
|
||||
@ -365,10 +383,11 @@ async fn do_catalog_export(state: &crate::api::AppState, args: &serde_json::Valu
|
||||
return ToolOutput::error("export 需要 'adql' 或 'table' 参数");
|
||||
}
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
|
||||
let catalog =
|
||||
crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.sources.vizier);
|
||||
let result = match catalog
|
||||
.export_to_file(
|
||||
state.config.library_dir.to_str().unwrap_or("."),
|
||||
state.config.storage.library_dir.to_str().unwrap_or("."),
|
||||
adql,
|
||||
table,
|
||||
columns,
|
||||
@ -406,8 +425,11 @@ async fn do_catalog_lookup(state: &crate::api::AppState, args: &serde_json::Valu
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 30) as usize;
|
||||
|
||||
let catalog =
|
||||
crate::services::cds::vizier::VizierCatalog::with_ads(&state.db, &state.vizier, &state.ads);
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::with_ads(
|
||||
&state.db,
|
||||
&state.sources.vizier,
|
||||
&state.sources.ads,
|
||||
);
|
||||
let results = match catalog.lookup(bibcode, limit).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return ToolOutput::error(format!("查找失败: {}", e)),
|
||||
|
||||
@ -14,6 +14,10 @@ impl AgentTool for ProcessPaperTool {
|
||||
"process_paper"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文献精读"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"文献处理流水线工具。按需执行一个或多个任务步骤:下载全文资源(PDF/HTML)、解析为结构化 Markdown、向量化嵌入。\
|
||||
任务按序执行,任一步失败则终止后续步骤。适用于:将新文献纳入本地库的完整流程。"
|
||||
|
||||
@ -15,6 +15,10 @@ impl AgentTool for SearchPapersTool {
|
||||
"search_papers"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文献搜索"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"搜索天文学文献。支持 NASA ADS 和 arXiv 跨平台联合检索,结果自动合并去重,并关联本地馆藏状态与引用关系网。输入关键词或高级检索式,返回匹配的文献列表。\
|
||||
适用于:查找相关文献、了解研究领域现状、获取特定主题的论文。"
|
||||
|
||||
@ -31,6 +31,10 @@ impl AgentTool for BgTaskRunTool {
|
||||
"bg_task_run"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"后台任务"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"在后台异步执行慢速工具(process_paper)。\
|
||||
返回任务ID后立即让 LLM 继续思考,后台完成的结果会在下一轮对话中自动通知。\
|
||||
@ -153,6 +157,14 @@ impl AgentTool for BgTaskCheckTool {
|
||||
"bg_task_check"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"检查后台"
|
||||
}
|
||||
|
||||
fn is_internal(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"查询后台任务状态。不指定 task_id 时返回所有任务。"
|
||||
}
|
||||
|
||||
@ -14,6 +14,14 @@ impl AgentTool for CompressTool {
|
||||
"compress_context"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"压缩上下文"
|
||||
}
|
||||
|
||||
fn is_internal(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"手动压缩对话上下文。当你发现对话历史过长、token 消耗过大时,主动调用此工具进行压缩以释放空间。\
|
||||
压缩后历史对话将被摘要替代,但关键信息不会丢失。"
|
||||
|
||||
@ -21,6 +21,10 @@ impl AgentTool for RunBashTool {
|
||||
"run_bash"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"运行命令"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"执行一个 Bash 命令并返回 stdout 和 stderr。\
|
||||
适用场景:运行 skill 目录中的 scripts/*.py、文件处理、数据提取。\
|
||||
|
||||
@ -158,6 +158,10 @@ impl AgentTool for FileEditTool {
|
||||
"file_edit"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"修改文件"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"在文件中执行精确的字符串替换。查找 old_string 并替换为 new_string。\
|
||||
old_string 在文件中必须唯一(仅出现一次),以防止意外破坏其他内容。\
|
||||
|
||||
@ -20,6 +20,10 @@ impl AgentTool for GlobFilesTool {
|
||||
"glob_files"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文件搜索"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"使用 glob 模式查找匹配的文件。支持通配符 * 和 **。返回匹配的文件路径列表。路径必须在允许的沙箱范围内。"
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ impl AgentTool for GrepFilesTool {
|
||||
"grep_files"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"文本检索"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"在指定目录或文件中搜索匹配正则表达式的行。返回匹配行及其上下文(前后各 2 行)。适用于在代码库或文献中搜索特定模式。"
|
||||
}
|
||||
|
||||
@ -23,6 +23,10 @@ impl AgentTool for ReadFileTool {
|
||||
"read_file"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"查看文件"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"读取指定路径的文件内容。支持文本文件和代码文件。会自动截断过长的内容。路径必须在允许的沙箱范围内。如果文件自上次读取后未修改,会返回占位消息以节省上下文。"
|
||||
}
|
||||
|
||||
@ -21,8 +21,8 @@ pub fn is_path_allowed(path: &Path, ctx: &ToolContext) -> bool {
|
||||
};
|
||||
// 内置允许根目录
|
||||
let builtin_roots: [Option<std::path::PathBuf>; 3] = [
|
||||
config.library_dir.canonicalize().ok(),
|
||||
config.skills_dir.canonicalize().ok(),
|
||||
config.storage.library_dir.canonicalize().ok(),
|
||||
config.storage.skills_dir.canonicalize().ok(),
|
||||
std::env::current_dir().ok(),
|
||||
];
|
||||
for root in builtin_roots.iter().flatten() {
|
||||
|
||||
@ -20,6 +20,10 @@ impl AgentTool for FileWriteTool {
|
||||
"file_write"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"写文件"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"将内容写入指定路径的文件。如果文件已存在则覆盖。路径必须在允许的沙箱范围内。适用于保存研究结果、生成报告等。"
|
||||
}
|
||||
|
||||
@ -30,6 +30,14 @@ impl AgentTool for SaveMemoryTool {
|
||||
"save_memory"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"保存记忆"
|
||||
}
|
||||
|
||||
fn is_internal(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"将重要信息保存到项目记忆系统。记忆会跨会话持久化,在后续会话中自动加载。\
|
||||
用于保存:用户偏好、研究方法论、项目进展、重要发现、外部参考。\n\n\
|
||||
|
||||
@ -299,6 +299,17 @@ pub trait AgentTool: Send + Sync {
|
||||
false
|
||||
}
|
||||
|
||||
/// 该工具是否为系统内部工具(如果是,执行成功时前端默认微缩/隐藏)
|
||||
fn is_internal(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 工具中文/显示名称,用于前端 UI 和审计日志的友好展示。
|
||||
/// 默认返回 name()。
|
||||
fn display_name(&self) -> &str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
/// 带进度流式执行。默认委托给 execute()。
|
||||
/// 长时间操作的工具可覆写以发送进度更新。
|
||||
async fn execute_with_progress(
|
||||
@ -508,6 +519,14 @@ impl ToolRegistry {
|
||||
self.tools.get(name).map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
/// 获取注册表中所有已注册工具的引用列表
|
||||
pub fn list(&self) -> Vec<&dyn AgentTool> {
|
||||
self.ordered_names
|
||||
.iter()
|
||||
.filter_map(|name| self.tools.get(name).map(|t| t.as_ref()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 设置工具定义白名单过滤器(子代理最小权限)。
|
||||
/// 设置后 `definitions()` 仅返回白名单中的工具。
|
||||
pub fn set_definition_filter(&mut self, allowed: Vec<String>) {
|
||||
|
||||
@ -16,6 +16,14 @@ impl AgentTool for SearchHistoryTool {
|
||||
"search_history"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"检索历史"
|
||||
}
|
||||
|
||||
fn is_internal(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"搜索之前会话的历史记录,查找已研究过的主题、已发现的结论、已下载的文献。\
|
||||
使用此工具可以避免重复研究已经完成的工作。"
|
||||
|
||||
@ -38,6 +38,14 @@ impl AgentTool for LoadSkillTool {
|
||||
"load_skill"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"加载技能"
|
||||
}
|
||||
|
||||
fn is_internal(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
// 注意:description() 返回 &str,但我们需要动态内容。
|
||||
// 实际使用 ToolRegistry 时,此方法的返回值作为 base description,
|
||||
|
||||
@ -52,6 +52,10 @@ impl AgentTool for SubAgentTool {
|
||||
"subagent"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"运行子代理"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"将子任务委托给独立的子代理执行。子代理拥有完整工具访问权限(文献搜索、下载、RAG检索等),\
|
||||
但只有最终文本摘要会返回给父代理,中间工具调用不会污染父上下文。\
|
||||
|
||||
@ -32,6 +32,10 @@ impl AgentTool for SpawnTeammateTool {
|
||||
"spawn_teammate"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"创建队友"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"启动一个队友 agent。队友拥有独立的上下文,可以并行执行文献搜索、论文下载等任务。\
|
||||
通过 send_teammate_message 向队友发送任务,通过 check_team_inbox 检查结果。\
|
||||
@ -111,6 +115,10 @@ impl AgentTool for SendTeammateMessageTool {
|
||||
"send_teammate_message"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"发送团队消息"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"向指定队友发送消息(任务分配、问题等)。消息会投递到队友的收件箱,\
|
||||
队友在处理循环中自动读取。"
|
||||
@ -184,6 +192,10 @@ impl AgentTool for TeamBroadcastTool {
|
||||
"team_broadcast"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"团队广播"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"向所有队友广播消息。适用于状态同步、全局指令等。"
|
||||
}
|
||||
@ -244,6 +256,10 @@ impl AgentTool for CheckTeamInboxTool {
|
||||
"check_team_inbox"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"检查团队收件箱"
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@ -21,6 +21,14 @@ impl AgentTool for TodoWriteTool {
|
||||
"todo_write"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"任务管理"
|
||||
}
|
||||
|
||||
fn is_internal(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"任务规划工具。在开始复杂研究前列出待办事项,执行中标记进度(每项状态:pending/in_progress/completed)。\
|
||||
一次只能有一个 in_progress 任务。支持任务依赖(blockedBy:依赖的其他任务ID列表)。\
|
||||
@ -256,58 +264,68 @@ mod tests {
|
||||
db: pool,
|
||||
dict,
|
||||
qiniu,
|
||||
ads,
|
||||
arxiv,
|
||||
vizier,
|
||||
lamost: crate::clients::lamost::LamostClient::new("https://www.lamost.org", 60)
|
||||
.unwrap(),
|
||||
gaia: crate::clients::gaia::GaiaClient::new(
|
||||
"https://gea.esac.esa.int/tap-server/tap",
|
||||
"https://gea.esac.esa.int/data-server",
|
||||
90,
|
||||
)
|
||||
.unwrap(),
|
||||
sdss: crate::clients::sdss::SdssClient::new("https://datalab.noirlab.edu/tap/sync", 90)
|
||||
.unwrap(),
|
||||
desi: crate::clients::desi::DesiClient::new(
|
||||
"https://datalab.noirlab.edu/tap/sync",
|
||||
120,
|
||||
)
|
||||
.unwrap(),
|
||||
irsa: crate::clients::irsa::IrsaClient::new("https://irsa.ipac.caltech.edu", 60)
|
||||
.unwrap(),
|
||||
mast: crate::clients::mast::MastClient::new("https://mast.stsci.edu", 90).unwrap(),
|
||||
observation_registry: std::sync::Arc::new(
|
||||
crate::services::observation::ObservationRegistry::default(),
|
||||
),
|
||||
llm,
|
||||
medium_llm,
|
||||
fast_llm,
|
||||
vision_llm: None,
|
||||
embedding,
|
||||
downloader,
|
||||
http_client: reqwest::Client::new(),
|
||||
harvest_status: Arc::new(tokio::sync::Mutex::new(MetaSyncStatus::default())),
|
||||
batch_status: Arc::new(tokio::sync::Mutex::new(AssetBatchStatus::default())),
|
||||
active_bibcode: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
cancelled_runs: Arc::new(dashmap::DashMap::new()),
|
||||
skill_registry: Arc::new(tokio::sync::RwLock::new(
|
||||
crate::agent::skills::SkillRegistry::new(PathBuf::from("/tmp/test_skills")),
|
||||
)),
|
||||
pending_questions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
pending_permissions: Arc::new(
|
||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
),
|
||||
session_permission_checkers: Arc::new(dashmap::DashMap::new()),
|
||||
sse_broadcast: None,
|
||||
memory_manager: Arc::new(tokio::sync::Mutex::new(
|
||||
crate::agent::memory::MemoryManager::new(PathBuf::from("/tmp/test_memory")),
|
||||
)),
|
||||
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||||
login_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
upload_rate_limiter: Arc::new(
|
||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
),
|
||||
login_rate_limiter: Arc::new(dashmap::DashMap::new()),
|
||||
upload_rate_limiter: Arc::new(dashmap::DashMap::new()),
|
||||
llm: crate::api::LlmState {
|
||||
primary: llm,
|
||||
medium: medium_llm,
|
||||
fast: fast_llm,
|
||||
vision: None,
|
||||
embedding,
|
||||
},
|
||||
sources: crate::api::DataSourceState {
|
||||
ads,
|
||||
arxiv,
|
||||
vizier,
|
||||
lamost: crate::clients::lamost::LamostClient::new("https://www.lamost.org", 60)
|
||||
.unwrap(),
|
||||
gaia: crate::clients::gaia::GaiaClient::new(
|
||||
"https://gea.esac.esa.int/tap-server/tap",
|
||||
"https://gea.esac.esa.int/data-server",
|
||||
90,
|
||||
)
|
||||
.unwrap(),
|
||||
sdss: crate::clients::sdss::SdssClient::new(
|
||||
"https://datalab.noirlab.edu/tap/sync",
|
||||
90,
|
||||
)
|
||||
.unwrap(),
|
||||
desi: crate::clients::desi::DesiClient::new(
|
||||
"https://datalab.noirlab.edu/tap/sync",
|
||||
120,
|
||||
)
|
||||
.unwrap(),
|
||||
irsa: crate::clients::irsa::IrsaClient::new("https://irsa.ipac.caltech.edu", 60)
|
||||
.unwrap(),
|
||||
mast: crate::clients::mast::MastClient::new("https://mast.stsci.edu", 90).unwrap(),
|
||||
observation_registry: std::sync::Arc::new(
|
||||
crate::services::observation::ObservationRegistry::default(),
|
||||
),
|
||||
},
|
||||
session: crate::api::SessionState {
|
||||
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||||
session_last_active: Arc::new(dashmap::DashMap::new()),
|
||||
cancelled_runs: Arc::new(dashmap::DashMap::new()),
|
||||
session_permission_checkers: Arc::new(dashmap::DashMap::new()),
|
||||
pending_questions: Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
pending_permissions: Arc::new(tokio::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
},
|
||||
});
|
||||
|
||||
ToolContext {
|
||||
|
||||
@ -80,13 +80,44 @@ pub async fn get_agent_modes() -> Json<Vec<AgentModeDto>> {
|
||||
Json(modes)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AgentToolDto {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub is_internal: bool,
|
||||
}
|
||||
|
||||
// ── GET /api/chat/tools ──
|
||||
// 获取系统注册的工具元数据列表
|
||||
pub async fn get_agent_tools(State(state): State<Arc<AppState>>) -> Json<Vec<AgentToolDto>> {
|
||||
use crate::agent::tools::ToolRegistry;
|
||||
let skill_registry = state.skill_registry.clone();
|
||||
let registry = ToolRegistry::new(skill_registry);
|
||||
let tools = registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|t| AgentToolDto {
|
||||
name: t.name().to_string(),
|
||||
display_name: t.display_name().to_string(),
|
||||
is_internal: t.is_internal(),
|
||||
})
|
||||
.collect();
|
||||
Json(tools)
|
||||
}
|
||||
|
||||
pub async fn chat_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AgentChatRequest>,
|
||||
) -> ApiResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
|
||||
// 截断日志中的问题内容,避免打印敏感信息
|
||||
let question_preview = if req.question.len() > 50 {
|
||||
format!("{}...", &req.question[..50])
|
||||
} else {
|
||||
req.question.clone()
|
||||
};
|
||||
info!(
|
||||
"接收到智能体对话请求: question='{}', session_id={:?}, has_image={}",
|
||||
req.question,
|
||||
question_preview,
|
||||
req.session_id,
|
||||
req.image.is_some()
|
||||
);
|
||||
@ -97,7 +128,7 @@ pub async fn chat_agent(
|
||||
Some(ref img) => {
|
||||
// 如果带有 path 字段(重试场景),复用已有文件,不重新保存
|
||||
let relative_path: String = if let Some(ref existing_path) = img.path {
|
||||
let full = state.config.library_dir.join(existing_path);
|
||||
let full = state.config.storage.library_dir.join(existing_path);
|
||||
if full.exists() {
|
||||
info!("重试复用已有图片: {}", existing_path);
|
||||
existing_path.clone()
|
||||
@ -117,7 +148,7 @@ pub async fn chat_agent(
|
||||
img.mime_type
|
||||
)));
|
||||
}
|
||||
if state.vision_llm.is_none() {
|
||||
if state.llm.vision.is_none() {
|
||||
return Err(AppError::bad_request(
|
||||
"图片分析功能未启用。请配置 LLM_VISION_MODEL 环境变量后重试。",
|
||||
));
|
||||
@ -125,6 +156,7 @@ pub async fn chat_agent(
|
||||
let ext = img.mime_type.strip_prefix("image/").unwrap_or("png");
|
||||
let upload_dir = state
|
||||
.config
|
||||
.storage
|
||||
.library_dir
|
||||
.join(".agent")
|
||||
.join("images")
|
||||
@ -142,7 +174,7 @@ pub async fn chat_agent(
|
||||
.await
|
||||
.map_err(|e| AppError::internal(format!("保存图片失败: {}", e)))?;
|
||||
let rel = filepath
|
||||
.strip_prefix(&state.config.library_dir)
|
||||
.strip_prefix(&state.config.storage.library_dir)
|
||||
.unwrap_or(&filepath)
|
||||
.display()
|
||||
.to_string();
|
||||
@ -171,7 +203,7 @@ pub async fn chat_agent(
|
||||
let session_id = req.session_id.clone();
|
||||
|
||||
// 在后台 tokio 任务中执行 Agent 循环
|
||||
tokio::spawn(async move {
|
||||
let agent_handle = tokio::spawn(async move {
|
||||
match runtime
|
||||
.run_turn_with_image_context(
|
||||
session_id,
|
||||
@ -197,11 +229,17 @@ pub async fn chat_agent(
|
||||
|
||||
// 将 mpsc 通道转换为 SSE 事件流(带 10 分钟超时)
|
||||
const SSE_TIMEOUT_SECS: u64 = 600;
|
||||
let cancelled_runs = state.session.cancelled_runs.clone();
|
||||
let stream = async_stream::stream! {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(SSE_TIMEOUT_SECS);
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
// 超时:通知 CancellationHook 停止 Agent,并中止后台任务
|
||||
if let Some(sid) = &req.session_id {
|
||||
cancelled_runs.insert(sid.clone(), ());
|
||||
}
|
||||
agent_handle.abort();
|
||||
let timeout_event = AgentStreamEvent::Error {
|
||||
message: "Agent 执行超时(10 分钟),请重试。".to_string(),
|
||||
};
|
||||
@ -220,6 +258,11 @@ pub async fn chat_agent(
|
||||
}
|
||||
Ok(None) => break, // channel closed
|
||||
Err(_) => {
|
||||
// 超时:通知 CancellationHook 停止 Agent,并中止后台任务
|
||||
if let Some(sid) = &req.session_id {
|
||||
cancelled_runs.insert(sid.clone(), ());
|
||||
}
|
||||
agent_handle.abort();
|
||||
let timeout_event = AgentStreamEvent::Error {
|
||||
message: "Agent 执行超时(10 分钟),请重试。".to_string(),
|
||||
};
|
||||
@ -247,8 +290,8 @@ pub async fn list_sessions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<SessionListParams>,
|
||||
) -> ApiResult<Json<Vec<crate::services::session::SessionSummary>>> {
|
||||
let limit = params.limit.unwrap_or(50);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
|
||||
let sessions = crate::services::session::list_sessions_service(&state.db, limit, offset)
|
||||
.await
|
||||
@ -299,7 +342,7 @@ pub async fn stop_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
state.cancelled_runs.insert(session_id.clone(), ());
|
||||
state.session.cancelled_runs.insert(session_id.clone(), ());
|
||||
info!("已接收并记录手动中止请求,会话 ID: {}", session_id);
|
||||
Ok(Json(
|
||||
serde_json::json!({ "status": "stopping", "session_id": session_id }),
|
||||
@ -349,7 +392,7 @@ pub async fn answer_question(
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
use crate::agent::tools::ask_user::UserAnswer;
|
||||
|
||||
let mut pending = state.pending_questions.lock().await;
|
||||
let mut pending = state.session.pending_questions.lock().await;
|
||||
let question_id = req.question_id.clone();
|
||||
|
||||
match pending.remove(&question_id) {
|
||||
@ -379,10 +422,15 @@ pub async fn answer_question(
|
||||
// ── GET /api/chat/pending_questions ──
|
||||
// 获取当前待回答的问题(前端轮询或初始化)
|
||||
|
||||
/// 待回答问题的 TTL(10 分钟),超过此时间自动清理
|
||||
const PENDING_TTL_SECS: u64 = 600;
|
||||
|
||||
pub async fn get_pending_questions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<Vec<serde_json::Value>> {
|
||||
let pending = state.pending_questions.lock().await;
|
||||
let mut pending = state.session.pending_questions.lock().await;
|
||||
// 清理过期条目(agent 崩溃后不会被 answer_question 清理)
|
||||
pending.retain(|_, pq| pq.created_at.elapsed().as_secs() < PENDING_TTL_SECS);
|
||||
let questions: Vec<serde_json::Value> = pending
|
||||
.iter()
|
||||
.map(|(id, pq)| {
|
||||
@ -401,7 +449,7 @@ pub async fn respond_permission(
|
||||
Path(session_id): Path<String>,
|
||||
Json(req): Json<super::PermissionResponse>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let mut perms = state.pending_permissions.lock().await;
|
||||
let mut perms = state.session.pending_permissions.lock().await;
|
||||
|
||||
// 按 tool_call_id 查找匹配的权限请求
|
||||
let perm_id = perms
|
||||
@ -440,7 +488,9 @@ pub async fn respond_permission(
|
||||
pub async fn get_pending_permissions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<Vec<serde_json::Value>> {
|
||||
let perms = state.pending_permissions.lock().await;
|
||||
let mut perms = state.session.pending_permissions.lock().await;
|
||||
// 清理过期条目(agent 崩溃后不会被 respond_permission 清理)
|
||||
perms.retain(|_, p| p.created_at.elapsed().as_secs() < PENDING_TTL_SECS);
|
||||
let result: Vec<serde_json::Value> = perms
|
||||
.iter()
|
||||
.map(|(id, p)| {
|
||||
|
||||
117
src/api/auth.rs
117
src/api/auth.rs
@ -83,13 +83,14 @@ fn prune_expired_sessions(
|
||||
) {
|
||||
let now = chrono::Utc::now();
|
||||
let expiry_duration = chrono::Duration::hours(24);
|
||||
let expiry_std = expiry_duration
|
||||
.to_std()
|
||||
.unwrap_or(std::time::Duration::from_secs(86400));
|
||||
sessions.retain(|_, last_active| {
|
||||
if let Ok(dur) = now.signed_duration_since(*last_active).to_std() {
|
||||
dur < expiry_duration
|
||||
.to_std()
|
||||
.unwrap_or(std::time::Duration::from_secs(86400))
|
||||
} else {
|
||||
true // 尚未到达或时钟回拨,保留
|
||||
// 时钟回拨或异常:无法计算时间差时保留会话,避免误删
|
||||
match now.signed_duration_since(*last_active).to_std() {
|
||||
Ok(dur) => dur < expiry_std,
|
||||
Err(_) => true,
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -104,7 +105,13 @@ fn get_bookmarklet_api_key(password: &str) -> String {
|
||||
}
|
||||
|
||||
/// 最大会话数上限,超过后拒绝新登录以防止内存耗尽
|
||||
const MAX_SESSIONS: usize = 1000;
|
||||
const MAX_SESSIONS: usize = 10000;
|
||||
|
||||
/// 过期会话清理间隔(秒)。避免每个请求都触发全表扫描。
|
||||
const PRUNE_INTERVAL_SECS: u64 = 300;
|
||||
|
||||
/// 上次执行 prune_expired_sessions 的 Unix 时间戳(秒),用 AtomicU64 实现无锁读取。
|
||||
static LAST_PRUNE_TS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
// 登录接口:验证密码成功后,写入 HttpOnly Cookie
|
||||
pub async fn login(
|
||||
@ -121,15 +128,17 @@ pub async fn login(
|
||||
const WINDOW_SECS: u64 = 300;
|
||||
const MAX_RATE_LIMITER_ENTRIES: usize = 10000;
|
||||
{
|
||||
let mut limiter = state.login_rate_limiter.lock().await;
|
||||
// 批量清理过期条目,防止内存无限增长
|
||||
limiter.retain(|_, (_, first_fail)| first_fail.elapsed().as_secs() < WINDOW_SECS);
|
||||
// 容量保护:超过上限时直接清空(比 O(n log n) 排序更高效,且不影响安全性)
|
||||
if limiter.len() > MAX_RATE_LIMITER_ENTRIES {
|
||||
limiter.clear();
|
||||
state
|
||||
.login_rate_limiter
|
||||
.retain(|_, (_, first_fail)| first_fail.elapsed().as_secs() < WINDOW_SECS);
|
||||
// 容量保护:超过上限时直接清空
|
||||
if state.login_rate_limiter.len() > MAX_RATE_LIMITER_ENTRIES {
|
||||
state.login_rate_limiter.clear();
|
||||
}
|
||||
|
||||
if let Some((count, first_fail)) = limiter.get(&client_ip) {
|
||||
if let Some(entry) = state.login_rate_limiter.get(&client_ip) {
|
||||
let (count, first_fail) = entry.value();
|
||||
let elapsed = first_fail.elapsed().as_secs();
|
||||
if elapsed < WINDOW_SECS && *count >= MAX_FAILURES {
|
||||
let remaining = WINDOW_SECS - elapsed;
|
||||
@ -149,13 +158,13 @@ pub async fn login(
|
||||
}
|
||||
if verify_password(&password, &state.config.admin_password) {
|
||||
// 成功:清除该 IP 的失败记录
|
||||
state.login_rate_limiter.lock().await.remove(&client_ip);
|
||||
state.login_rate_limiter.remove(&client_ip);
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// 异常安全地记录活跃会话及其当前时间
|
||||
{
|
||||
let mut sessions = state.sessions.write().await;
|
||||
let mut sessions = state.session.sessions.write().await;
|
||||
// 先清理过期会话
|
||||
prune_expired_sessions(&mut sessions);
|
||||
// 检查会话上限
|
||||
@ -167,6 +176,16 @@ pub async fn login(
|
||||
}
|
||||
sessions.insert(token.clone(), chrono::Utc::now());
|
||||
}
|
||||
// 同步写入无锁快照供 auth 中间件高频读取
|
||||
state.session.session_last_active.insert(
|
||||
token.clone(),
|
||||
std::sync::atomic::AtomicU64::new(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
),
|
||||
);
|
||||
|
||||
// 构建 Set-Cookie 报头
|
||||
// 全局统一使用 SameSite=Lax 且不带 Secure,以完美支持 HTTP、HTTPS 以及局域网 IP 访问
|
||||
@ -190,11 +209,11 @@ pub async fn login(
|
||||
))
|
||||
} else {
|
||||
// 失败:记录该 IP 的失败次数
|
||||
let mut limiter = state.login_rate_limiter.lock().await;
|
||||
let entry = limiter
|
||||
state
|
||||
.login_rate_limiter
|
||||
.entry(client_ip)
|
||||
.or_insert((0, std::time::Instant::now()));
|
||||
entry.0 += 1;
|
||||
.and_modify(|entry| entry.0 += 1)
|
||||
.or_insert((1, std::time::Instant::now()));
|
||||
Err(AppError::unauthorized("密码错误"))
|
||||
}
|
||||
}
|
||||
@ -207,7 +226,8 @@ pub async fn logout(
|
||||
let token_to_remove = extract_auth_token_from_headers(req.headers());
|
||||
|
||||
if let Some(ref token) = token_to_remove {
|
||||
state.sessions.write().await.remove(token);
|
||||
state.session.sessions.write().await.remove(token);
|
||||
state.session.session_last_active.remove(token);
|
||||
}
|
||||
|
||||
// 通过 Max-Age=0 清除浏览器端的 Cookie
|
||||
@ -239,39 +259,32 @@ pub async fn auth_middleware(
|
||||
let mut is_valid = false;
|
||||
if let Some(ref tok) = token {
|
||||
// 1. 优先校验内存中的动态 Session
|
||||
// 先用读锁快速检查会话是否存在,并获取其最后活跃时间
|
||||
let mut last_active_opt = None;
|
||||
{
|
||||
let sessions = state.sessions.read().await;
|
||||
if let Some(last_active) = sessions.get(tok) {
|
||||
is_valid = true;
|
||||
last_active_opt = Some(*last_active);
|
||||
// 快速路径:用 AtomicU64 无锁检查 last_active,避免每次请求获取写锁
|
||||
if let Some(entry) = state.session.session_last_active.get(tok) {
|
||||
is_valid = true;
|
||||
let now_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let last_ts = entry.value().load(std::sync::atomic::Ordering::Relaxed);
|
||||
// 仅当超过 5 分钟未更新时才获取写锁
|
||||
if now_ts.saturating_sub(last_ts) > 300 {
|
||||
entry
|
||||
.value()
|
||||
.store(now_ts, std::sync::atomic::Ordering::Relaxed);
|
||||
// 按间隔执行过期会话清理
|
||||
let last_prune = LAST_PRUNE_TS.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if now_ts.saturating_sub(last_prune) >= PRUNE_INTERVAL_SECS {
|
||||
LAST_PRUNE_TS.store(now_ts, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut sessions = state.session.sessions.write().await;
|
||||
prune_expired_sessions(&mut sessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 会话存在时,如果最后活跃时间超过 60 秒,则获取写锁更新最后活跃时间 + 过期清理
|
||||
if is_valid {
|
||||
let needs_update = match last_active_opt {
|
||||
Some(last_active) => {
|
||||
if let Ok(dur) = chrono::Utc::now()
|
||||
.signed_duration_since(last_active)
|
||||
.to_std()
|
||||
{
|
||||
dur.as_secs() > 60
|
||||
} else {
|
||||
true // 时钟回拨等异常情况,强制更新
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
if needs_update {
|
||||
let mut sessions = state.sessions.write().await;
|
||||
// 顺便执行过期会话自动清理垃圾收集
|
||||
prune_expired_sessions(&mut sessions);
|
||||
if let Some(last_active) = sessions.get_mut(tok) {
|
||||
*last_active = chrono::Utc::now();
|
||||
}
|
||||
} else {
|
||||
// Atomic map 中不存在,降级检查 RwLock sessions(兼容登录时序)
|
||||
let sessions = state.session.sessions.read().await;
|
||||
if sessions.contains_key(tok) {
|
||||
is_valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -74,7 +74,7 @@ pub async fn vizier_query(
|
||||
let max_records = clamp_max(params.max_records);
|
||||
let result = crate::services::cds::vizier::query_adql_cached(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
&state.sources.vizier,
|
||||
¶ms.adql,
|
||||
max_records,
|
||||
)
|
||||
@ -100,22 +100,12 @@ pub async fn vizier_table(
|
||||
|
||||
let result = crate::services::cds::vizier::query_table(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
&state.sources.vizier,
|
||||
¶ms.table,
|
||||
&columns,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("VizieR 表查询失败: {}", e);
|
||||
// 标识符非法 → bad_request
|
||||
let msg = e.to_string();
|
||||
if msg.contains("非法字符") || msg.contains("不能为空") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("VizieR 表查询失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@ -130,7 +120,7 @@ pub async fn cone_search(
|
||||
|
||||
let result = crate::services::cds::vizier::cone_search(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
&state.sources.vizier,
|
||||
params.ra,
|
||||
params.dec,
|
||||
radius,
|
||||
@ -138,15 +128,6 @@ pub async fn cone_search(
|
||||
max_records,
|
||||
nearest,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Cone Search 失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径") || msg.contains("坐标") || msg.contains("非法字符") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("Cone Search 失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@ -36,6 +36,10 @@ pub enum AppError {
|
||||
#[error("{0}")]
|
||||
Gone(String),
|
||||
|
||||
/// 外部服务错误(HTTP 502 Bad Gateway)。
|
||||
#[error("{0}")]
|
||||
BadGateway(String),
|
||||
|
||||
/// 内部错误:对外只返回通用提示,详细信息仅写入日志。
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
@ -62,6 +66,10 @@ impl AppError {
|
||||
AppError::Gone(msg.into())
|
||||
}
|
||||
|
||||
pub fn bad_gateway(msg: impl Into<String>) -> Self {
|
||||
AppError::BadGateway(msg.into())
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
AppError::Internal(msg.into())
|
||||
}
|
||||
@ -74,6 +82,7 @@ impl AppError {
|
||||
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||
AppError::Gone(_) => StatusCode::GONE,
|
||||
AppError::BadGateway(_) => StatusCode::BAD_GATEWAY,
|
||||
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
@ -88,6 +97,7 @@ impl AppError {
|
||||
AppError::Unauthorized(m) => m.clone(),
|
||||
AppError::Conflict(m) => m.clone(),
|
||||
AppError::Gone(m) => m.clone(),
|
||||
AppError::BadGateway(m) => m.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -104,6 +114,20 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ServiceError 构造 AppError,消除 handler 中的 msg.contains() 匹配。
|
||||
impl From<crate::services::error::ServiceError> for AppError {
|
||||
fn from(e: crate::services::error::ServiceError) -> Self {
|
||||
use crate::services::error::ServiceError;
|
||||
match e {
|
||||
ServiceError::NotFound(m) => AppError::NotFound(m),
|
||||
ServiceError::NotReady(m) => AppError::bad_request(m),
|
||||
ServiceError::InvalidInput(m) => AppError::bad_request(m),
|
||||
ServiceError::External(m) => AppError::BadGateway(m),
|
||||
ServiceError::Other(e) => AppError::Internal(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷转换:从 anyhow::Error 构造内部错误。
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
@ -147,6 +171,10 @@ mod tests {
|
||||
AppError::internal("x").status_code(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
assert_eq!(
|
||||
AppError::bad_gateway("x").status_code(),
|
||||
StatusCode::BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
148
src/api/mod.rs
148
src/api/mod.rs
@ -32,6 +32,7 @@ pub enum AppEvent {
|
||||
pub struct PendingQuestion {
|
||||
pub question_json: String,
|
||||
pub answer_tx: oneshot::Sender<crate::agent::tools::ask_user::UserAnswer>,
|
||||
pub created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// 用户对权限请求的响应
|
||||
@ -51,6 +52,41 @@ pub struct PendingPermission {
|
||||
pub message: String,
|
||||
pub arguments: serde_json::Value,
|
||||
pub response_tx: oneshot::Sender<PermissionResponse>,
|
||||
pub created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
// ── LLM 客户端子结构 ──
|
||||
pub struct LlmState {
|
||||
pub primary: LlmClient,
|
||||
pub medium: LlmClient,
|
||||
pub fast: LlmClient,
|
||||
pub vision: Option<LlmClient>,
|
||||
pub embedding: EmbeddingClient,
|
||||
}
|
||||
|
||||
// ── 天文数据源客户端子结构 ──
|
||||
pub struct DataSourceState {
|
||||
pub ads: AdsClient,
|
||||
pub arxiv: ArxivClient,
|
||||
pub vizier: VizierClient,
|
||||
pub lamost: LamostClient,
|
||||
pub gaia: GaiaClient,
|
||||
pub sdss: SdssClient,
|
||||
pub desi: DesiClient,
|
||||
pub irsa: crate::clients::irsa::IrsaClient,
|
||||
pub mast: crate::clients::mast::MastClient,
|
||||
pub observation_registry: Arc<crate::services::observation::ObservationRegistry>,
|
||||
}
|
||||
|
||||
// ── 会话与认证子结构 ──
|
||||
pub struct SessionState {
|
||||
pub sessions: Arc<RwLock<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>,
|
||||
pub session_last_active: Arc<dashmap::DashMap<String, std::sync::atomic::AtomicU64>>,
|
||||
pub cancelled_runs: Arc<dashmap::DashMap<String, ()>>,
|
||||
pub session_permission_checkers:
|
||||
Arc<dashmap::DashMap<String, crate::agent::runtime::permission::PermissionChecker>>,
|
||||
pub pending_questions: Arc<Mutex<HashMap<String, PendingQuestion>>>,
|
||||
pub pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
|
||||
}
|
||||
|
||||
// 全局共享的 Axum 应用上下文状态
|
||||
@ -59,57 +95,20 @@ pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub dict: Dictionary,
|
||||
pub qiniu: QiniuClient,
|
||||
pub ads: AdsClient,
|
||||
pub arxiv: ArxivClient,
|
||||
/// VizieR TAP 星表查询客户端(CDS VizieR TAP 服务)
|
||||
pub vizier: VizierClient,
|
||||
/// LAMOST 观测数据客户端(ConeSearch + FITS.gz 下载,服务光谱/光变等产品)
|
||||
pub lamost: LamostClient,
|
||||
/// Gaia 观测数据客户端(TAP + DataLink,服务光谱/光变等产品)
|
||||
pub gaia: GaiaClient,
|
||||
/// SDSS 观测数据客户端(Data Lab TAP + SAS,服务光谱/APOGEE 等产品)
|
||||
pub sdss: SdssClient,
|
||||
/// DESI 观测数据客户端(Data Lab TAP + HEALPix coadd SAS,服务光谱等产品)
|
||||
pub desi: DesiClient,
|
||||
/// IRSA/IPAC 客户端(ZTF 光变曲线 REST API)
|
||||
pub irsa: crate::clients::irsa::IrsaClient,
|
||||
/// MAST 客户端(TESS 光变曲线 + TIC 星表)
|
||||
pub mast: crate::clients::mast::MastClient,
|
||||
/// 观测数据 fetcher 注册表(跨源 × 跨产品类型的统一获取入口)
|
||||
pub observation_registry: Arc<crate::services::observation::ObservationRegistry>,
|
||||
pub llm: LlmClient,
|
||||
pub medium_llm: LlmClient,
|
||||
pub fast_llm: LlmClient,
|
||||
/// 专用视觉模型(LLM_VISION_MODEL 配置时启用图片分析工具)
|
||||
pub vision_llm: Option<LlmClient>,
|
||||
pub embedding: EmbeddingClient,
|
||||
pub downloader: Downloader,
|
||||
/// 共享 HTTP 客户端(带超时),避免每次请求新建连接池
|
||||
pub http_client: reqwest::Client,
|
||||
pub skill_registry: Arc<RwLock<SkillRegistry>>,
|
||||
pub sse_broadcast: Option<broadcast::Sender<AppEvent>>,
|
||||
pub memory_manager: Arc<tokio::sync::Mutex<MemoryManager>>,
|
||||
pub harvest_status: Arc<tokio::sync::Mutex<crate::services::batch::MetaSyncStatus>>,
|
||||
pub batch_status: Arc<tokio::sync::Mutex<crate::services::batch::AssetBatchStatus>>,
|
||||
pub active_bibcode: Arc<tokio::sync::Mutex<Option<String>>>,
|
||||
pub cancelled_runs: Arc<dashmap::DashMap<String, ()>>,
|
||||
pub skill_registry: Arc<RwLock<SkillRegistry>>,
|
||||
/// ask_user 工具 — 待回答的问题
|
||||
pub pending_questions: Arc<Mutex<HashMap<String, PendingQuestion>>>,
|
||||
/// 权限检查 — 待处理的权限确认请求
|
||||
pub pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
|
||||
/// 会话级权限检查器注册表(按 session_id 隔离,支持 API 动态添加/移除规则)
|
||||
pub session_permission_checkers:
|
||||
Arc<dashmap::DashMap<String, crate::agent::runtime::permission::PermissionChecker>>,
|
||||
/// SSE 广播通道(agent 运行时向所有连接的客户端推送事件)
|
||||
pub sse_broadcast: Option<broadcast::Sender<AppEvent>>,
|
||||
/// 项目记忆管理器(跨会话持久化)
|
||||
pub memory_manager: Arc<tokio::sync::Mutex<MemoryManager>>,
|
||||
/// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理)
|
||||
pub sessions: Arc<RwLock<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>,
|
||||
/// 登录速率限制器(IP -> 最近失败次数 + 首次失败时间)
|
||||
pub login_rate_limiter:
|
||||
Arc<Mutex<std::collections::HashMap<String, (u32, std::time::Instant)>>>,
|
||||
/// 上传速率限制器(token -> 最近上传次数 + 首次上传时间)
|
||||
pub upload_rate_limiter:
|
||||
Arc<Mutex<std::collections::HashMap<String, (u32, std::time::Instant)>>>,
|
||||
pub login_rate_limiter: Arc<dashmap::DashMap<String, (u32, std::time::Instant)>>,
|
||||
pub upload_rate_limiter: Arc<dashmap::DashMap<String, (u32, std::time::Instant)>>,
|
||||
// 子结构
|
||||
pub llm: LlmState,
|
||||
pub sources: DataSourceState,
|
||||
pub session: SessionState,
|
||||
}
|
||||
|
||||
// 统一标准化的文献格式,用于向前端传输
|
||||
@ -126,60 +125,3 @@ pub mod permissions;
|
||||
pub mod search;
|
||||
pub mod sync;
|
||||
pub mod targets;
|
||||
|
||||
// 提供兼容的 handlers 命名空间,避免修改 main.rs 里的导入
|
||||
pub mod handlers {
|
||||
pub use super::agent::{
|
||||
answer_question, branch_session, chat_agent, delete_session, get_agent_metrics,
|
||||
get_agent_modes, get_pending_permissions, get_pending_questions, get_session,
|
||||
get_session_audit, list_sessions, respond_permission, restore_rewound_session,
|
||||
retry_session, rewind_session, stop_agent, AgentChatRequest, BranchResponse,
|
||||
RestoreResponse, RetryResponse, RewindRequest, RewindResponse, SessionListParams,
|
||||
};
|
||||
pub use super::auth::{check_auth, login, logout};
|
||||
pub use super::catalog::{
|
||||
cone_search, vizier_query, vizier_table, ConeSearchParams, VizierQueryParams,
|
||||
VizierTableParams,
|
||||
};
|
||||
pub use super::notes::{
|
||||
create_note, delete_note, get_notes, CreateNoteRequest, DeleteNoteParams, GetNotesParams,
|
||||
NoteRecord,
|
||||
};
|
||||
pub use super::observation::{
|
||||
observation_capabilities, observation_download, observation_list, observation_preview,
|
||||
observation_search, unified_resolve, unified_search, DownloadMode,
|
||||
ObservationDownloadRequest, ObservationListParams, ObservationListResponse,
|
||||
ObservationPreviewParams, ObservationSearchParams,
|
||||
};
|
||||
pub use super::papers::{
|
||||
download_paper, embed_paper, export_citations, get_active_bibcode, get_citation_network,
|
||||
get_library, get_paper_detail, mark_no_resource, parse_paper, search_papers,
|
||||
set_active_bibcode, translate_paper, upload_paper_file, DownloadRequest, EmbedRequest,
|
||||
EmbedResponse, ExportRequest, ExportResponse, MarkNoResourceRequest, PaperDetailResponse,
|
||||
ParseRequest, ParseResponse, SearchParams, TranslateRequest, TranslateResponse,
|
||||
};
|
||||
pub use super::permissions::{
|
||||
list_permission_rules, update_permission_mode, update_permission_rules,
|
||||
};
|
||||
pub use super::search::{search as search_history, SearchParams as SearchHistoryParams};
|
||||
pub use super::sync::{
|
||||
delete_sync_query, get_asset_batch_status, get_meta_sync_count, get_meta_sync_status,
|
||||
get_sync_queries, run_asset_batch, run_meta_sync, stop_asset_batch, AssetBatchRunRequest,
|
||||
MetaSyncCountRequest, MetaSyncCountResponse, MetaSyncRunRequest,
|
||||
};
|
||||
pub use super::targets::{
|
||||
associate_target, extract_paper_targets, list_targets, query_target, AssociateResponse,
|
||||
AssociateTargetRequest, ExtractTargetsRequest, ExtractTargetsResponse, TargetListParams,
|
||||
TargetQueryParams,
|
||||
};
|
||||
pub use super::{AppState, StandardPaper};
|
||||
pub use crate::services::batch::SavedSyncQuery;
|
||||
pub use crate::services::paper::{
|
||||
check_paper_paths_in_db, convert_ads_doc_to_standard, convert_arxiv_to_standard,
|
||||
get_paper_from_db, save_paper_to_db, save_paper_to_db_tx,
|
||||
};
|
||||
pub use crate::services::session::{
|
||||
AgentMetrics as AgentMetricsResponse, AuditLogEntry, MessageRecord, SessionDetail,
|
||||
SessionSummary,
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ pub async fn observation_search(
|
||||
|
||||
let candidates = crate::services::observation::search_observation(
|
||||
&state,
|
||||
&state.observation_registry,
|
||||
&state.sources.observation_registry,
|
||||
source,
|
||||
&product,
|
||||
params.ra,
|
||||
@ -83,22 +83,7 @@ pub async fn observation_search(
|
||||
params.release.as_deref(),
|
||||
params.version.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("观测数据检索失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径")
|
||||
|| msg.contains("坐标")
|
||||
|| msg.contains("格式")
|
||||
|| msg.contains("暂不支持")
|
||||
|| msg.contains("应为")
|
||||
|| msg.contains("不支持的")
|
||||
{
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("观测数据检索失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
.await?;
|
||||
Ok(Json(candidates))
|
||||
}
|
||||
|
||||
@ -182,23 +167,13 @@ pub async fn observation_download(
|
||||
}
|
||||
};
|
||||
|
||||
let result = download_observation(&state, &state.observation_registry, &request, req.force)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("观测数据下载失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径")
|
||||
|| msg.contains("坐标")
|
||||
|| msg.contains("格式")
|
||||
|| msg.contains("暂不支持")
|
||||
|| msg.contains("应为")
|
||||
|| msg.contains("不支持的")
|
||||
{
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("观测数据下载失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
let result = download_observation(
|
||||
&state,
|
||||
&state.sources.observation_registry,
|
||||
&request,
|
||||
req.force,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@ -209,7 +184,7 @@ pub async fn observation_download(
|
||||
pub async fn observation_capabilities(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
) -> ApiResult<Json<Vec<crate::services::observation::CapabilitySpec>>> {
|
||||
let caps = state.observation_registry.list_capabilities();
|
||||
let caps = state.sources.observation_registry.list_capabilities();
|
||||
Ok(Json(caps))
|
||||
}
|
||||
|
||||
@ -291,17 +266,7 @@ pub async fn observation_preview(
|
||||
¶ms.source_id,
|
||||
params.artifact_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("{:#}", e);
|
||||
// 缓存未找到 / 文件缺失 → 404;解析失败 → 500
|
||||
if msg.contains("缓存中未找到") || msg.contains("不存在于磁盘") {
|
||||
AppError::not_found(msg)
|
||||
} else {
|
||||
tracing::error!("FITS 预览解析失败: {}", msg);
|
||||
AppError::internal(format!("FITS 预览解析失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
.await?;
|
||||
|
||||
Ok(Json(preview))
|
||||
}
|
||||
@ -319,20 +284,10 @@ pub async fn unified_search(
|
||||
) -> ApiResult<Json<crate::services::observation::UnifiedSearchResult>> {
|
||||
let result = crate::services::observation::unified::unified_search(
|
||||
&state,
|
||||
&state.observation_registry,
|
||||
&state.sources.observation_registry,
|
||||
&req,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("{:#}", e);
|
||||
tracing::error!("统一检索失败: {}", msg);
|
||||
if msg.contains("不能为空") || msg.contains("暂不支持") || msg.contains("不支持的")
|
||||
{
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("统一检索失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user