diff --git a/.gitignore b/.gitignore index df01c9c..4acfcbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,9 @@ # Rust build artifacts -/target/ +target/ # Local configuration .env -# SQLite local database -/astro_research.db -/astro_research.db-journal -/astro_research.db-shm -/astro_research.db-wal # Local literature storage (contains downloaded PDFs, HTML, Markdowns and Translations) /library/ @@ -20,3 +15,7 @@ .DS_Store *.suo *.swp +library/MinerU/ + +libs/ +.claude diff --git a/Cargo.lock b/Cargo.lock index 452fd13..60309bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -85,11 +135,13 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "clap", "dotenvy", "flate2", "futures-util", "hmac 0.12.1", "html2md", + "libsqlite3-sys", "obscura-browser", "obscura-net", "quick-xml", @@ -99,6 +151,7 @@ dependencies = [ "serde", "serde_json", "sha1 0.10.6", + "sqlite-vec", "sqlx", "thiserror 1.0.69", "tokio", @@ -527,6 +580,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -542,6 +635,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1768,6 +1867,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2289,6 +2394,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-macros" version = "0.1.1" @@ -3256,6 +3367,15 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba424237a9a5db2f6071f193319e2b6a32f7f3961debb2fbbfe67067abce3f" +dependencies = [ + "cc", +] + [[package]] name = "sqlx" version = "0.7.4" @@ -3511,6 +3631,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -4122,6 +4248,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.2" diff --git a/Cargo.toml b/Cargo.toml index 01f12bd..0fd6ea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,9 @@ path = "src/main.rs" name = "health_check" path = "src/bin/health_check.rs" - - +[[bin]] +name = "astroresearch_cli" +path = "src/bin/cli.rs" [dependencies] tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["macros", "multipart"] } @@ -47,6 +48,9 @@ uuid = { version = "1.23.2", features = ["v4"] } tracing-appender = "0.2.5" obscura-browser = { path = "libs/obscura/crates/obscura-browser", optional = true } obscura-net = { path = "libs/obscura/crates/obscura-net", optional = true } +libsqlite3-sys = { version = "0.27.0", features = ["bundled"] } +sqlite-vec = "0.1.9" +clap = { version = "4", features = ["derive"] } [features] default = [] diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 35123d4..be34dc4 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -17,6 +17,8 @@ "react-dom": "^19.2.6", "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.6.0" @@ -2449,6 +2451,44 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -2475,6 +2515,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", @@ -2542,6 +2600,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4273,6 +4340,33 @@ "katex": "cli.js" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 276dc41..afbfdb5 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -19,6 +19,8 @@ "react-dom": "^19.2.6", "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.6.0" diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 0c0a2e9..20ba31e 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -51,6 +51,34 @@ export default function App() { const [library, setLibrary] = useState([]); const [selectedPaper, setSelectedPaper] = useState(null); const [detailBibcode, setDetailBibcode] = useState(null); + + const [recentlySelected, setRecentlySelected] = useState(() => { + 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 []; + } + }); + + 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) => { + setSelectedPaper(paper); + setActiveTab('citation'); + loadCitations(paper.bibcode, true); + addPaperToRecent(paper); + }; // 检索页状态 const [searchQuery, setSearchQuery] = useState(''); @@ -73,6 +101,7 @@ export default function App() { const [chineseText, setChineseText] = useState(''); const [parsing, setParsing] = useState(false); const [translating, setTranslating] = useState(false); + const [vectorizing, setVectorizing] = useState(false); // 引用星系数据状态 const [citationNetwork, setCitationNetwork] = useState(null); @@ -101,6 +130,15 @@ export default function App() { try { const res = await axios.get('/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); } @@ -298,6 +336,25 @@ export default function App() { } }; + // 5.5. 对文献进行向量化分块入库 (独立任务) + const handleVectorize = async (bibcode: string) => { + setVectorizing(true); + try { + const res = await axios.post<{ chunk_count: number }>('/api/embed', { bibcode }); + + setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_vector: true } : p)); + if (selectedPaper?.bibcode === bibcode) { + setSelectedPaper(prev => prev ? { ...prev, has_vector: true } : null); + } + showAlert(`文献向量化分块入库成功,共切片并录入 ${res.data.chunk_count} 个向量块。`, '向量化成功'); + } catch (e) { + console.error('文献向量化失败', e); + showAlert('向量化失败,请检查 .env 中的 Embedding API 配置。', '向量化失败'); + } finally { + setVectorizing(false); + } + }; + // 6. 加载文献引用关系网络 const loadCitations = useCallback(async (bibcode: string, reset = false) => { setLoadingCitations(true); @@ -323,13 +380,18 @@ export default function App() { }, []); // 7. 进入阅读器 - const openReader = async (paper: StandardPaper) => { + const openReader = async (paper: StandardPaper, skipTabSwitch = false) => { setSelectedPaper(paper); + localStorage.setItem('last_read_bibcode', paper.bibcode); + addPaperToRecent(paper); + setEnglishText(''); setChineseText(''); setNotes([]); setShowNotesPanel(false); - setActiveTab('reader'); + if (!skipTabSwitch) { + setActiveTab('reader'); + } // 获取详情 (包含已有原文及翻译) try { @@ -355,6 +417,56 @@ export default function App() { } }; + // 7.5. 从 RAG 问答结果跳转至目标文献段落并高亮 + const handleJumpToSource = async (bibcode: string, paragraphIndex: number) => { + setActiveTab('reader'); + + // 如果当前选中的不是目标文献,则先加载它 + if (!selectedPaper || selectedPaper.bibcode !== bibcode) { + setEnglishText(''); + setChineseText(''); + setNotes([]); + setShowNotesPanel(false); + + try { + // 获取 StandardPaper 基础信息与正文 + const paperRes = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', { + params: { bibcode } + }); + + setSelectedPaper(paperRes.data.paper); + if (paperRes.data.english_content) { + setEnglishText(paperRes.data.english_content); + } + if (paperRes.data.translation_content) { + setChineseText(paperRes.data.translation_content); + } + + // 加载该文献的所有笔记 + const nRes = await axios.get('/api/notes', { params: { bibcode } }); + setNotes(nRes.data); + } catch (e) { + console.error('加载跳转文献失败:', e); + return; + } + } + + // 强制开启侧边栏 + setShowNotesPanel(true); + + // 延迟等 DOM 渲染完成进行滚动高亮 + setTimeout(() => { + const element = document.getElementById(`paragraph-block-${paragraphIndex}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.classList.add('bg-amber-100', 'transition-all'); + setTimeout(() => { + element.classList.remove('bg-amber-100'); + }, 2500); + } + }, 400); + }; + // 8. 批量导出 BibTeX const handleExportBibtex = async () => { if (exportingList.length === 0) return; @@ -472,6 +584,8 @@ export default function App() { fetchLibrary={fetchLibrary} setActiveTab={setActiveTab} onShowDetail={(paper) => setDetailBibcode(paper.bibcode)} + onOpenReader={openReader} + onOpenCitation={openCitation} /> )} @@ -479,10 +593,15 @@ export default function App() { selectedPaper ? ( ) : (
@@ -524,6 +644,9 @@ export default function App() { selectedPaper ? ( void; loadingCitations: boolean; citationNetwork: CitationNetwork | null; citationHistory: CitationNetwork[]; @@ -15,12 +18,16 @@ interface CitationPanelProps { export function CitationPanel({ selectedPaper, + library, + recentlySelected, + onSwitchPaper, loadingCitations, citationNetwork, citationHistory, loadCitations, onUncachedClick, }: CitationPanelProps) { + const [showSwitchMenu, setShowSwitchMenu] = useState(false); const [nodeLimit, setNodeLimit] = useState(50); // 统一节点点击事件:已入库直接拉取,未入库弹窗选择 @@ -41,7 +48,85 @@ export function CitationPanel({

引用星系拓扑图谱

通过图谱层级快速 visual 展现当前文献的“参考文献 - 被引文献”关联脉络

-
+
+ {selectedPaper && ( +
+ + + {showSwitchMenu && ( + <> +
setShowSwitchMenu(false)} /> +
+ {/* 最近文献 */} + {recentlySelected.length > 0 && ( +
+
+ + 最近选择 +
+ {recentlySelected.map(paper => ( + + ))} +
+ )} + + {/* 全部已下载文献 */} +
+
+ + 全部已下载馆藏 +
+ {library.filter(p => p.is_downloaded).length === 0 ? ( +
暂无已下载文献
+ ) : ( + library + .filter(p => p.is_downloaded) + .map(paper => ( + + )) + )} +
+
+ + )} +
+ )}
展示节点上限: Promise; setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void; onShowDetail: (paper: StandardPaper) => void; + onOpenReader: (paper: StandardPaper) => void; + onOpenCitation: (paper: StandardPaper) => void; } export function LibraryPanel({ @@ -17,9 +19,11 @@ export function LibraryPanel({ fetchLibrary, setActiveTab, onShowDetail, + onOpenReader, + onOpenCitation, }: LibraryPanelProps) { const [searchTerm, setSearchTerm] = useState(''); - const [filterStatus, setFilterStatus] = useState<'all' | 'downloaded' | 'undownloaded' | 'download_failed' | 'no_resource' | 'parsed' | 'translated'>('all'); + const [filterStatus, setFilterStatus] = useState<'all' | 'downloaded' | 'undownloaded' | 'download_failed' | 'no_resource' | 'parsed' | 'translated' | 'vectorized'>('all'); const [filterDoctype, setFilterDoctype] = useState('all'); const [sortBy, setSortBy] = useState<'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title'>('created'); @@ -41,6 +45,7 @@ export function LibraryPanel({ const countNoResource = library.filter(p => !p.is_downloaded && p.pdf_error === 'no_resource' && p.html_error === 'no_resource').length; const countParsed = library.filter(p => p.has_markdown).length; const countTranslated = library.filter(p => p.has_translation).length; + const countVectorized = library.filter(p => p.has_vector).length; // 本地检索与筛选过滤 const filteredLibrary = library.filter(paper => { @@ -72,6 +77,7 @@ export function LibraryPanel({ } if (filterStatus === 'parsed' && !paper.has_markdown) return false; if (filterStatus === 'translated' && !paper.has_translation) return false; + if (filterStatus === 'vectorized' && !paper.has_vector) return false; // 3. 文献类型筛选 if (filterDoctype !== 'all') { @@ -122,6 +128,7 @@ export function LibraryPanel({ }); const getStatusScore = (paper: StandardPaper) => { + if (paper.has_vector) return 5; if (paper.has_translation) return 4; if (paper.has_markdown) return 3; if (paper.is_downloaded) return 2; @@ -243,6 +250,7 @@ export function LibraryPanel({ { value: 'downloaded', label: `已下载 (${countDownloaded})` }, { value: 'parsed', label: `已解析 (${countParsed})` }, { value: 'translated', label: `已翻译 (${countTranslated})` }, + { value: 'vectorized', label: `已向量化 (${countVectorized})` }, { value: 'undownloaded', label: `未下载 (${countUndownloaded})` }, { value: 'download_failed', label: `下载失败 (${countDownloadFailed})` }, { value: 'no_resource', label: `无资源 (${countNoResource})` }, @@ -406,30 +414,34 @@ export function LibraryPanel({ : undefined } className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${ - paper.has_translation - ? 'bg-emerald-50 text-emerald-700 border-emerald-200' - : paper.has_markdown - ? 'bg-sky-50 text-sky-700 border-sky-200' - : paper.is_downloaded - ? 'bg-indigo-50 text-indigo-700 border-indigo-200' - : (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') - ? 'bg-slate-100 text-slate-600 border-slate-200 cursor-help' - : (paper.pdf_error || paper.html_error) - ? 'bg-rose-50 text-rose-700 border-rose-200 cursor-help' - : 'bg-amber-50 text-amber-700 border-amber-200' + paper.has_vector + ? 'bg-amber-50 text-amber-700 border-amber-200' + : paper.has_translation + ? 'bg-emerald-50 text-emerald-700 border-emerald-200' + : paper.has_markdown + ? 'bg-sky-50 text-sky-700 border-sky-200' + : paper.is_downloaded + ? 'bg-indigo-50 text-indigo-700 border-indigo-200' + : (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') + ? 'bg-slate-100 text-slate-600 border-slate-200 cursor-help' + : (paper.pdf_error || paper.html_error) + ? 'bg-rose-50 text-rose-700 border-rose-200 cursor-help' + : 'bg-slate-50 text-slate-500 border-slate-100' }`} > - {paper.has_translation - ? '已翻译' - : paper.has_markdown - ? '已解析' - : paper.is_downloaded - ? '已下载' - : (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') - ? '无资源' - : (paper.pdf_error || paper.html_error) - ? '下载失败' - : '未下载'} + {paper.has_vector + ? '已向量化' + : paper.has_translation + ? '已翻译' + : paper.has_markdown + ? '已解析' + : paper.is_downloaded + ? '已下载' + : (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') + ? '无资源' + : (paper.pdf_error || paper.html_error) + ? '下载失败' + : '未下载'}
@@ -445,10 +457,35 @@ export function LibraryPanel({
- e.stopPropagation()}> - {paper.bibcode === paper.arxiv_id ? `arXiv:${paper.arxiv_id}` : paper.bibcode} - -
+
+ {paper.is_downloaded && ( +
+ + +
+ )} +
+
{paper.has_translation ? '翻译' : '未翻译'}
+
+ + {paper.has_vector ? '向量库' : '未向量化'} +
diff --git a/dashboard/src/features/reader/AIAssistantPanel.tsx b/dashboard/src/features/reader/AIAssistantPanel.tsx new file mode 100644 index 0000000..676b549 --- /dev/null +++ b/dashboard/src/features/reader/AIAssistantPanel.tsx @@ -0,0 +1,302 @@ +// dashboard/src/features/reader/AIAssistantPanel.tsx +import { useState, useRef, useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkMath from 'remark-math'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeKatex from 'rehype-katex'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; +import 'katex/dist/katex.min.css'; +import { Send, Loader, Sparkles, X, BookOpen, AlertCircle, Compass } from 'lucide-react'; +import axios from 'axios'; + +interface RetrievalSource { + bibcode: string; + paragraph_index: number; + content: string; + distance: number; +} + +interface Message { + sender: 'user' | 'ai'; + text: string; + sources?: RetrievalSource[]; + imageUrl?: string; +} + +interface AIAssistantPanelProps { + bibcode: string; + onClose: () => void; + onJumpToSource: (bibcode: string, paragraphIndex: number) => void; + pendingFigure?: { path: string; url: string } | null; + onClearPendingFigure?: () => void; +} + +const safeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + '*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display']), + }, + tagNames: (defaultSchema.tagNames || []).concat([ + 'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation' + ]), +}; + +const SUGGESTED_QUESTIONS = [ + "对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。", + "有哪些文献提及了脉动白矮星的非径向振动模?", + "简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。", + "文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?" +]; + +export function AIAssistantPanel({ + bibcode, + onClose, + onJumpToSource, + pendingFigure, + onClearPendingFigure +}: AIAssistantPanelProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const chatEndRef = useRef(null); + + const scrollToBottom = () => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages, loading]); + + const handleSend = async (questionText: string) => { + if (!questionText.trim() || loading) return; + + setError(null); + const userMsg: Message = { + sender: 'user', + text: questionText, + imageUrl: pendingFigure?.url + }; + setMessages(prev => [...prev, userMsg]); + setInput(''); + setLoading(true); + + const isFigureQuery = !!pendingFigure; + const currentFigurePath = pendingFigure?.path; + + if (onClearPendingFigure) { + onClearPendingFigure(); + } + + try { + if (isFigureQuery && currentFigurePath) { + const res = await axios.post<{ answer: string }>('/api/chat/figure', { + bibcode, + image_path: currentFigurePath, + question: questionText + }); + const aiMsg: Message = { + sender: 'ai', + text: res.data.answer + }; + setMessages(prev => [...prev, aiMsg]); + } else { + const res = await axios.post<{ answer: string; sources: RetrievalSource[] }>('/api/chat/rag', { + question: questionText, + top_k: 5 + }); + + const aiMsg: Message = { + sender: 'ai', + text: res.data.answer, + sources: res.data.sources + }; + setMessages(prev => [...prev, aiMsg]); + } + } catch (err: any) { + console.error('问答请求失败:', err); + setError(err.response?.data?.error || err.message || '网络请求错误,请稍后重试'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* 头部面板 */} +
+
+ + Astro RAG 学术助手 +
+ +
+ + {/* 对话消息区 */} +
+ {messages.length === 0 ? ( +
+
+ +

探索馆藏文献知识库

+

+ 基于向量检索(SQLite-Vec)与学术大语言模型,跨越所有已解析的 Markdown 文献为您提供深度知识整合与溯源解答。 +

+
+ + {/* 推荐提示词 */} +
+ 推荐学术提问: +
+ {SUGGESTED_QUESTIONS.map((q, idx) => ( + + ))} +
+
+
+ ) : ( + messages.map((msg, index) => ( +
+ + {msg.sender === 'user' ? '我' : 'Astro AI'} + +
+ {msg.sender === 'user' ? ( +
+ {msg.imageUrl && ( + Attached Figure + )} +

{msg.text}

+
+ ) : ( +
+ + {msg.text} + +
+ )} +
+ + {/* 关联来源展示 */} + {msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && ( +
+ 参考来源文献(点击跳转): +
+ {msg.sources.map((src, sIdx) => ( + + ))} +
+
+ )} +
+ )) + )} + + {loading && ( +
+ + 文献检索及综述生成中... +
+ )} + + {error && ( +
+ +
+
查询发生错误
+
{error}
+
+
+ )} + +
+
+ + {/* 待提问图片预览 */} + {pendingFigure && ( +
+
+ Preview +
+ 已选中图表插图 + {pendingFigure.path.split('/').pop()} +
+
+ +
+ )} + + {/* 底栏输入区 */} +
{ + e.preventDefault(); + handleSend(input); + }} + className="p-3 border-t border-slate-200 bg-white shrink-0" + > +
+ setInput(e.target.value)} + disabled={loading} + placeholder={pendingFigure ? "针对选中图表提问..." : "向 AI 馆藏助手提问..."} + className="flex-1 bg-slate-50 border border-slate-200 rounded-xl text-xs text-slate-900 placeholder-slate-400 pl-3 pr-10 py-2.5 focus:outline-none focus:bg-white focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed font-semibold transition-all disabled:opacity-60" + /> + +
+
+
+ ); +} diff --git a/dashboard/src/features/reader/ReaderPanel.tsx b/dashboard/src/features/reader/ReaderPanel.tsx index 292cd97..1842df4 100644 --- a/dashboard/src/features/reader/ReaderPanel.tsx +++ b/dashboard/src/features/reader/ReaderPanel.tsx @@ -1,19 +1,39 @@ // dashboard/src/features/reader/ReaderPanel.tsx -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; import rehypeKatex from 'rehype-katex'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import 'katex/dist/katex.min.css'; -import { FileText, Loader, Languages, RotateCw, Pencil, X, PlusCircle, Trash2, BookOpen } from 'lucide-react'; +import { FileText, Loader, Languages, RotateCw, Pencil, X, PlusCircle, Trash2, BookOpen, Sparkles, ChevronDown, History } from 'lucide-react'; import type { StandardPaper, NoteRecord } from '../../types'; +import { AIAssistantPanel } from './AIAssistantPanel'; +import axios from 'axios'; + +interface TargetInfo { + target_name: string; + ra: string | null; + dec: string | null; + parallax: number | null; + spectral_type: string | null; + v_magnitude: number | null; + aliases: string[]; +} interface ReaderPanelProps { selectedPaper: StandardPaper; + library: StandardPaper[]; + recentlySelected: StandardPaper[]; + onSwitchPaper: (paper: StandardPaper) => void; parsing: boolean; handleParse: (bibcode: string, force?: boolean) => void; translating: boolean; handleTranslate: (bibcode: string, force?: boolean) => void; + vectorizing: boolean; + handleVectorize: (bibcode: string) => void; showNotesPanel: boolean; setShowNotesPanel: (show: boolean) => void; notes: NoteRecord[]; @@ -31,8 +51,20 @@ interface ReaderPanelProps { handleCreateNote: () => void; handleDeleteNote: (id: number) => void; showConfirm: (message: string, onConfirm: () => void, title?: string) => void; + onJumpToSource: (bibcode: string, paragraphIndex: number) => void; } +const safeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + '*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display']), + }, + tagNames: (defaultSchema.tagNames || []).concat([ + 'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation' + ]), +}; + const NOTE_COLORS: Record = { cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' }, amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' }, @@ -42,10 +74,15 @@ const NOTE_COLORS: Record(() => { if (typeof window !== 'undefined' && window.innerWidth < 768) { return 'english'; @@ -77,7 +116,143 @@ export function ReaderPanel({ const [syncScroll, setSyncScroll] = useState(true); const scrollLock = useRef(false); + // 天体识别与 RAG 助手状态 + const [targets, setTargets] = useState([]); + const [loadingTargets, setLoadingTargets] = useState(false); + const [manualTargetName, setManualTargetName] = useState(''); + const [associatingTarget, setAssociatingTarget] = useState(false); + const [identifyingTargets, setIdentifyingTargets] = useState(false); + + const [sidebarTab, setSidebarTab] = useState<'notes' | 'ai'>('notes'); + const [hoveredTarget, setHoveredTarget] = useState(null); + const [hoverCardPos, setHoverCardPos] = useState<{ x: number; y: number } | null>(null); + + const hoverTimeout = useRef(null); + + // 预先计算并缓存天体高亮匹配项(首选名称 + 别名 + 空格变体) + const highlightCandidates = useMemo(() => { + return getHighlightCandidates(targets); + }, [targets]); + + // 获取天体关联列表 + useEffect(() => { + const fetchTargets = async () => { + setLoadingTargets(true); + try { + const res = await axios.get('/api/target/list', { + params: { bibcode: selectedPaper.bibcode } + }); + setTargets(res.data); + } catch (e) { + console.error('获取天体关联列表失败:', e); + } finally { + setLoadingTargets(false); + } + }; + if (selectedPaper?.bibcode) { + fetchTargets(); + setSidebarTab('notes'); + } + }, [selectedPaper?.bibcode]); + + // 自动从正文中提取天体并查询 CDS Sesame 缓存 (独立任务) + const handleIdentifyTargets = async (bibcode: string) => { + setIdentifyingTargets(true); + try { + const res = await axios.post<{ targets: TargetInfo[] }>('/api/target/extract', { bibcode }); + setTargets(res.data.targets); + } catch (e) { + console.error('天体识别失败:', e); + alert('从文献提取天体识别失败,请检查网络或后端连接。'); + } finally { + setIdentifyingTargets(false); + } + }; + + // 手动关联天体 + const handleAssociateTarget = async (e: React.FormEvent) => { + e.preventDefault(); + if (!manualTargetName.trim() || associatingTarget) return; + setAssociatingTarget(true); + try { + const res = await axios.post<{ status: string; target: TargetInfo }>('/api/target/associate', { + bibcode: selectedPaper.bibcode, + object_name: manualTargetName.trim() + }); + if (!targets.some(t => t.target_name.toLowerCase() === res.data.target.target_name.toLowerCase())) { + setTargets(prev => [...prev, res.data.target]); + } + setManualTargetName(''); + } catch (e) { + console.error('手动关联天体失败:', e); + alert('关联天体失败,请确认天体名称是否正确,或者 CDS 接口可访问。'); + } finally { + setAssociatingTarget(false); + } + }; + + // 悬浮显示气泡 + const handleMouseOver = (e: React.MouseEvent) => { + const element = e.target as HTMLElement; + const targetName = element.getAttribute('data-target-name'); + if (targetName) { + if (hoverTimeout.current) { + clearTimeout(hoverTimeout.current); + hoverTimeout.current = null; + } + const matchedTarget = targets.find(t => + t.target_name.toLowerCase() === targetName.toLowerCase() || + t.aliases.some(a => a.toLowerCase() === targetName.toLowerCase()) + ); + if (matchedTarget) { + if (!hoveredTarget || hoveredTarget.target_name !== matchedTarget.target_name) { + const rect = element.getBoundingClientRect(); + + // 使用 fixed 视口定位防止父级 overflow-y-auto 裁切 + let x = rect.left; + if (x + 288 > window.innerWidth) { + x = window.innerWidth - 288 - 16; + } + + let y = rect.bottom + 2; + if (y + 240 > window.innerHeight) { + y = Math.max(10, rect.top - 240 - 2); + } + + setHoverCardPos({ x, y }); + setHoveredTarget(matchedTarget); + } + } + } else { + if (hoveredTarget && !hoverTimeout.current) { + hoverTimeout.current = setTimeout(() => { + setHoveredTarget(null); + hoverTimeout.current = null; + }, 500); + } + } + }; + + const handleContainerMouseLeave = () => { + if (hoveredTarget && !hoverTimeout.current) { + hoverTimeout.current = setTimeout(() => { + setHoveredTarget(null); + hoverTimeout.current = null; + }, 500); + } + }; + + const handleMouseLeaveTarget = () => { + if (!hoverTimeout.current) { + hoverTimeout.current = setTimeout(() => { + setHoveredTarget(null); + hoverTimeout.current = null; + }, 500); + } + }; + const handleEnglishScroll = () => { + if (hoveredTarget) setHoveredTarget(null); if (!syncScroll) return; if (scrollLock.current) return; const eng = englishRef.current; @@ -93,6 +268,7 @@ export function ReaderPanel({ }; const handleChineseScroll = () => { + if (hoveredTarget) setHoveredTarget(null); if (!syncScroll) return; if (scrollLock.current) return; const eng = englishRef.current; @@ -107,19 +283,168 @@ export function ReaderPanel({ }); }; + // 点击侧边栏天体时,跳转并高亮正文中对应的天体 + const handleJumpToTarget = (target: TargetInfo) => { + if (!englishText) return; + + // 如果当前处于仅中文视图,自动切换为对照视图(移动端切换为英文视图) + if (viewMode === 'chinese') { + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + setViewMode(isMobile ? 'english' : 'bilingual'); + } + + // 生成所有可能匹配的名称变体(包含空格与无空格变体、别名) + const cleanNames = new Set(); + const addNameAndVariants = (name: string) => { + const trimmed = name.trim().toLowerCase(); + if (!trimmed) return; + cleanNames.add(trimmed); + + const spaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)\s+(\d+.*)$/i; + const match = trimmed.match(spaceRegex); + if (match) { + cleanNames.add(`${match[1]}${match[2]}`); + } + + const noSpaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)(\d+.*)$/i; + const matchNoSpace = trimmed.match(noSpaceRegex); + if (matchNoSpace) { + cleanNames.add(`${matchNoSpace[1]} ${matchNoSpace[2]}`); + } + }; + + addNameAndVariants(target.target_name); + if (target.aliases) { + target.aliases.forEach(addNameAndVariants); + } + + const paragraphs = englishText.split('\n\n'); + const foundIdx = paragraphs.findIndex(para => { + const paraLower = para.toLowerCase(); + return Array.from(cleanNames).some(name => paraLower.includes(name)); + }); + + if (foundIdx !== -1) { + setTimeout(() => { + const element = document.getElementById(`paragraph-block-${foundIdx}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // 闪烁高亮段落背景 + element.classList.add('bg-sky-50', 'ring-2', 'ring-sky-500/20', 'transition-all'); + + // 闪烁高亮段落内具体的天体标签 + const targetSpans = element.querySelectorAll(`[data-target-name]`); + targetSpans.forEach(span => { + const attrVal = span.getAttribute('data-target-name')?.toLowerCase(); + if (attrVal && Array.from(cleanNames).some(name => name === attrVal)) { + span.classList.add('bg-amber-200', 'scale-105', 'px-1', 'rounded', 'transition-all'); + setTimeout(() => { + span.classList.remove('bg-amber-200', 'scale-105', 'px-1', 'rounded'); + }, 3000); + } + }); + + setTimeout(() => { + element.classList.remove('bg-sky-50', 'ring-2', 'ring-sky-500/20'); + }, 3000); + } + }, 150); + } + }; + return (
{/* 控制头部 */}
-
-

{selectedPaper.title}

+
+

+ {selectedPaper.title} +

发表期刊: {selectedPaper.pub_journal || '未标注'} 文献编码: {selectedPaper.bibcode}
-
+
+
+ + + {showSwitchMenu && ( + <> + {/* 遮罩,用于点击外部关闭 */} +
setShowSwitchMenu(false)} /> +
+ {/* 最近阅读部分 */} + {recentlySelected.length > 0 && ( +
+
+ + 最近阅读 +
+ {recentlySelected.map(paper => ( + + ))} +
+ )} + + {/* 全部馆藏已下载文献部分 */} +
+
+ + 全部已下载馆藏 +
+ {library.filter(p => p.is_downloaded).length === 0 ? ( +
暂无已下载文献
+ ) : ( + library + .filter(p => p.is_downloaded) + .map(paper => ( + + )) + )} +
+
+ + )} +
{(englishText || chineseText) && (
)} + {selectedPaper.has_markdown && !selectedPaper.has_vector && ( + + )} + + {selectedPaper.has_markdown && selectedPaper.has_vector && ( + + )} + +
@@ -225,14 +595,16 @@ export function ReaderPanel({ className="flex-1 grid gap-6 overflow-hidden w-full" style={{ gridTemplateColumns: viewMode === 'bilingual' - ? (showNotesPanel ? '1fr 1fr 340px' : '1fr 1fr') - : (showNotesPanel ? '1fr 340px' : '1fr') + ? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr') + : (showNotesPanel ? '1fr 380px' : '1fr') }} > {(viewMode === 'english' || viewMode === 'bilingual') && (
)}
-
{showPdf ? ( @@ -283,10 +646,18 @@ export function ReaderPanel({

正在提取源文献正文排版,并转换至 Markdown 排版...

) : englishText ? ( -
+
+
{englishText.split('\n\n').map((para, idx) => (
handleTextSelection(idx)} className={`cursor-text relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 ${ notes.some(n => n.paragraph_index === idx) @@ -296,12 +667,13 @@ export function ReaderPanel({ > - {para} + {highlightTargetsInMarkdown(para, highlightCandidates)}
))} +
) : (
@@ -310,14 +682,14 @@ export function ReaderPanel({

请点击上方按钮提取文档结构正文

)} + +
)} {(viewMode === 'chinese' || viewMode === 'bilingual') && (
中文学术对比翻译 @@ -329,13 +701,19 @@ export function ReaderPanel({

天文学专属词典加载中,正在通过大模型进行术语修正翻译...

) : chineseText ? ( -
+
+
{chineseText} +
) : (
@@ -347,107 +725,383 @@ export function ReaderPanel({
)} - {/* 笔记侧边栏 */} + {/* 侧边栏 */} {showNotesPanel && ( -
-
- 观测记录手札 ({notes.length}) -
+ + {/* 标签栏选择器 */} +
+ + +
- {/* 新建笔记输入区 */} - {selectedParagraphIdx !== null && ( -
-
正在批注段落 #{selectedParagraphIdx + 1}
- {selectedText && ( -
- "{selectedText}" -
- )} - {/* 颜色标记 */} -
- 标记色: -
- {Object.entries(NOTE_COLORS).map(([color, style]) => ( -
-
-