feat: Docker 容器化、Cookie 鉴权、Coordinator 编排、FTS5 搜索与 P1-P3 全面收尾
Docker 容器化部署
- 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB)
两种镜像,Docker Compose 一键启动
- build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建
- 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制
安全:Cookie-Based 鉴权系统
- HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理)
- 登录/登出/验证接口 + 中间件注入
- 前端登录页面 + 退出按钮
- 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由
- 书签脚本 fetch 添加 credentials:'include'
Coordinator 模式 (P2)
- 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize)
- WorkerPool + Semaphore 并发控制 + 超时保护
- 前端协调者模式开关
Hook 系统:UserPromptSubmit 事件 (P2)
- 第 13 个生命周期事件,fire-and-forget 审计
FTS5 全文搜索 (P3)
- agent_sessions_fts + agent_messages_fts 虚拟表
- search_history Agent 工具 + /api/search/history HTTP 接口
- 前端防抖搜索框 + 仅当前会话筛选
工具加载优化 (P3)
- defer_loading 延迟加载 (7 个重型工具)
- is_readonly 只读标记 (9 个查询工具)
- classifier_summary 工具目录供 LLM 按需判断
模型回退策略 (P3)
- LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换
- LlmClient model 改为 Arc<RwLock> 支持运行时切换
- 连续 3 次过载后自动切换
压缩记忆桥接 (P3)
- 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction)
git2 依赖修复
- 切换到 vendored-libgit2,消除 OpenSSL 系统依赖
This commit is contained in:
+147
-3
@@ -1,7 +1,7 @@
|
||||
// dashboard/src/App.tsx
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle, Lock } from 'lucide-react';
|
||||
import { Sidebar } from './components/layout/Sidebar';
|
||||
import { SearchPanel, getDoctypeBadge } from './features/search/SearchPanel';
|
||||
import { LibraryPanel } from './features/library/LibraryPanel';
|
||||
@@ -12,6 +12,12 @@ import { ResearchAgentPanel } from './features/agent/ResearchAgentPanel';
|
||||
import type { StandardPaper, CitationNetwork, NoteRecord } from './types';
|
||||
|
||||
export default function App() {
|
||||
// 登录与鉴权相关状态
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => {
|
||||
const saved = localStorage.getItem('astro_active_tab');
|
||||
return (saved as any) || 'search';
|
||||
@@ -21,6 +27,53 @@ export default function App() {
|
||||
localStorage.setItem('astro_active_tab', activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
// 全局启用 Axios 跨域 Cookie 传输凭证
|
||||
useEffect(() => {
|
||||
axios.defaults.withCredentials = true;
|
||||
}, []);
|
||||
|
||||
// 初始化校验登录凭证状态(由浏览器自动带上 Cookie)
|
||||
useEffect(() => {
|
||||
axios.get('/api/auth/check')
|
||||
.then(() => {
|
||||
setIsAuthenticated(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsAuthenticated(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) {
|
||||
setLoginError('请输入访问密码!');
|
||||
return;
|
||||
}
|
||||
setLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await axios.post('/api/auth/login', { password });
|
||||
setIsAuthenticated(true);
|
||||
setLoginError(null);
|
||||
} catch (err: any) {
|
||||
console.error('登录校验失败:', err);
|
||||
const errMsg = err.response?.data || '密码错误,请重试。';
|
||||
setLoginError(errMsg);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/logout');
|
||||
} catch (e) {
|
||||
console.error('登出失败:', e);
|
||||
}
|
||||
setIsAuthenticated(false);
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
// 全局对话框弹窗状态
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: 'alert' | 'confirm';
|
||||
@@ -124,8 +177,10 @@ export default function App() {
|
||||
|
||||
// 1. 初始化时加载本地文献
|
||||
useEffect(() => {
|
||||
fetchLibrary();
|
||||
}, []);
|
||||
if (isAuthenticated === true) {
|
||||
fetchLibrary();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
try {
|
||||
@@ -529,6 +584,94 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9]">
|
||||
<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">正在校验系统安全凭证...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (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="console-panel rounded-xl p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
|
||||
{/* Logo & 头部 */}
|
||||
<div className="flex flex-col items-center text-center mb-7 select-none">
|
||||
<div className="w-14 h-14 mb-3">
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
|
||||
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
|
||||
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
|
||||
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill="url(#loginStarGrad)" />
|
||||
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
|
||||
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
|
||||
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
|
||||
<defs>
|
||||
<linearGradient id="loginStarGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#106ba3" />
|
||||
<stop offset="100%" stopColor="#0a2540" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">AstroResearch</h2>
|
||||
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">天文学科研辅助系统 · 安全登录</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-[#0a2540] block">访问密码</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="请输入系统访问密码"
|
||||
autoFocus
|
||||
disabled={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"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-slate-400">
|
||||
<Lock className="w-3.5 h-3.5 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<div className="p-2.5 rounded-lg bg-red-50 border border-red-200 text-[10px] font-bold text-red-700 leading-relaxed">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={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"
|
||||
>
|
||||
{loggingIn ? (
|
||||
<>
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>正在校验...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>登录系统</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
|
||||
@@ -538,6 +681,7 @@ export default function App() {
|
||||
setActiveTab={setActiveTab}
|
||||
selectedPaper={selectedPaper}
|
||||
loadCitations={loadCitations}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
{/* 主工作区 */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// dashboard/src/components/layout/Sidebar.tsx
|
||||
import { useState } from 'react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles } from 'lucide-react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -8,9 +8,10 @@ interface SidebarProps {
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
|
||||
selectedPaper: StandardPaper | null;
|
||||
loadCitations: (bibcode: string) => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations }: SidebarProps) {
|
||||
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const renderLogo = () => (
|
||||
@@ -137,8 +138,8 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部当前选定文献提示 (平滑动画版本) */}
|
||||
<div className="space-y-4">
|
||||
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
|
||||
<div className="space-y-3">
|
||||
{selectedPaper ? (
|
||||
<div
|
||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||
@@ -207,6 +208,27 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 退出登录按钮 */}
|
||||
<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-red-600 hover:bg-red-50 hover:text-red-700 cursor-pointer ${
|
||||
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
|
||||
}`}
|
||||
title={isCollapsed ? "退出登录" : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-red-500" />
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
isCollapsed
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
退出登录
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Brain, Settings, Eye, CheckCircle2, AlertTriangle,
|
||||
Send, Loader, Plus, Trash2, Compass, Clock, Square,
|
||||
BarChart3, ScrollText, Network, Rewind, RotateCcw,
|
||||
GitBranch, RefreshCw
|
||||
GitBranch, RefreshCw, Search, X, MessageSquare, BookOpen
|
||||
} from 'lucide-react';
|
||||
import { AskUserQuestionCard } from './AskUserQuestionCard';
|
||||
import { PermissionRequestCard } from './PermissionRequestCard';
|
||||
@@ -28,6 +28,14 @@ interface SessionSummary {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
result_type: string;
|
||||
session_id: string;
|
||||
title?: string | null;
|
||||
snippet: string;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: number;
|
||||
agent_name: string;
|
||||
@@ -185,9 +193,16 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [coordinatorMode, setCoordinatorMode] = useState(false);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
// 全文搜索历史记录状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchScopeOnlyCurrent, setSearchScopeOnlyCurrent] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [loadingSearch, setLoadingSearch] = useState(false);
|
||||
|
||||
// 展开折叠控制
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||
@@ -309,6 +324,38 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
// 跨会话历史检索防抖逻辑
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const delayDebounceFn = setTimeout(async () => {
|
||||
setLoadingSearch(true);
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
q: searchQuery,
|
||||
scope: 'all',
|
||||
limit: 30,
|
||||
};
|
||||
if (searchScopeOnlyCurrent && currentSessionId) {
|
||||
params.session_id = currentSessionId;
|
||||
}
|
||||
const res = await axios.get<SearchResult[]>('/api/search/history', {
|
||||
params,
|
||||
});
|
||||
setSearchResults(res.data);
|
||||
} catch (e) {
|
||||
console.error('搜索会话历史失败:', e);
|
||||
} finally {
|
||||
setLoadingSearch(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchQuery, searchScopeOnlyCurrent, currentSessionId]);
|
||||
|
||||
// 新建会话
|
||||
const handleNewSession = () => {
|
||||
setCurrentSessionId(null);
|
||||
@@ -536,6 +583,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
question: questionText,
|
||||
session_id: currentSessionId,
|
||||
thinking,
|
||||
coordinator_mode: coordinatorMode,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1079,47 +1127,144 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
</button>
|
||||
</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="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索历史会话或消息内容..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-lg bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white 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>
|
||||
{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"
|
||||
>
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchScopeOnlyCurrent}
|
||||
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
|
||||
className="rounded text-sky-600 border-slate-300 focus:ring-sky-500 w-3 h-3"
|
||||
/>
|
||||
<span>仅搜索当前会话</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2.5 space-y-1.5 scrollbar-thin">
|
||||
{loadingSessions ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
|
||||
<span>加载历史会话中...</span>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||
暂无历史会话记录
|
||||
</div>
|
||||
) : (
|
||||
sessions.map(session => {
|
||||
const isActive = session.session_id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
key={session.session_id}
|
||||
onClick={() => setCurrentSessionId(session.session_id)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-sky-50 border-sky-200 text-sky-850 font-bold shadow-2xs'
|
||||
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-650'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
{session.title || '无标题会话'}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold block text-left">
|
||||
{session.turn_count} 轮交互 • {session.updated_at.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
{searchQuery.trim() ? (
|
||||
loadingSearch ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
|
||||
<span>检索历史记录中...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||
未找到匹配的历史记录
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((result, idx) => {
|
||||
const isActive = result.session_id === currentSessionId;
|
||||
const isMessage = result.result_type.startsWith('message/');
|
||||
const role = isMessage ? result.result_type.split('/')[1] : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(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"
|
||||
title="删除此会话"
|
||||
key={`${result.session_id}-${idx}`}
|
||||
onClick={() => setCurrentSessionId(result.session_id)}
|
||||
className={`w-full text-left p-2.5 rounded-lg border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-sky-50/70 border-sky-200 text-sky-850 font-bold shadow-2xs'
|
||||
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
|
||||
{isMessage ? (
|
||||
<>
|
||||
<MessageSquare className="w-3 h-3 text-purple-400" />
|
||||
<span>{role === 'user' ? '提问' : '解答'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="w-3 h-3 text-sky-500" />
|
||||
<span>会话</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{result.created_at && (
|
||||
<span className="text-[8px] text-slate-400 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">
|
||||
{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"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
</button>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
// 正常的会话列表渲染
|
||||
loadingSessions ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
|
||||
<span>加载历史会话中...</span>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||
暂无历史会话记录
|
||||
</div>
|
||||
) : (
|
||||
sessions.map(session => {
|
||||
const isActive = session.session_id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
key={session.session_id}
|
||||
onClick={() => setCurrentSessionId(session.session_id)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-sky-50 border-sky-200 text-sky-850 font-bold shadow-2xs'
|
||||
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-650'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
{session.title || '无标题会话'}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold block text-left">
|
||||
{session.turn_count} 轮交互 • {session.updated_at.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(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"
|
||||
title="删除此会话"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1407,6 +1552,21 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
<Brain className={`w-4 h-4 ${thinking ? 'text-purple-500' : ''}`} />
|
||||
<span className="hidden sm:inline">{thinking ? '思考中' : '思考'}</span>
|
||||
</button>
|
||||
{/* 协调者模式开关 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoordinatorMode(!coordinatorMode)}
|
||||
disabled={streaming}
|
||||
title={coordinatorMode ? '协调者模式已开启(委托子智能体执行)' : '协调者模式已关闭(单智能体直接执行)'}
|
||||
className={`p-2.5 rounded-xl border text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5 shrink-0 ${
|
||||
coordinatorMode
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-2xs'
|
||||
: 'bg-slate-50 border-slate-250 text-slate-400 hover:text-sky-500 hover:border-sky-200'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Network className={`w-4 h-4 ${coordinatorMode ? 'text-sky-500' : ''}`} />
|
||||
<span className="hidden sm:inline">{coordinatorMode ? '协调中' : '协调'}</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
|
||||
@@ -880,7 +880,7 @@ export function SyncPanel() {
|
||||
if (el) {
|
||||
el.setAttribute(
|
||||
'href',
|
||||
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
|
||||
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -895,7 +895,7 @@ export function SyncPanel() {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
|
||||
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
|
||||
navigator.clipboard.writeText(bookmarkletCode);
|
||||
alert('书签代码已成功复制到剪贴板!');
|
||||
}}
|
||||
|
||||
@@ -133,3 +133,13 @@ body {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* FTS search result highlights */
|
||||
mark {
|
||||
background-color: rgba(254, 240, 138, 0.7); /* translucent yellow bg */
|
||||
color: #854d0e; /* text-yellow-800 */
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user