feat: RAG 文献问答、天体目标识别、解析器模块化与批量管线扩展

核心新增:
  - RAG 问答系统:Markdown 安全切片器 (LaTeX 保护) + 向量化 + sqlite-vec 检索 + LLM 生成
  - 天体目标识别:IAU 标准正则提取 15+ 星表标识符,CDS SIMBAD/Sesame 查询与本地缓存
  - 多模态 LLM:chat_completion_with_image 支持图表视觉分析
  - CLI Skills Agent (cli.rs):对外暴露 rag/target/ingest 等 5 个子命令
  - 解析器模块化重构:单体 778 行 → 按期刊拆分 (A&A/ar5iv/IOP/Generic/PDF) +
    common.rs 静态正则工具库

  管线与 Schema:
  - AssetSync→AssetBatch 重命名,批量管线新增 embed/target 两个处理阶段
  - 新增 paper_chunks_content (RAG 切片) 和 paper_targets (天体缓存) 两张表
  - StandardPaper 新增 has_vector 字段,所有查询同步更新

  前端:
  - 新增 AI 助手侧边栏 (RAG 问答 + 来源跳转高亮)
  - 最近浏览文献列表 (localStorage 持久化)、跨面板无缝导航
  - SyncPanel 批量阶段扩展为下拉选项,支持向量化/天体识别

  测试与清理:
  - 集成测试合并至 ads.rs 和 llm.rs,新增 chunker + target 单元测试 15 个
  - 删除旧单体 parser.rs、独立测试文件及过期 scratch 脚本
