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 系统依赖
922 lines
41 KiB
TypeScript
922 lines
41 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import axios from 'axios';
|
||
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal, Lightbulb } from 'lucide-react';
|
||
|
||
import type { SavedSyncQuery } from '../../types';
|
||
import { CustomSelect } from '../../components/CustomSelect';
|
||
|
||
interface BatchStatus {
|
||
active: boolean;
|
||
total: number;
|
||
downloaded: number;
|
||
parsed: number;
|
||
download_failed: number;
|
||
parse_failed: number;
|
||
current_bibcode: string;
|
||
logs: string[];
|
||
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
|
||
}
|
||
|
||
interface HarvestStatus {
|
||
active: boolean;
|
||
query: string;
|
||
source: string;
|
||
synced: number;
|
||
total: number;
|
||
}
|
||
|
||
export function SyncPanel() {
|
||
const [query, setQuery] = useState('');
|
||
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
|
||
const [limit, setLimit] = useState<number>(200);
|
||
const [estimating, setEstimating] = useState(false);
|
||
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
|
||
const [status, setStatus] = useState<HarvestStatus>({
|
||
active: false,
|
||
query: '',
|
||
source: '',
|
||
synced: 0,
|
||
total: 0,
|
||
});
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
|
||
const pollIntervalRef = useRef<any>(null);
|
||
|
||
// 批量下载与解析相关状态
|
||
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
|
||
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
|
||
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
|
||
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
|
||
const [skipFailed, setSkipFailed] = useState<boolean>(false);
|
||
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
|
||
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
|
||
|
||
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
|
||
active: false,
|
||
total: 0,
|
||
downloaded: 0,
|
||
parsed: 0,
|
||
download_failed: 0,
|
||
parse_failed: 0,
|
||
current_bibcode: '',
|
||
logs: [],
|
||
});
|
||
const [batchError, setBatchError] = useState<string | null>(null);
|
||
const batchPollIntervalRef = useRef<any>(null);
|
||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const [showBuilder, setShowBuilder] = useState(false);
|
||
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
|
||
{ field: 'all', op: 'AND', val: '' }
|
||
]);
|
||
|
||
// 当高级表单规则变化时,自动更新同步输入框的检索式
|
||
const updateQueryFromRules = (currentRules: typeof rules) => {
|
||
let qParts: string[] = [];
|
||
currentRules.forEach((rule, idx) => {
|
||
if (!rule.val.trim()) return;
|
||
let valStr = rule.val.trim();
|
||
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
|
||
valStr = `"${valStr}"`;
|
||
}
|
||
|
||
let fieldPart = '';
|
||
if (rule.field !== 'all') {
|
||
fieldPart = `${rule.field}:${valStr}`;
|
||
} else {
|
||
fieldPart = valStr;
|
||
}
|
||
|
||
if (idx === 0) {
|
||
qParts.push(fieldPart);
|
||
} else {
|
||
qParts.push(`${rule.op} ${fieldPart}`);
|
||
}
|
||
});
|
||
setQuery(qParts.join(' '));
|
||
};
|
||
|
||
const handleAddRule = () => {
|
||
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||
};
|
||
|
||
const handleRemoveRule = (idx: number) => {
|
||
const next = rules.filter((_, i) => i !== idx);
|
||
setRules(next);
|
||
updateQueryFromRules(next);
|
||
};
|
||
|
||
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
|
||
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
|
||
setRules(next);
|
||
updateQueryFromRules(next);
|
||
};
|
||
|
||
// 获取历史检索配置
|
||
const fetchSyncQueries = async () => {
|
||
try {
|
||
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
|
||
setSyncQueries(res.data);
|
||
} catch (e) {
|
||
console.error('获取检索配置列表失败', e);
|
||
}
|
||
};
|
||
|
||
const handleDeleteQuery = async (id: number) => {
|
||
try {
|
||
await axios.delete(`/api/sync/queries/${id}`);
|
||
fetchSyncQueries();
|
||
} catch (e) {
|
||
console.error('删除配置失败', e);
|
||
}
|
||
};
|
||
|
||
const handleReuseQuery = (sq: SavedSyncQuery) => {
|
||
setQuery(sq.query);
|
||
setSource(sq.source as any);
|
||
setLimit(sq.limit_count);
|
||
};
|
||
|
||
const handleQuickSync = async (sq: SavedSyncQuery) => {
|
||
setErrorMsg(null);
|
||
|
||
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
|
||
setStatus(prev => ({
|
||
...prev,
|
||
active: true,
|
||
query: sq.query,
|
||
source: sq.source as any,
|
||
synced: 0,
|
||
total: sq.limit_count,
|
||
}));
|
||
startPolling();
|
||
|
||
try {
|
||
await axios.post('/api/sync/meta/run', {
|
||
q: sq.query,
|
||
source: sq.source,
|
||
limit: sq.limit_count,
|
||
});
|
||
fetchStatus();
|
||
setTimeout(fetchSyncQueries, 500);
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
setErrorMsg(e.response?.data || '启动快速同步失败。');
|
||
fetchStatus();
|
||
}
|
||
};
|
||
|
||
// 获取当前的元数据同步状态
|
||
const fetchStatus = async () => {
|
||
try {
|
||
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
|
||
setStatus(res.data);
|
||
if (res.data.active) {
|
||
startPolling();
|
||
} else {
|
||
if (pollIntervalRef.current) {
|
||
clearInterval(pollIntervalRef.current);
|
||
pollIntervalRef.current = null;
|
||
fetchSyncQueries();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('获取同步状态失败', e);
|
||
}
|
||
};
|
||
|
||
// 开始轮询
|
||
const startPolling = () => {
|
||
if (pollIntervalRef.current) return;
|
||
pollIntervalRef.current = setInterval(fetchStatus, 1000);
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchStatus();
|
||
fetchSyncQueries();
|
||
|
||
return () => {
|
||
if (pollIntervalRef.current) {
|
||
clearInterval(pollIntervalRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 批量下载与解析相关的网络操作
|
||
const fetchBatchStatus = async () => {
|
||
try {
|
||
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
|
||
setBatchStatus(res.data);
|
||
if (res.data.active) {
|
||
startBatchPolling();
|
||
} else if (batchPollIntervalRef.current) {
|
||
clearInterval(batchPollIntervalRef.current);
|
||
batchPollIntervalRef.current = null;
|
||
}
|
||
} catch (e) {
|
||
console.error('获取处理状态失败', e);
|
||
}
|
||
};
|
||
|
||
const startBatchPolling = () => {
|
||
if (batchPollIntervalRef.current) return;
|
||
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
|
||
};
|
||
|
||
const handleStartBatch = async () => {
|
||
setBatchError(null);
|
||
try {
|
||
await axios.post('/api/batch/asset/run', {
|
||
target_phase: targetPhase,
|
||
limit_count: batchLimitCount,
|
||
sort_order: sortOrder,
|
||
skip_completed: skipCompleted,
|
||
skip_failed: skipFailed,
|
||
skip_preceding_failed: skipPrecedingFailed,
|
||
skip_preceding_uncompleted: skipPrecedingUncompleted,
|
||
});
|
||
fetchBatchStatus();
|
||
startBatchPolling();
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
setBatchError(e.response?.data || '启动批量任务失败。');
|
||
}
|
||
};
|
||
|
||
const handleStopBatch = async () => {
|
||
try {
|
||
await axios.post('/api/batch/asset/stop');
|
||
fetchBatchStatus();
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
setBatchError(e.response?.data || '停止任务失败。');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchBatchStatus();
|
||
|
||
return () => {
|
||
if (batchPollIntervalRef.current) {
|
||
clearInterval(batchPollIntervalRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 日志终端自动滚动到底部 (仅限定滚动容器内部,不影响整个网页的滚动)
|
||
useEffect(() => {
|
||
const container = logsContainerRef.current;
|
||
if (container) {
|
||
// 阈值设为 80px。如果用户滚动到离底部距离小于 80px,则视为保持跟踪底部的模式
|
||
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
|
||
if (isCloseToBottom) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
}
|
||
}, [batchStatus.logs]);
|
||
|
||
// 估算文献总量
|
||
const handleEstimate = async () => {
|
||
if (!query.trim()) {
|
||
setErrorMsg('请输入检索关键词!');
|
||
return;
|
||
}
|
||
setErrorMsg(null);
|
||
setEstimating(true);
|
||
setEstimatedCount(null);
|
||
try {
|
||
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
|
||
params: { q: query.trim(), source }
|
||
});
|
||
setEstimatedCount(res.data.total);
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
|
||
} finally {
|
||
setEstimating(false);
|
||
}
|
||
};
|
||
|
||
// 启动任务
|
||
const handleStartHarvest = async () => {
|
||
if (!query.trim()) {
|
||
setErrorMsg('请输入检索关键词!');
|
||
return;
|
||
}
|
||
setErrorMsg(null);
|
||
|
||
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
|
||
setStatus(prev => ({
|
||
...prev,
|
||
active: true,
|
||
query: query.trim(),
|
||
source,
|
||
synced: 0,
|
||
total: 0,
|
||
}));
|
||
startPolling();
|
||
|
||
try {
|
||
await axios.post('/api/sync/meta/run', {
|
||
q: query.trim(),
|
||
source,
|
||
limit: limit,
|
||
});
|
||
fetchStatus();
|
||
setTimeout(fetchSyncQueries, 500);
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
|
||
fetchStatus();
|
||
}
|
||
};
|
||
|
||
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
|
||
|
||
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">
|
||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">批量任务管理器</h2>
|
||
<p className="text-slate-500 text-xs">设定检索关键词,在 NASA ADS 和 arXiv 平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。</p>
|
||
</div>
|
||
|
||
{errorMsg && (
|
||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
|
||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||
<div className="font-semibold">{errorMsg}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 控制面板卡片 */}
|
||
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden">
|
||
<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">
|
||
<span>检索关键词 (Query)</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowBuilder(!showBuilder)}
|
||
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1"
|
||
>
|
||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
|
||
</button>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
disabled={status.active}
|
||
placeholder="例如: hot subdwarf, Gaia BH1..."
|
||
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white 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">
|
||
<span>高级格式:</span>
|
||
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-slate-700 block">数据发布平台</label>
|
||
<div className="flex gap-2">
|
||
{[
|
||
{ id: 'all', label: '全部' },
|
||
{ id: 'ads', label: 'NASA ADS' },
|
||
{ id: 'arxiv', label: 'arXiv 预印本' },
|
||
].map(src => (
|
||
<button
|
||
key={src.id}
|
||
type="button"
|
||
disabled={status.active}
|
||
onClick={() => setSource(src.id as any)}
|
||
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all ${
|
||
source === src.id
|
||
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
|
||
: 'btn-console btn-console-secondary'
|
||
}`}
|
||
>
|
||
{src.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 动态表单生成器 */}
|
||
{showBuilder && (
|
||
<div className="p-4 rounded-lg 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">
|
||
<span>高级条件生成器</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddRule}
|
||
className="text-xs text-sky-600 hover:text-sky-700 font-bold"
|
||
>
|
||
+ 添加条件
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2.5">
|
||
{rules.map((rule, idx) => (
|
||
<div key={idx} className="flex items-center gap-2">
|
||
{idx > 0 ? (
|
||
<CustomSelect
|
||
value={rule.op}
|
||
onChange={val => handleRuleChange(idx, 'op', val)}
|
||
className="w-24"
|
||
options={[
|
||
{ value: 'AND', label: '并且 (AND)' },
|
||
{ value: 'OR', label: '或者 (OR)' },
|
||
{ value: 'NOT', label: '排除 (NOT)' },
|
||
]}
|
||
/>
|
||
) : (
|
||
<div className="w-24 text-center text-xs text-slate-500 font-semibold">筛选条件</div>
|
||
)}
|
||
|
||
<CustomSelect
|
||
value={rule.field}
|
||
onChange={val => handleRuleChange(idx, 'field', val)}
|
||
className="w-32"
|
||
options={[
|
||
{ value: 'all', label: '任意字段' },
|
||
{ value: 'title', label: '标题' },
|
||
{ value: 'author', label: '作者' },
|
||
{ value: 'abs', label: '摘要' },
|
||
{ value: 'year', label: '年份' },
|
||
]}
|
||
/>
|
||
|
||
<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="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium"
|
||
/>
|
||
|
||
{rules.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveRule(idx)}
|
||
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5"
|
||
>
|
||
删除
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
|
||
<span>单次拉取最大上限</span>
|
||
<span className="text-[11px] text-slate-450 font-normal">防止请求超出频率被封禁 API</span>
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={limit}
|
||
disabled={status.active}
|
||
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
|
||
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-end gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={status.active || estimating}
|
||
onClick={handleEstimate}
|
||
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
|
||
>
|
||
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||
估计目录总量
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={status.active || !query.trim()}
|
||
onClick={handleStartHarvest}
|
||
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40"
|
||
>
|
||
<Play className="w-3.5 h-3.5" />
|
||
启动元数据同步
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 预估结果 */}
|
||
{estimatedCount !== null && !status.active && (
|
||
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center">
|
||
<Info className="w-4 h-4 shrink-0 text-sky-600" />
|
||
<div>
|
||
检索库中估计有约 <strong className="text-sky-900 font-bold">{estimatedCount}</strong> 篇文献记录。
|
||
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 实时同步进度 */}
|
||
{(status.active || status.synced > 0) && (
|
||
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<div>
|
||
<h3 className="text-xs font-bold text-slate-800 flex items-center gap-2">
|
||
{status.active ? (
|
||
<>
|
||
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
|
||
<span>正在同步学术元数据...</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||
<span>元数据目录同步完成</span>
|
||
</>
|
||
)}
|
||
</h3>
|
||
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold">
|
||
检索条件: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700">{status.query}</code> • 平台: {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
|
||
</p>
|
||
</div>
|
||
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} 篇</span>
|
||
</div>
|
||
|
||
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
|
||
<div
|
||
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
|
||
style={{ width: `${percent}%` }}
|
||
/>
|
||
</div>
|
||
|
||
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
|
||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5">
|
||
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
|
||
<span>同步 arXiv 文献时系统会自动加设 3000 毫秒的安全限流延迟以规避服务器封锁。</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
{/* 批量任务 */}
|
||
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden">
|
||
<div className="flex flex-col gap-1">
|
||
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||
<Download className="w-4 h-4 text-sky-600" />
|
||
<span>馆藏文献批量学术流水线任务</span>
|
||
</h3>
|
||
<p className="text-slate-500 text-xs">
|
||
针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown 结构化排版解析以及中英双语对照翻译。
|
||
</p>
|
||
</div>
|
||
|
||
{batchError && (
|
||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
|
||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||
<div>{batchError}</div>
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
<CustomSelect
|
||
value={targetPhase}
|
||
disabled={batchStatus.active}
|
||
onChange={val => setTargetPhase(val as any)}
|
||
className="w-full"
|
||
options={[
|
||
{ value: 'download', label: '下载' },
|
||
{ value: 'parse', label: '解析' },
|
||
{ value: 'translate', label: '翻译' },
|
||
{ value: 'embed', label: '向量化' },
|
||
{ value: 'target', label: '天体识别' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-slate-700 block">批量处理上限</label>
|
||
<input
|
||
type="number"
|
||
value={batchLimitCount}
|
||
disabled={batchStatus.active}
|
||
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
|
||
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-slate-700 block">处理顺序</label>
|
||
<CustomSelect
|
||
value={sortOrder}
|
||
disabled={batchStatus.active}
|
||
onChange={val => setSortOrder(val)}
|
||
className="w-full"
|
||
options={[
|
||
{ value: 'default', label: '默认(不指定)' },
|
||
{ value: 'pub_year_desc', label: '按出版年份降序' },
|
||
{ value: 'created_at_desc', label: '按入库时间降序' },
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2 border-t border-slate-100 pt-4">
|
||
<label className="text-xs font-bold text-slate-700 block">执行策略</label>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={skipCompleted}
|
||
disabled={batchStatus.active}
|
||
onChange={e => setSkipCompleted(e.target.checked)}
|
||
className="rounded text-sky-650 focus:ring-sky-500"
|
||
/>
|
||
<span>跳过已完成</span>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={skipFailed}
|
||
disabled={batchStatus.active}
|
||
onChange={e => setSkipFailed(e.target.checked)}
|
||
className="rounded text-sky-650 focus:ring-sky-500"
|
||
/>
|
||
<span>跳过当前失败</span>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={skipPrecedingFailed}
|
||
disabled={batchStatus.active}
|
||
onChange={e => setSkipPrecedingFailed(e.target.checked)}
|
||
className="rounded text-sky-650 focus:ring-sky-500"
|
||
/>
|
||
<span>跳过前置失败</span>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={skipPrecedingUncompleted}
|
||
disabled={batchStatus.active}
|
||
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
|
||
className="rounded text-sky-650 focus:ring-sky-500"
|
||
/>
|
||
<span>跳过前置未完成</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-2 border-t border-slate-100">
|
||
<div className="w-full md:w-1/3 flex">
|
||
{batchStatus.active ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleStopBatch}
|
||
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
|
||
>
|
||
<StopCircle className="w-3.5 h-3.5" />
|
||
停止批量任务
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={handleStartBatch}
|
||
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2"
|
||
>
|
||
<Play className="w-3.5 h-3.5" />
|
||
启动批量任务
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度与终端日志展示 */}
|
||
{(batchStatus.active || batchStatus.total > 0) && (
|
||
<div className="space-y-4 pt-4 border-t border-slate-200">
|
||
<div className="space-y-1.5">
|
||
<div className="flex justify-between text-xs font-bold text-slate-600">
|
||
<span className="flex items-center gap-1">
|
||
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
|
||
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
|
||
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
|
||
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
|
||
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />}
|
||
{batchStatus.action === 'download'
|
||
? '正文离线下载进度'
|
||
: batchStatus.action === 'parse'
|
||
? '结构化排版解析进度'
|
||
: batchStatus.action === 'translate'
|
||
? '中英双语对照翻译进度'
|
||
: batchStatus.action === 'embed'
|
||
? '段落向量分块入库进度'
|
||
: '天体识别与缓存进度'}
|
||
</span>
|
||
<span>
|
||
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total} 篇
|
||
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
|
||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
|
||
<span className="text-red-500 ml-2 font-bold">
|
||
(失败 {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} 篇)
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
|
||
<div
|
||
className={`h-full transition-all duration-300 ${
|
||
batchStatus.action === 'download'
|
||
? 'bg-sky-600'
|
||
: batchStatus.action === 'parse'
|
||
? 'bg-emerald-600'
|
||
: batchStatus.action === 'translate'
|
||
? 'bg-indigo-600'
|
||
: batchStatus.action === 'embed'
|
||
? 'bg-amber-600'
|
||
: 'bg-sky-600'
|
||
}`}
|
||
style={{
|
||
width: `${
|
||
batchStatus.total > 0
|
||
? Math.min(
|
||
100,
|
||
Math.round(
|
||
((batchStatus.action === 'download'
|
||
? batchStatus.downloaded + batchStatus.download_failed
|
||
: batchStatus.parsed + batchStatus.parse_failed) /
|
||
batchStatus.total) *
|
||
100
|
||
)
|
||
)
|
||
: 0
|
||
}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{batchStatus.active && batchStatus.current_bibcode && (
|
||
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
|
||
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
|
||
<span>当前正在处理: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200">{batchStatus.current_bibcode}</code></span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 滚动日志终端 */}
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-slate-700 block">实时处理日志流终端</label>
|
||
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative">
|
||
{batchStatus.logs.length === 0 ? (
|
||
<div className="text-slate-400 italic">等待数据流任务启动,暂无日志输出...</div>
|
||
) : (
|
||
batchStatus.logs.map((log, idx) => (
|
||
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
|
||
{log}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 已存同步检索配置 */}
|
||
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
|
||
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
|
||
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||
<SlidersHorizontal className="w-4 h-4 text-sky-600 animate-pulse" />
|
||
<span>常用批量同步检索配置</span>
|
||
</h3>
|
||
<p className="text-slate-500 text-xs">
|
||
保存的历史批量元数据同步检索规则。可在此快速一键再次启动同步或加载检索参数。
|
||
</p>
|
||
</div>
|
||
|
||
{syncQueries.length === 0 ? (
|
||
<div className="text-center py-6 text-xs text-slate-400 italic">
|
||
暂无已存检索配置。执行一次批量元数据同步后将自动去重记录在此。
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
|
||
{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">
|
||
{sq.query}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
|
||
<span>数据源: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
|
||
<span>数量限制: <strong className="text-slate-700">{sq.limit_count}</strong></span>
|
||
<span>最近同步: <strong className="text-slate-700">{sq.last_run}</strong></span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 shrink-0 self-end sm:self-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleReuseQuery(sq)}
|
||
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
|
||
>
|
||
加载
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={status.active}
|
||
onClick={() => handleQuickSync(sq)}
|
||
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
|
||
>
|
||
一键同步
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteQuery(sq.id)}
|
||
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-750 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 浏览器快捷直推书签工具 */}
|
||
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
|
||
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
|
||
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
|
||
<span>浏览器快捷直推书签导入工具</span>
|
||
</h3>
|
||
<p className="text-slate-500 text-xs mt-1">
|
||
无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF 人机验证或在学校内网成功打开 PDF/HTML 页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch 数据库与文献馆中。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-3.5 text-xs text-slate-700">
|
||
<div className="space-y-1.5 font-bold">
|
||
<span className="text-slate-500">第一步:安装书签到您的浏览器</span>
|
||
<p className="text-[11px] text-slate-400 font-normal mt-1">
|
||
将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其 URL 设为复制的 JavaScript 代码(点击右侧按钮复制):
|
||
</p>
|
||
|
||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||
{/* 可直接拖拽的链接按钮 */}
|
||
<a
|
||
ref={(el) => {
|
||
if (el) {
|
||
el.setAttribute(
|
||
'href',
|
||
`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);`
|
||
);
|
||
}
|
||
}}
|
||
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-600 hover:to-indigo-600 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
|
||
title="拖拽我到您的书签栏中"
|
||
onClick={(e) => e.preventDefault()}
|
||
>
|
||
{/* 浏览器拖拽时读取全部 textContent,sr-only span 的文字成为书签名 */}
|
||
<span style={{position:'absolute',width:'1px',height:'1px',overflow:'hidden',opacity:0,pointerEvents:'none'}}>导入AstroResearch</span>
|
||
<span aria-hidden="true">添加导入书签</span>
|
||
</a>
|
||
|
||
<button
|
||
onClick={() => {
|
||
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('书签代码已成功复制到剪贴板!');
|
||
}}
|
||
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
|
||
>
|
||
复制 JavaScript 书签代码
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5 font-bold mt-4">
|
||
<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">
|
||
<li>点击详情弹窗内的 <span className="font-bold">BIBCODE / DOI / arXiv ID 链接</span>;</li>
|
||
<li>页面跳转至文献原始网页后,直接<span className="font-bold">点击该浏览器书签</span>;</li>
|
||
<li>书签脚本会自动读取并<span className="font-bold">自动填充好 Bibcode</span>,您只需点击确认,文件即可秒级自动上传录入至系统!</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|