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:
fmq
2026-06-23 20:22:06 +08:00
parent 698d007f39
commit cec4b8cf7b
58 changed files with 3366 additions and 345 deletions
+147 -3
View File
@@ -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}
/>
{/* 主工作区 */}
+26 -4
View File
@@ -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}
+2 -2
View File
@@ -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('书签代码已成功复制到剪贴板!');
}}
+10
View File
@@ -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;
}