后端核心变更: - API 层: 新增 AppError 枚举统一错误类型,替代散落的 (StatusCode, String) - Agent 工具域: 重组为 astro/system/ 和 astro/research/ 两级域,新增 ProcessPaperTool 流水线工具 - 安全: 新增 SSRF 双层防护 (同步字符串级 + 异步 DNS 解析级),覆盖 IPv4/IPv6 私网段 - 弱密码检测: 扩展弱密码列表并增加最小长度检查 - LLM 客户端: 新增 ChatCompleter/Embedder trait,支持依赖注入与批量向量化 embed_batch - 批量处理: AssetBatch 从串行改为 Semaphore 并发池 (BATCH_CONCURRENCY=3) - 分块器: 重写为三阶段结构化管线 (章节解析→短节合并→带标题路径子块) - RAG: embedding 计算移出事务,RetrievalResult 新增 headings/section_index 字段 - 检索: ADS/arXiv 并行检索 (tokio::join!),去重改用 HashSet,本地库回填批量 IN 查询 - 天体查询: Sesame API 升级到 v4,新增视差误差/自行/视向速度/多波段测光字段 - 迁移: 14 个增量文件合并为单一 init.sql,支持 sqlx::migrate! 内存库集成测试 - 测试: circuit_breaker/hooks/task_board/session/memory/streaming_executor 新增修正 15+ 测试 前端架构重构: - 目录重组: features/ → pages/ + components/ + hooks/ 三层分离 - App.tsx 从 1181 行压缩至 ~174 行 (逻辑抽入 9 个自定义 Hook) - Agent 面板拆分为 AgentSessionSidebar/AgentMessageList/AgentInputArea 子组件 - 新增 GlobalDialog/PaperDetailModal/UncachedPaperModal 通用对话框组件 - 工具函数抽取: celestial.ts (天体坐标格式), paper.tsx (文献信息渲染)
217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
// dashboard/src/hooks/useLibrary.ts
|
||
import { useState, useEffect } from 'react';
|
||
import axios from 'axios';
|
||
import type { StandardPaper } from '../types';
|
||
|
||
interface UseLibraryProps {
|
||
isAuthenticated: boolean | null;
|
||
showAlert: (message: string, title?: string) => void;
|
||
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
|
||
openReader: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
|
||
}
|
||
|
||
export function useLibrary({
|
||
isAuthenticated,
|
||
showAlert,
|
||
showConfirm,
|
||
openReader,
|
||
}: UseLibraryProps) {
|
||
const [library, setLibrary] = useState<StandardPaper[]>([]);
|
||
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null);
|
||
const [detailBibcode, setDetailBibcode] = useState<string | null>(null);
|
||
|
||
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(() => {
|
||
try {
|
||
const saved = localStorage.getItem('astro_recently_selected');
|
||
return saved ? JSON.parse(saved) : [];
|
||
} catch (e) {
|
||
console.error('Failed to parse recently selected papers', e);
|
||
return [];
|
||
}
|
||
});
|
||
|
||
const [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
|
||
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('astro_recently_selected', JSON.stringify(recentlySelected));
|
||
}, [recentlySelected]);
|
||
|
||
const addPaperToRecent = (paper: StandardPaper) => {
|
||
setRecentlySelected(prev => {
|
||
const filtered = prev.filter(p => p.bibcode !== paper.bibcode);
|
||
return [paper, ...filtered].slice(0, 5);
|
||
});
|
||
};
|
||
|
||
const openCitation = (
|
||
paper: StandardPaper,
|
||
setActiveTab: (tab: any) => void,
|
||
loadCitations: (bibcode: string, reset?: boolean) => void
|
||
) => {
|
||
setSelectedPaper(paper);
|
||
setActiveTab('citation');
|
||
loadCitations(paper.bibcode, true);
|
||
addPaperToRecent(paper);
|
||
};
|
||
|
||
const fetchLibrary = async () => {
|
||
try {
|
||
const res = await axios.get<StandardPaper[]>('/api/library');
|
||
setLibrary(res.data);
|
||
|
||
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
|
||
if (lastReadBibcode) {
|
||
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
|
||
if (lastReadPaper) {
|
||
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
|
||
openReader(lastReadPaper, initialTab !== 'reader');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('加载本地文献库失败', e);
|
||
}
|
||
};
|
||
|
||
// 初始化加载本地文献库
|
||
useEffect(() => {
|
||
if (isAuthenticated === true) {
|
||
fetchLibrary();
|
||
}
|
||
}, [isAuthenticated]);
|
||
|
||
// 触发文献双格式下载
|
||
const handleDownload = async (bibcode: string, force = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
|
||
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: true }));
|
||
try {
|
||
const res = await axios.post<StandardPaper>('/api/download', { bibcode, force });
|
||
|
||
if (searchResultsSetter) {
|
||
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
|
||
}
|
||
setLibrary(prev => {
|
||
if (prev.some(p => p.bibcode === bibcode)) {
|
||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
||
} else {
|
||
return [res.data, ...prev];
|
||
}
|
||
});
|
||
if (selectedPaper?.bibcode === bibcode) {
|
||
setSelectedPaper(res.data);
|
||
}
|
||
} catch (e) {
|
||
console.error('下载文献失败', e);
|
||
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
|
||
} finally {
|
||
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
|
||
}
|
||
};
|
||
|
||
// 手动上传文献文件以绕过反爬/人机验证
|
||
const handleManualUpload = async (bibcode: string, type: 'pdf' | 'html', file: File, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
|
||
setUploadingBibcode(bibcode);
|
||
const formData = new FormData();
|
||
formData.append('bibcode', bibcode);
|
||
formData.append('type', type);
|
||
formData.append('file', file);
|
||
|
||
try {
|
||
const res = await axios.post<StandardPaper>('/api/upload', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
});
|
||
|
||
if (searchResultsSetter) {
|
||
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
|
||
}
|
||
setLibrary(prev => {
|
||
if (prev.some(p => p.bibcode === bibcode)) {
|
||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
||
} else {
|
||
return [res.data, ...prev];
|
||
}
|
||
});
|
||
if (selectedPaper?.bibcode === bibcode) {
|
||
setSelectedPaper(res.data);
|
||
}
|
||
showAlert('手动文献文件上传导入成功!', '上传成功');
|
||
} catch (e: any) {
|
||
console.error('手动文件上传失败', e);
|
||
const errMsg = e.response?.data || '请确保上传的是合法且完整的文件。';
|
||
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
|
||
} finally {
|
||
setUploadingBibcode(null);
|
||
}
|
||
};
|
||
|
||
// 手动标记文献为“无有效全文资源”
|
||
const handleMarkNoResource = async (bibcode: string, clear = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
|
||
const performMark = async () => {
|
||
try {
|
||
const res = await axios.post<StandardPaper>('/api/no_resource', { bibcode, clear });
|
||
|
||
if (searchResultsSetter) {
|
||
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
|
||
}
|
||
setLibrary(prev => {
|
||
if (prev.some(p => p.bibcode === bibcode)) {
|
||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
||
} else {
|
||
return [res.data, ...prev];
|
||
}
|
||
});
|
||
if (selectedPaper?.bibcode === bibcode) {
|
||
setSelectedPaper(res.data);
|
||
}
|
||
showAlert(
|
||
clear
|
||
? '已成功清除“无全文资源”标记,该文献已被重新允许重载!'
|
||
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
|
||
'标记更新'
|
||
);
|
||
} catch (e: any) {
|
||
console.error('标记更新失败', e);
|
||
const errMsg = e.response?.data || '请稍后重试。';
|
||
showAlert(`标记失败: ${errMsg}`, '操作出错');
|
||
}
|
||
};
|
||
|
||
if (clear) {
|
||
performMark();
|
||
} else {
|
||
showConfirm(
|
||
'确认将此文献标记为“无有效全文资源”吗?\n标记后,未来的批量下载/解析重试任务将自动跳过此文献。',
|
||
performMark,
|
||
'标记无有效全文资源'
|
||
);
|
||
}
|
||
};
|
||
|
||
// 衍生状态计算
|
||
const getDetailPaper = (searchResults: StandardPaper[]) => {
|
||
return library.find(p => p.bibcode === detailBibcode) || searchResults.find(p => p.bibcode === detailBibcode);
|
||
};
|
||
|
||
return {
|
||
library,
|
||
setLibrary,
|
||
selectedPaper,
|
||
setSelectedPaper,
|
||
detailBibcode,
|
||
setDetailBibcode,
|
||
recentlySelected,
|
||
setRecentlySelected,
|
||
downloadingBibcodes,
|
||
setDownloadingBibcodes,
|
||
uploadingBibcode,
|
||
addPaperToRecent,
|
||
openCitation,
|
||
fetchLibrary,
|
||
handleDownload,
|
||
handleManualUpload,
|
||
handleMarkNoResource,
|
||
getDetailPaper,
|
||
};
|
||
}
|