This commit is contained in:
fmq
2026-06-15 13:25:18 +08:00
parent 3f1935678b
commit 22e7e1dcee
45 changed files with 5289 additions and 1411 deletions
+94
View File
@@ -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",
+2
View File
@@ -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"
+125 -2
View File
@@ -51,6 +51,34 @@ export default function App() {
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 [];
}
});
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<CitationNetwork | null>(null);
@@ -101,6 +130,15 @@ export default function App() {
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);
}
@@ -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<NoteRecord[]>('/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 ? (
<ReaderPanel
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={openReader}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notes={notes}
@@ -500,6 +619,7 @@ export default function App() {
handleCreateNote={handleCreateNote}
handleDeleteNote={handleDeleteNote}
showConfirm={showConfirm}
onJumpToSource={handleJumpToSource}
/>
) : (
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-white rounded-2xl border border-slate-200 shadow-xs min-h-[450px]">
@@ -524,6 +644,9 @@ export default function App() {
selectedPaper ? (
<CitationPanel
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={openCitation}
loadingCitations={loadingCitations}
citationNetwork={citationNetwork}
citationHistory={citationHistory}
@@ -1,11 +1,14 @@
// dashboard/src/features/citation/CitationPanel.tsx
import { useState } from 'react';
import { Loader, GitFork, RotateCcw } from 'lucide-react';
import { Loader, GitFork, RotateCcw, ChevronDown, History, FileText } from 'lucide-react';
import { CitationGalaxyCanvas } from '../../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../../types';
interface CitationPanelProps {
selectedPaper: StandardPaper | null;
library: StandardPaper[];
recentlySelected: StandardPaper[];
onSwitchPaper: (paper: StandardPaper) => 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({
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-xs text-slate-500 mt-1"> visual - </p>
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-4 items-center relative z-20">
{selectedPaper && (
<div className="relative shrink-0">
<button
type="button"
onClick={() => setShowSwitchMenu(!showSwitchMenu)}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0"
>
<GitFork className="w-3.5 h-3.5 text-sky-600" />
<span></span>
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
</button>
{showSwitchMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowSwitchMenu(false)} />
<div className="absolute right-0 mt-1.5 w-72 rounded-xl bg-white border border-slate-200 shadow-xl py-1.5 z-55 text-xs max-h-96 overflow-y-auto scrollbar-thin">
{/* 最近文献 */}
{recentlySelected.length > 0 && (
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
<History className="w-3.5 h-3.5 text-slate-400" />
<span></span>
</div>
{recentlySelected.map(paper => (
<button
key={`citation-recent-${paper.bibcode}`}
onClick={() => {
onSwitchPaper(paper);
setShowSwitchMenu(false);
}}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold'
: 'border-transparent text-slate-700 font-medium'
}`}
>
<span className="truncate block leading-tight text-left">{paper.title}</span>
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
</button>
))}
</div>
)}
{/* 全部已下载文献 */}
<div>
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
<FileText className="w-3.5 h-3.5 text-slate-400" />
<span></span>
</div>
{library.filter(p => p.is_downloaded).length === 0 ? (
<div className="px-3 py-2 text-slate-400 italic"></div>
) : (
library
.filter(p => p.is_downloaded)
.map(paper => (
<button
key={`citation-lib-${paper.bibcode}`}
onClick={() => {
onSwitchPaper(paper);
setShowSwitchMenu(false);
}}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold'
: 'border-transparent text-slate-700 font-medium'
}`}
>
<span className="truncate block leading-tight text-left">{paper.title}</span>
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
</button>
))
)}
</div>
</div>
</>
)}
</div>
)}
<div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-lg shadow-sm">
<span className="text-[11px] text-slate-500 font-bold">:</span>
<input
+71 -28
View File
@@ -1,6 +1,6 @@
// dashboard/src/features/library/LibraryPanel.tsx
import { useState, useCallback } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle } from 'lucide-react';
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../search/SearchPanel';
import { CustomSelect } from '../../components/CustomSelect';
@@ -10,6 +10,8 @@ interface LibraryPanelProps {
fetchLibrary: () => Promise<void>;
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<string>('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)
? '下载失败'
: '未下载'}
</div>
<div className="pr-10">
@@ -445,10 +457,35 @@ export function LibraryPanel({
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-3 mt-4 text-[10px]">
<span className="font-mono text-slate-400 select-all" onClick={(e) => e.stopPropagation()}>
{paper.bibcode === paper.arxiv_id ? `arXiv:${paper.arxiv_id}` : paper.bibcode}
</span>
<div className="flex items-center gap-2 shrink-0">
<div className="flex flex-col gap-1 flex-1 min-w-0">
{paper.is_downloaded && (
<div className="flex gap-1.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenReader(paper);
}}
className="px-2 py-0.5 rounded bg-sky-50 hover:bg-sky-100 text-sky-700 border border-sky-200 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in"
>
<BookOpen className="w-3 h-3" />
<span></span>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenCitation(paper);
}}
className="px-2 py-0.5 rounded bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in"
>
<GitFork className="w-3 h-3" />
<span></span>
</button>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 self-end">
<div className="flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full ${paper.has_markdown ? 'bg-sky-500' : 'bg-slate-200'}`}
@@ -461,6 +498,12 @@ export function LibraryPanel({
/>
<span className="text-slate-400 text-[9px]">{paper.has_translation ? '翻译' : '未翻译'}</span>
</div>
<div className="flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full ${paper.has_vector ? 'bg-amber-500' : 'bg-slate-200'}`}
/>
<span className="text-slate-400 text-[9px]">{paper.has_vector ? '向量库' : '未向量化'}</span>
</div>
</div>
</div>
</div>
@@ -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<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chatEndRef = useRef<HTMLDivElement>(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 (
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
{/* 头部面板 */}
<div className="px-4 py-3.5 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
<div className="flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-sky-600 animate-pulse" />
<span className="text-xs font-bold text-slate-800">Astro RAG </span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* 对话消息区 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
{messages.length === 0 ? (
<div className="py-6 space-y-6">
<div className="text-center space-y-2 max-w-sm mx-auto">
<Compass className="w-10 h-10 mx-auto text-sky-500 opacity-60" />
<h3 className="text-xs font-bold text-slate-800"></h3>
<p className="text-[11px] text-slate-500 leading-relaxed font-semibold">
SQLite-Vec Markdown
</p>
</div>
{/* 推荐提示词 */}
<div className="space-y-2">
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">:</span>
<div className="space-y-2">
{SUGGESTED_QUESTIONS.map((q, idx) => (
<button
key={idx}
onClick={() => handleSend(q)}
className="w-full text-left bg-white hover:bg-slate-100 border border-slate-200 rounded-lg p-3 text-xs text-slate-700 leading-relaxed font-medium transition-colors shadow-sm cursor-pointer"
>
{q}
</button>
))}
</div>
</div>
</div>
) : (
messages.map((msg, index) => (
<div key={index} className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1`}>
<span className="text-[10px] font-bold text-slate-400 px-1">
{msg.sender === 'user' ? '我' : 'Astro AI'}
</span>
<div
className={`max-w-[90%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${
msg.sender === 'user'
? 'bg-sky-600 text-white border-sky-600'
: 'bg-white text-slate-800 border-slate-200'
}`}
>
{msg.sender === 'user' ? (
<div className="space-y-2">
{msg.imageUrl && (
<img src={msg.imageUrl} alt="Attached Figure" className="max-w-40 max-h-40 object-contain rounded-md border border-sky-500/30" />
)}
<p className="whitespace-pre-wrap">{msg.text}</p>
</div>
) : (
<div className="prose prose-sm max-w-none text-slate-800 leading-relaxed prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
>
{msg.text}
</ReactMarkdown>
</div>
)}
</div>
{/* 关联来源展示 */}
{msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && (
<div className="w-full mt-2 pl-2 space-y-1.5 border-l-2 border-slate-200">
<span className="text-[9px] font-bold text-slate-400">:</span>
<div className="grid grid-cols-1 gap-1.5 w-[90%]">
{msg.sources.map((src, sIdx) => (
<button
key={sIdx}
onClick={() => onJumpToSource(src.bibcode, src.paragraph_index)}
className="flex items-center justify-between text-left bg-white hover:bg-slate-100 border border-slate-200 rounded-md px-2.5 py-1.5 text-[10px] text-slate-655 font-semibold transition-all shadow-sm cursor-pointer"
title={src.content}
>
<div className="flex items-center gap-1.5 truncate mr-2">
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
<span className="font-bold text-slate-700 truncate">{src.bibcode}</span>
<span className="text-slate-400 text-[9px] font-bold">§{src.paragraph_index + 1}</span>
</div>
<span className="text-slate-400 text-[9px] shrink-0 font-medium">
: {src.distance.toFixed(3)}
</span>
</button>
))}
</div>
</div>
)}
</div>
))
)}
{loading && (
<div className="flex items-center space-x-2 text-slate-500 pl-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-[10px] font-bold">...</span>
</div>
)}
{error && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs w-[90%] font-semibold">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div>
<div className="font-bold"></div>
<div className="text-[10px] opacity-80 mt-0.5">{error}</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* 待提问图片预览 */}
{pendingFigure && (
<div className="px-3 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<img
src={pendingFigure.url}
alt="Preview"
className="w-10 h-10 object-cover rounded-md border border-slate-300"
/>
<div className="text-[10px] text-slate-550">
<span className="font-bold text-slate-700 block"></span>
<span className="font-mono text-slate-400 truncate max-w-48 block">{pendingFigure.path.split('/').pop()}</span>
</div>
</div>
<button
type="button"
onClick={onClearPendingFigure}
className="text-slate-400 hover:text-slate-655 p-1 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* 底栏输入区 */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSend(input);
}}
className="p-3 border-t border-slate-200 bg-white shrink-0"
>
<div className="flex gap-2 relative items-center">
<input
type="text"
value={input}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={!input.trim() || loading}
className="absolute right-1.5 p-1.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-200 disabled:text-slate-400 transition-colors cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</form>
</div>
);
}
File diff suppressed because it is too large Load Diff
+91 -79
View File
@@ -5,7 +5,7 @@ import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle,
import type { SavedSyncQuery } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
interface ProcessStatus {
interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
@@ -14,7 +14,7 @@ interface ProcessStatus {
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate';
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
interface HarvestStatus {
@@ -43,7 +43,7 @@ export function SyncPanel() {
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate'>('download');
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);
@@ -51,7 +51,7 @@ export function SyncPanel() {
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [processStatus, setProcessStatus] = useState<ProcessStatus>({
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
@@ -61,8 +61,8 @@ export function SyncPanel() {
current_bibcode: '',
logs: [],
});
const [processError, setProcessError] = useState<string | null>(null);
const processPollIntervalRef = useRef<any>(null);
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
@@ -203,30 +203,30 @@ export function SyncPanel() {
}, []);
// 批量下载与解析相关的网络操作
const fetchProcessStatus = async () => {
const fetchBatchStatus = async () => {
try {
const res = await axios.get<ProcessStatus>(`/api/sync/asset/status?t=${Date.now()}`);
setProcessStatus(res.data);
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
setBatchStatus(res.data);
if (res.data.active) {
startProcessPolling();
} else if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
processPollIntervalRef.current = null;
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startProcessPolling = () => {
if (processPollIntervalRef.current) return;
processPollIntervalRef.current = setInterval(fetchProcessStatus, 1000);
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartProcess = async () => {
setProcessError(null);
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/sync/asset/run', {
await axios.post('/api/batch/asset/run', {
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
@@ -235,30 +235,30 @@ export function SyncPanel() {
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchProcessStatus();
startProcessPolling();
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '启动批量任务失败。');
setBatchError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopProcess = async () => {
const handleStopBatch = async () => {
try {
await axios.post('/api/sync/asset/stop');
fetchProcessStatus();
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '停止任务失败。');
setBatchError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchProcessStatus();
fetchBatchStatus();
return () => {
if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
@@ -273,7 +273,7 @@ export function SyncPanel() {
container.scrollTop = container.scrollHeight;
}
}
}, [processStatus.logs]);
}, [batchStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
@@ -579,35 +579,29 @@ export function SyncPanel() {
</p>
</div>
{processError && (
{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>{processError}</div>
<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>
<div className="flex flex-col gap-1.5">
{[
{ id: 'download', label: '下载' },
{ id: 'parse', label: '解析' },
{ id: 'translate', label: '翻译' },
].map(phase => (
<label key={phase.id} className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="radio"
name="targetPhase"
checked={targetPhase === phase.id}
disabled={processStatus.active}
onChange={() => setTargetPhase(phase.id as any)}
className="text-sky-650 focus:ring-sky-500"
/>
<span>{phase.label}</span>
</label>
))}
</div>
<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">
@@ -615,7 +609,7 @@ export function SyncPanel() {
<input
type="number"
value={batchLimitCount}
disabled={processStatus.active}
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"
/>
@@ -625,7 +619,7 @@ export function SyncPanel() {
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={sortOrder}
disabled={processStatus.active}
disabled={batchStatus.active}
onChange={val => setSortOrder(val)}
className="w-full"
options={[
@@ -644,7 +638,7 @@ export function SyncPanel() {
<input
type="checkbox"
checked={skipCompleted}
disabled={processStatus.active}
disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
@@ -655,7 +649,7 @@ export function SyncPanel() {
<input
type="checkbox"
checked={skipFailed}
disabled={processStatus.active}
disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
@@ -666,7 +660,7 @@ export function SyncPanel() {
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={processStatus.active}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
@@ -677,7 +671,7 @@ export function SyncPanel() {
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={processStatus.active}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
@@ -688,10 +682,10 @@ export function SyncPanel() {
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{processStatus.active ? (
{batchStatus.active ? (
<button
type="button"
onClick={handleStopProcess}
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" />
@@ -700,7 +694,7 @@ export function SyncPanel() {
) : (
<button
type="button"
onClick={handleStartProcess}
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" />
@@ -711,22 +705,32 @@ export function SyncPanel() {
</div>
{/* 进度与终端日志展示 */}
{(processStatus.active || processStatus.total > 0) && (
{(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">
{processStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{processStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
{processStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
{processStatus.action === 'download' ? '正文离线下载进度' : processStatus.action === 'parse' ? '结构化排版解析进度' : '中英双语对照翻译进度'}
{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>
{processStatus.action === 'download' ? processStatus.downloaded : processStatus.parsed} / {processStatus.total}
{((processStatus.action === 'download' && processStatus.download_failed > 0) ||
(processStatus.action !== 'download' && processStatus.parse_failed > 0)) && (
{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">
( {processStatus.action === 'download' ? processStatus.download_failed : processStatus.parse_failed} )
( {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} )
</span>
)}
</span>
@@ -734,18 +738,26 @@ export function SyncPanel() {
<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 ${
processStatus.action === 'download' ? 'bg-sky-600' : processStatus.action === 'parse' ? 'bg-emerald-600' : 'bg-indigo-600'
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: `${
processStatus.total > 0
batchStatus.total > 0
? Math.min(
100,
Math.round(
((processStatus.action === 'download'
? processStatus.downloaded + processStatus.download_failed
: processStatus.parsed + processStatus.parse_failed) /
processStatus.total) *
((batchStatus.action === 'download'
? batchStatus.downloaded + batchStatus.download_failed
: batchStatus.parsed + batchStatus.parse_failed) /
batchStatus.total) *
100
)
)
@@ -756,10 +768,10 @@ export function SyncPanel() {
</div>
</div>
{processStatus.active && processStatus.current_bibcode && (
{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">{processStatus.current_bibcode}</code></span>
<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>
)}
@@ -767,10 +779,10 @@ export function SyncPanel() {
<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">
{processStatus.logs.length === 0 ? (
{batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic">...</div>
) : (
processStatus.logs.map((log, idx) => (
batchStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
+1
View File
@@ -17,6 +17,7 @@ export interface StandardPaper {
has_html: boolean;
has_markdown: boolean;
has_translation: boolean;
has_vector: boolean;
doctype: string;
pdf_error?: string;
html_error?: string;