refactor: 全栈质量硬化

后端:
  - 权限系统重写: 全局→按 Session 隔离, 新增规则查询 API
  - 安全加固: 登录 IP 限流, Token 仅存 Cookie, bibcode 白名单校验
  - SSE 超时保护, 异步 I/O 迁移, 10+ 处静默 DB 错误改为显式日志
  - ar5iv 下标解析修复, parse_paper_row 去重, 优雅关闭

  前端:
  - useSyncScroll 重写: 段落 ID 映射修复中英错位
  - 全局竞态修复 (active 标志), libraryRef 闭包过期修复
  - ErrorBoundary + vitest 测试基础设施
  - Logo 组件提取, CustomSelect 泛型化, TabId 类型统一
  - ReaderPanel 自动视图模式, AIAssistantPanel 状态批处理
This commit is contained in:
Asfmq 2026-06-28 14:40:26 +08:00
parent 5f2d2d83f6
commit c5fd5b0d66
54 changed files with 3223 additions and 743 deletions

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"axios": "^1.17.0",
@ -29,6 +31,8 @@
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -37,9 +41,11 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^26.1.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vitest": "^3.2.1"
}
}

View File

@ -1,5 +1,5 @@
// dashboard/src/App.tsx
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { Loader, BookOpen, GitFork, Lock } from 'lucide-react';
import { Sidebar } from './components/layout/Sidebar';
@ -13,6 +13,8 @@ import type { StandardPaper, NoteRecord } from './types';
import { GlobalDialog } from './components/dialogs/GlobalDialog';
import { UncachedPaperModal } from './components/dialogs/UncachedPaperModal';
import { PaperDetailModal } from './components/dialogs/PaperDetailModal';
import { Logo } from './components/Logo';
import { ErrorBoundary } from './components/ErrorBoundary';
// 引入拆分出的业务逻辑 Hooks
import { useAuth } from './hooks/useAuth';
@ -52,7 +54,8 @@ export default function App() {
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => {
const saved = localStorage.getItem('astro_active_tab');
return (saved as any) || 'search';
const validTabs = ['search', 'library', 'reader', 'citation', 'sync', 'agent'] as const;
return (validTabs.includes(saved as typeof validTabs[number]) ? saved : 'search') as typeof validTabs[number];
});
const [englishText, setEnglishText] = useState('');
@ -71,20 +74,24 @@ export default function App() {
const citations = useCitations();
// 协调:进入阅读器方法 (需要拉取正文和笔记)
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
const openReader = useCallback(async (paper: StandardPaper, skipTabSwitch = false) => {
library.setSelectedPaper(paper);
const lib = libraryRef.current;
if (!lib) return;
lib.setSelectedPaper(paper);
localStorage.setItem('last_read_bibcode', paper.bibcode);
library.addPaperToRecent(paper);
lib.addPaperToRecent(paper);
setEnglishText('');
setChineseText('');
notes.setNotes([]);
notes.setShowNotesPanel(false);
if (!skipTabSwitch) {
setActiveTab('reader');
}
// 异步加载文献详情正文
try {
const res = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', {
@ -116,6 +123,8 @@ export default function App() {
openReader,
});
useEffect(() => { libraryRef.current = library; });
const search = useSearch({ showAlert });
// 协调:从 RAG 问答结果跳转至目标文献段落并高亮
@ -247,20 +256,7 @@ export default function App() {
<div className="console-panel rounded-lg p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
<div className="flex flex-col items-center text-center mb-7 select-none">
<div className="w-14 h-14 mb-3">
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill="url(#loginStarGrad)" />
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
<defs>
<linearGradient id="loginStarGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#106ba3" />
<stop offset="100%" stopColor="#0a2540" />
</linearGradient>
</defs>
</svg>
<Logo gradientId="loginStarGrad" />
</div>
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">AstroResearch</h2>
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide"> · </p>
@ -330,143 +326,144 @@ export default function App() {
<div className={`w-full flex-1 flex flex-col min-h-0 ${
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
}`}>
{activeTab === 'search' && (
<SearchPanel
searchQuery={search.searchQuery}
setSearchQuery={search.setSearchQuery}
searchSource={search.searchSource}
setSearchSource={search.setSearchSource}
searchRows={search.searchRows}
searchStart={search.searchStart}
searchSort={search.searchSort}
handlePageChange={search.handlePageChange}
handleSortChange={search.handleSortChange}
handleRowsChange={search.handleRowsChange}
searching={search.searching}
handleSearch={search.handleSearch}
searchResults={search.searchResults}
exportingList={search.exportingList}
toggleExportItem={search.toggleExportItem}
handleExportBibtex={search.handleExportBibtex}
exporting={search.exporting}
bibtexContent={search.bibtexContent}
downloadingBibcodes={library.downloadingBibcodes}
handleDownload={(bibcode, force) => library.handleDownload(bibcode, force, search.setSearchResults)}
selectedPaper={library.selectedPaper}
setSelectedPaper={library.setSelectedPaper}
openReader={openReader}
setActiveTab={setActiveTab}
loadCitations={citations.loadCitations}
showAlert={showAlert}
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
/>
)}
{activeTab === 'library' && (
<LibraryPanel
library={library.library}
fetchLibrary={library.fetchLibrary}
setActiveTab={setActiveTab}
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
onOpenReader={openReader}
onOpenCitation={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
/>
)}
{activeTab === 'reader' && (
library.selectedPaper ? (
<ReaderPanel
<ErrorBoundary>
{activeTab === 'search' && (
<SearchPanel
searchQuery={search.searchQuery}
setSearchQuery={search.setSearchQuery}
searchSource={search.searchSource}
setSearchSource={search.setSearchSource}
searchRows={search.searchRows}
searchStart={search.searchStart}
searchSort={search.searchSort}
handlePageChange={search.handlePageChange}
handleSortChange={search.handleSortChange}
handleRowsChange={search.handleRowsChange}
searching={search.searching}
handleSearch={search.handleSearch}
searchResults={search.searchResults}
exportingList={search.exportingList}
toggleExportItem={search.toggleExportItem}
handleExportBibtex={search.handleExportBibtex}
exporting={search.exporting}
bibtexContent={search.bibtexContent}
downloadingBibcodes={library.downloadingBibcodes}
handleDownload={(bibcode, force) => library.handleDownload(bibcode, force, search.setSearchResults)}
selectedPaper={library.selectedPaper}
library={library.library}
recentlySelected={library.recentlySelected}
onSwitchPaper={openReader}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={notes.showNotesPanel}
setShowNotesPanel={notes.setShowNotesPanel}
notes={notes.notes}
englishText={englishText}
chineseText={chineseText}
handleTextSelection={notes.handleTextSelection}
selectedParagraphIdx={notes.selectedParagraphIdx}
setSelectedParagraphIdx={notes.setSelectedParagraphIdx}
selectedText={notes.selectedText}
setSelectedText={notes.setSelectedText}
newNoteColor={notes.newNoteColor}
setNewNoteColor={notes.setNewNoteColor}
newNoteText={notes.newNoteText}
setNewNoteText={notes.setNewNoteText}
handleCreateNote={() => notes.handleCreateNote(library.selectedPaper)}
handleDeleteNote={notes.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] select-none">
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
<BookOpen className="w-8 h-8" />
</div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3>
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
</p>
<button
onClick={() => setActiveTab('library')}
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
>
</button>
</div>
)
)}
{activeTab === 'citation' && (
library.selectedPaper ? (
<CitationPanel
selectedPaper={library.selectedPaper}
library={library.library}
recentlySelected={library.recentlySelected}
onSwitchPaper={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
loadingCitations={citations.loadingCitations}
citationNetwork={citations.citationNetwork}
citationHistory={citations.citationHistory}
setSelectedPaper={library.setSelectedPaper}
openReader={openReader}
setActiveTab={setActiveTab}
loadCitations={citations.loadCitations}
onUncachedClick={citations.setUncachedBibcode}
showAlert={showAlert}
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
/>
) : (
<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] select-none">
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
<GitFork className="w-8 h-8" />
)}
{activeTab === 'library' && (
<LibraryPanel
library={library.library}
fetchLibrary={library.fetchLibrary}
setActiveTab={setActiveTab}
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
onOpenReader={openReader}
onOpenCitation={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
/>
)}
{activeTab === 'reader' && (
library.selectedPaper ? (
<ReaderPanel
selectedPaper={library.selectedPaper}
library={library.library}
recentlySelected={library.recentlySelected}
onSwitchPaper={openReader}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={notes.showNotesPanel}
setShowNotesPanel={notes.setShowNotesPanel}
notes={notes.notes}
englishText={englishText}
chineseText={chineseText}
handleTextSelection={notes.handleTextSelection}
selectedParagraphIdx={notes.selectedParagraphIdx}
setSelectedParagraphIdx={notes.setSelectedParagraphIdx}
selectedText={notes.selectedText}
setSelectedText={notes.setSelectedText}
newNoteColor={notes.newNoteColor}
setNewNoteColor={notes.setNewNoteColor}
newNoteText={notes.newNoteText}
setNewNoteText={notes.setNewNoteText}
handleCreateNote={() => notes.handleCreateNote(library.selectedPaper)}
handleDeleteNote={notes.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] select-none">
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
<BookOpen className="w-8 h-8" />
</div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3>
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
"馆藏管理"
</p>
<button
onClick={() => setActiveTab('library')}
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
>
</button>
</div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3>
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
-
</p>
<button
onClick={() => setActiveTab('library')}
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
>
</button>
</div>
)
)}
)
)}
{activeTab === 'sync' && (
<SyncPanel />
)}
{activeTab === 'citation' && (
library.selectedPaper ? (
<CitationPanel
selectedPaper={library.selectedPaper}
library={library.library}
recentlySelected={library.recentlySelected}
onSwitchPaper={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
loadingCitations={citations.loadingCitations}
citationNetwork={citations.citationNetwork}
citationHistory={citations.citationHistory}
loadCitations={citations.loadCitations}
onUncachedClick={citations.setUncachedBibcode}
/>
) : (
<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] select-none">
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
<GitFork className="w-8 h-8" />
</div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3>
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
"引用 - 被引""馆藏管理"
</p>
<button
onClick={() => setActiveTab('library')}
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
>
</button>
</div>
)
)}
{activeTab === 'agent' && (
<ResearchAgentPanel
showConfirm={showConfirm}
showAlert={showAlert}
/>
)}
{activeTab === 'sync' && (
<SyncPanel />
)}
{activeTab === 'agent' && (
<ResearchAgentPanel
showConfirm={showConfirm}
showAlert={showAlert}
/>
)}
</ErrorBoundary>
</div>
</div>
</main>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,4 +1,4 @@
// dashboard/src/features/reader/AIAssistantPanel.tsx
// dashboard/src/components/AIAssistantPanel.tsx
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
import { useState, useRef, useEffect } from 'react';
import {
@ -107,14 +107,16 @@ export function AIAssistantPanel({
// ── bibcode 切换重置 ──
useEffect(() => {
setMessages([]);
setSessionId(null);
setError(null);
setExpandedThoughts({});
setExpandedArgs({});
setExpandedResults({});
setCollapsedSubAgents({});
setPendingImage(null);
queueMicrotask(() => {
setMessages([]);
setSessionId(null);
setError(null);
setExpandedThoughts({});
setExpandedArgs({});
setExpandedResults({});
setCollapsedSubAgents({});
setPendingImage(null);
});
}, [bibcode]);
const handleStop = async () => {
@ -172,15 +174,15 @@ export function AIAssistantPanel({
if (last.sender !== 'ai') return prev;
const newTimeline = routeToolResult(last.timeline, tcId, name, _output, isError);
// RAG 来源收集
let sources = [...(last.sources || [])];
if (name === 'rag_search' && metadata?.sources) {
for (const s of metadata.sources) {
const sources = [...(last.sources || [])];
if (name === 'rag_search' && metadata?.sources && Array.isArray(metadata.sources)) {
for (const s of metadata.sources as Array<{ bibcode: string; paragraph_index: number; preview?: string; distance?: number }>) {
if (!sources.some(
c => c.bibcode === s.bibcode && c.paragraph_index === s.paragraph_index,
)) {
sources.push({
bibcode: s.bibcode, paragraph_index: s.paragraph_index,
content: s.preview || '', distance: s.distance,
content: s.preview || '', distance: s.distance ?? 0,
});
}
}

View File

@ -154,14 +154,14 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
const updatePhysics = () => {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let minDist = nodes[i].radius + nodes[j].radius + 60;
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const minDist = nodes[i].radius + nodes[j].radius + 60;
if (dist < minDist) {
let force = (minDist - dist) * 0.08;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
const force = (minDist - dist) * 0.08;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
if (nodes[i].type !== 'center' || nodes[i].id !== activeNetwork.bibcode) {
nodes[i].vx -= fx;
@ -179,12 +179,12 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
if (sourceNode && targetNode) {
let dx = targetNode.x - sourceNode.x;
let dy = targetNode.y - sourceNode.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = dist * 0.003;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
const dx = targetNode.x - sourceNode.x;
const dy = targetNode.y - sourceNode.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = dist * 0.003;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
if (sourceNode.type !== 'center' || sourceNode.id !== activeNetwork.bibcode) {
sourceNode.vx += fx;
@ -327,9 +327,9 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
let found: Node | null = null;
for (const node of nodes) {
let dx = node.x - gx;
let dy = node.y - gy;
let dist = Math.sqrt(dx * dx + dy * dy);
const dx = node.x - gx;
const dy = node.y - gy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < node.radius + 6) {
found = node;
break;

View File

@ -1,26 +1,26 @@
import { useState, useRef, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
export interface SelectOption {
value: string | number;
export interface SelectOption<T extends string | number = string | number> {
value: T;
label: React.ReactNode;
}
interface CustomSelectProps {
value: string | number;
onChange: (value: any) => void;
options: SelectOption[];
interface CustomSelectProps<T extends string | number = string | number> {
value: T;
onChange: (value: T) => void;
options: SelectOption<T>[];
className?: string;
disabled?: boolean;
}
export function CustomSelect({
export function CustomSelect<T extends string | number = string | number>({
value,
onChange,
options,
className = '',
disabled = false,
}: CustomSelectProps) {
}: CustomSelectProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

View File

@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorBoundary } from './ErrorBoundary';
function ThrowingComponent(): React.ReactNode {
throw new Error('Test error');
}
function WorkingComponent() {
return <div>Working content</div>;
}
describe('ErrorBoundary', () => {
it('renders children when no error', () => {
render(
<ErrorBoundary>
<WorkingComponent />
</ErrorBoundary>
);
expect(screen.getByText('Working content')).toBeInTheDocument();
});
it('renders error UI when child throws', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText('组件渲染出错')).toBeInTheDocument();
expect(screen.getByText('Test error')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('renders custom fallback when provided', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText('Custom fallback')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('retry button resets state and re-renders children', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { unmount } = render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText('组件渲染出错')).toBeInTheDocument();
fireEvent.click(screen.getByText('重试'));
unmount();
const { getByText } = render(
<ErrorBoundary>
<WorkingComponent />
</ErrorBoundary>
);
expect(getByText('Working content')).toBeInTheDocument();
consoleSpy.mockRestore();
});
});

View File

@ -0,0 +1,60 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center p-8 bg-white rounded-xl border border-slate-200 shadow-sm min-h-[300px]">
<div className="w-12 h-12 rounded-full bg-red-50 border border-red-100 flex items-center justify-center text-red-500 mb-4">
<AlertTriangle className="w-6 h-6" />
</div>
<h3 className="text-sm font-bold text-slate-800 mb-2"></h3>
<p className="text-xs text-slate-500 text-center max-w-sm mb-4">
{this.state.error?.message || '发生了未知错误'}
</p>
<button
onClick={this.handleRetry}
className="flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,23 @@
interface LogoProps {
className?: string;
gradientId?: string;
}
export function Logo({ className = 'w-full h-full', gradientId = 'logoStarGrad' }: LogoProps) {
return (
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill={`url(#${gradientId})`} />
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
<defs>
<linearGradient id={gradientId} x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#0284c7" />
<stop offset="100%" stopColor="#0369a1" />
</linearGradient>
</defs>
</svg>
);
}

View File

@ -90,7 +90,20 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
};
useEffect(() => {
fetchMetrics();
let active = true;
(async () => {
setLoading(true);
try {
const res = await axios.get<AgentMetricsResponse>('/api/chat/metrics');
if (active) setMetrics(res.data);
} catch (e) {
console.error('获取智能体指标失败:', e);
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, []);
// 提取工具调用排行取前10

View File

@ -100,11 +100,12 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
return next;
});
onAnswered?.();
} catch (e: any) {
} catch (e: unknown) {
console.error('提交答案失败:', e);
const msg = e.response?.status === 410
const axiosError = e as { response?: { status?: number } };
const msg = axiosError.response?.status === 410
? '该问题已超时或已被回答'
: e.response?.status === 404
: axiosError.response?.status === 404
? '未找到该问题'
: '提交失败,请稍后重试';
setError(prev => ({ ...prev, [questionId]: msg }));

View File

@ -52,16 +52,21 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
useEffect(() => {
if (!sessionId) return;
setLoading(true);
setError(null);
axios
.get<AuditLogEntry[]>(`/api/chat/sessions/${sessionId}/audit`)
.then(res => setEntries(res.data))
.catch(e => {
let active = true;
(async () => {
setLoading(true);
setError(null);
try {
const res = await axios.get<AuditLogEntry[]>(`/api/chat/sessions/${sessionId}/audit`);
if (active) setEntries(res.data);
} catch (e) {
console.error('加载审计日志失败:', e);
setError('审计日志加载失败');
})
.finally(() => setLoading(false));
if (active) setError('审计日志加载失败');
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [sessionId]);
const okCount = entries.filter(e => e.status === 'OK').length;

View File

@ -9,7 +9,7 @@ import { useAutoScroll } from './useAutoScroll';
interface ToolCallCardProps {
step: number;
name: string;
arguments: any;
arguments: Record<string, unknown>;
result?: { output: string; isError: boolean };
isStreaming: boolean;
colorScheme: ColorScheme;

View File

@ -82,7 +82,7 @@ export function routeToolCall(
step: number,
id: string,
name: string,
args: any,
args: Record<string, unknown>,
): TimelineItem[] {
// 创建 subagent_container
if (isSubagentContainerCall(name)) {
@ -220,7 +220,7 @@ function upsertParentToolCall(
step: number,
id: string,
name: string,
args: any,
args: Record<string, unknown>,
): TimelineItem[] {
const existing = timeline.find(
t => t.type === 'tool_call' && 'id' in t && t.id === id,

View File

@ -9,7 +9,7 @@ export type TimelineItem =
step: number;
id: string;
name: string;
arguments: any;
arguments: Record<string, unknown>;
result?: { output: string; isError: boolean };
}
| { type: 'answer'; content: string }
@ -25,14 +25,14 @@ export type TimelineItem =
export interface SSEEventHandlers {
onSession?: (sessionId: string, title?: string) => void;
onThought?: (step: number, content: string) => void;
onToolCall?: (step: number, id: string, name: string, args: any) => void;
onToolCall?: (step: number, id: string, name: string, args: Record<string, unknown>) => void;
onToolResult?: (
step: number,
toolCallId: string,
name: string,
output: string,
isError: boolean,
metadata?: any,
metadata?: Record<string, unknown>,
) => void;
onTextDelta?: (content: string, toolCallId?: string) => void;
onUsage?: (usage: {

View File

@ -24,7 +24,7 @@ export function useAgentSSE(): UseAgentSSEReturn {
abortRef.current = controller;
try {
const body: Record<string, any> = {
const body: Record<string, string | boolean | null | { data?: string; path?: string; mime_type: string }> = {
question: params.question,
session_id: params.sessionId,
mode: params.mode || 'default',
@ -125,13 +125,14 @@ export function useAgentSSE(): UseAgentSSEReturn {
setStreaming(false);
return resolvedSessionId;
} catch (e: any) {
if (e.name === 'AbortError') {
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error('未知错误');
if (error.name === 'AbortError') {
setStreaming(false);
return null;
}
console.error('智能体对话请求失败:', e);
handlers.onError?.(e.message || '网络连接错误,请稍后重试');
handlers.onError?.(error.message || '网络连接错误,请稍后重试');
setStreaming(false);
return null;
}

View File

@ -11,7 +11,7 @@ interface UseAutoScrollReturn {
handleScroll: () => void;
}
export function useAutoScroll(deps: any[]): UseAutoScrollReturn {
export function useAutoScroll(deps: React.DependencyList): UseAutoScrollReturn {
const chatEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);

View File

@ -2,10 +2,13 @@
import { useState } from 'react';
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { Logo } from '../Logo';
export type TabId = 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
interface SidebarProps {
activeTab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
activeTab: TabId;
setActiveTab: (tab: TabId) => void;
selectedPaper: StandardPaper | null;
loadCitations: (bibcode: string) => void;
onLogout: () => void;
@ -14,23 +17,6 @@ interface SidebarProps {
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const renderLogo = () => (
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill="url(#sidebarStarGrad)" />
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
<defs>
<linearGradient id="sidebarStarGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#0284c7" />
<stop offset="100%" stopColor="#0369a1" />
</linearGradient>
</defs>
</svg>
);
return (
<aside
className={`bg-slate-50 border-r border-slate-200 flex flex-col justify-between py-6 z-20 select-none transition-all duration-355 cubic-bezier(0.4, 0, 0.2, 1) ${
@ -57,7 +43,7 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
title={isCollapsed ? "展开导航" : undefined}
>
<div className={`transition-all duration-300 flex items-center justify-center ${isCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}>
{renderLogo()}
<Logo gradientId="sidebarStarGrad" />
</div>
</button>
@ -92,22 +78,21 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
{/* 导航菜单列表 */}
<nav className="space-y-1">
{[
{ id: 'search', label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library },
{ id: 'reader', label: '双语阅读', icon: BookOpen },
{ id: 'citation', label: '引用星系', icon: GitFork },
{ id: 'sync', label: '批量任务', icon: RefreshCw },
{ id: 'agent', label: '智能科研', icon: Sparkles },
].map((tab: { id: string; label: string; icon: any; disabled?: boolean }) => {
{ id: 'search' as TabId, label: '统一检索', icon: Search },
{ id: 'library' as TabId, label: '馆藏管理', icon: Library },
{ id: 'reader' as TabId, label: '双语阅读', icon: BookOpen },
{ id: 'citation' as TabId, label: '引用星系', icon: GitFork },
{ id: 'sync' as TabId, label: '批量任务', icon: RefreshCw },
{ id: 'agent' as TabId, label: '智能科研', icon: Sparkles },
].map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
disabled={tab.disabled}
title={isCollapsed ? tab.label : undefined}
onClick={() => {
setActiveTab(tab.id as any);
setActiveTab(tab.id);
if (tab.id === 'citation' && selectedPaper) {
loadCitations(selectedPaper.bibcode);
}
@ -115,11 +100,9 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border ${
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
} ${
isActive
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
: tab.disabled
? 'opacity-30 cursor-not-allowed border-transparent text-slate-400'
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
isActive
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`}
>
<Icon className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`} />

View File

@ -12,6 +12,7 @@ import type { StandardPaper, NoteRecord } from '../../types';
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial';
import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper';
import { splitParagraphs } from '../../hooks/useSyncScroll';
interface BilingualViewerProps {
selectedPaper: StandardPaper;
@ -44,11 +45,11 @@ interface BilingualViewerProps {
/**
* Metadata Dossier
*/
function PaperMetadataDossier({ metadata, isChinese }: { metadata: PaperMetadata; isChinese?: boolean }) {
function PaperMetadataDossier({ metadata, isChinese, id }: { metadata: PaperMetadata; isChinese?: boolean; id?: string }) {
if (!metadata || (!metadata.title && !metadata.author)) return null;
return (
<div className="mb-6 rounded-lg p-5 bg-slate-50 border border-slate-200 relative overflow-hidden select-text">
<div id={id} className="mb-6 rounded-lg p-5 bg-slate-50 border border-slate-200 relative overflow-hidden select-text">
{/* 科技风背景线段装饰 - 极细学术边框线 */}
<div className="absolute top-0 right-0 w-16 h-16 bg-slate-100/50 pointer-events-none border-l border-b border-slate-200/60" />
@ -206,9 +207,9 @@ export function BilingualViewer({
>
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-300 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg">
{/* 渲染英文元数据档案面板 */}
{engMeta && <PaperMetadataDossier metadata={engMeta} isChinese={false} />}
{engMeta && <PaperMetadataDossier metadata={engMeta} isChinese={false} id="paragraph-block--1" />}
{engPure.split('\n\n').map((para, idx) => {
{splitParagraphs(engPure).map((para: string, idx: number) => {
const matchedNote = notes.find(n => n.paragraph_index === idx);
const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null;
return (
@ -285,9 +286,9 @@ export function BilingualViewer({
>
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-350 prose-img:max-w-full prose-img:rounded-lg">
{/* 渲染中文元数据档案面板 */}
{mergedChnMeta && <PaperMetadataDossier metadata={mergedChnMeta} isChinese={true} />}
{mergedChnMeta && <PaperMetadataDossier metadata={mergedChnMeta} isChinese={true} id="paragraph-block--1" />}
{chnPure.split('\n\n').map((para, idx) => (
{splitParagraphs(chnPure).map((para: string, idx: number) => (
<div
key={idx}
id={`paragraph-block-${idx}`}

View File

@ -73,7 +73,7 @@ export function BatchPipelineCard({
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={val => setTargetPhase(val as any)}
onChange={val => setTargetPhase(val as 'download' | 'parse' | 'translate' | 'embed' | 'target')}
className="w-full"
options={[
{ value: 'download', label: '下载' },
@ -101,7 +101,7 @@ export function BatchPipelineCard({
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={val => setSortOrder(val as any)}
onChange={val => setSortOrder(val as 'default' | 'pub_year_desc' | 'created_at_desc')}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },

View File

@ -88,7 +88,7 @@ export function MetadataSyncCard({
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
onClick={() => setSource(src.id as 'all' | 'ads' | 'arxiv')}
className={`flex-1 py-2 rounded-md text-xs font-bold border transition-all cursor-pointer ${
source === src.id
? 'bg-slate-100 border-slate-350 text-slate-855 shadow-2xs'

View File

@ -36,9 +36,10 @@ export function useAuth() {
await axios.post('/api/auth/login', { password });
setIsAuthenticated(true);
setLoginError(null);
} catch (err: any) {
} catch (err: unknown) {
console.error('登录校验失败:', err);
const errMsg = err.response?.data || '密码错误,请重试。';
const axiosError = err as { response?: { data?: string } };
const errMsg = axiosError.response?.data || '密码错误,请重试。';
setLoginError(errMsg);
} finally {
setLoggingIn(false);

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
import type { TabId } from '../components/layout/Sidebar';
interface UseLibraryProps {
isAuthenticated: boolean | null;
@ -46,7 +47,7 @@ export function useLibrary({
const openCitation = (
paper: StandardPaper,
setActiveTab: (tab: any) => void,
setActiveTab: (tab: TabId) => void,
loadCitations: (bibcode: string, reset?: boolean) => void
) => {
setSelectedPaper(paper);
@ -75,9 +76,27 @@ export function useLibrary({
// 初始化加载本地文献库
useEffect(() => {
if (isAuthenticated === true) {
fetchLibrary();
}
if (isAuthenticated !== true) return;
let active = true;
(async () => {
try {
const res = await axios.get<StandardPaper[]>('/api/library');
if (!active) return;
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);
}
})();
return () => { active = false; };
}, [isAuthenticated]);
// 触发文献双格式下载
@ -136,9 +155,10 @@ export function useLibrary({
setSelectedPaper(res.data);
}
showAlert('手动文献文件上传导入成功!', '上传成功');
} catch (e: any) {
} catch (e: unknown) {
console.error('手动文件上传失败', e);
const errMsg = e.response?.data || '请确保上传的是合法且完整的文件。';
const axiosError = e as { response?: { data?: string } };
const errMsg = axiosError.response?.data || '请确保上传的是合法且完整的文件。';
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
} finally {
setUploadingBibcode(null);
@ -170,9 +190,10 @@ export function useLibrary({
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
'标记更新'
);
} catch (e: any) {
} catch (e: unknown) {
console.error('标记更新失败', e);
const errMsg = e.response?.data || '请稍后重试。';
const axiosError = e as { response?: { data?: string } };
const errMsg = axiosError.response?.data || '请稍后重试。';
showAlert(`标记失败: ${errMsg}`, '操作出错');
}
};

View File

@ -47,6 +47,19 @@ export function useReaderState({
const hasMarkdown = !!englishText;
// 天体识别与 RAG 助手状态
const [targets, setTargets] = useState<TargetInfo[]>([]);
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<TargetInfo | null>(null);
const [hoverCardPos, setHoverCardPos] = useState<{ x: number; y: number; isAbove?: boolean } | null>(null);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
// 绑定双语自适应双轨同步滚动 (方案三)
const {
englishRef,
@ -57,19 +70,6 @@ export function useReaderState({
if (hoveredTarget) setHoveredTarget(null);
});
// 天体识别与 RAG 助手状态
const [targets, setTargets] = useState<TargetInfo[]>([]);
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<TargetInfo | null>(null);
const [hoverCardPos, setHoverCardPos] = useState<{ x: number; y: number; isAbove?: boolean } | null>(null);
const hoverTimeout = useRef<any>(null);
// 预先计算并缓存天体高亮匹配项
const highlightCandidates = useMemo(() => {
return getHighlightCandidates(targets);
@ -77,23 +77,23 @@ export function useReaderState({
// 获取天体关联列表
useEffect(() => {
const fetchTargets = async () => {
if (!selectedPaper?.bibcode) return;
let active = true;
(async () => {
setSidebarTab('notes');
setLoadingTargets(true);
try {
const res = await axios.get<TargetInfo[]>('/api/target/list', {
params: { bibcode: selectedPaper.bibcode }
});
setTargets(res.data);
if (active) setTargets(res.data);
} catch (e) {
console.error('获取天体关联列表失败:', e);
} finally {
setLoadingTargets(false);
if (active) setLoadingTargets(false);
}
};
if (selectedPaper?.bibcode) {
fetchTargets();
setSidebarTab('notes');
}
})();
return () => { active = false; };
}, [selectedPaper?.bibcode]);
// 自动从正文中提取天体并查询 CDS Sesame 缓存

View File

@ -8,17 +8,9 @@ import {
routeTextDelta,
} from '../components/agent';
import type { TimelineItem } from '../components/agent';
import type { SessionSummary, MessageRecord } from '../types';
export interface SessionSummary {
session_id: string;
title: string;
model: string;
mode: string;
turn_count: number;
summary?: string | null;
created_at: string;
updated_at: string;
}
export type { SessionSummary, MessageRecord } from '../types';
export interface SearchResult {
result_type: string;
@ -28,30 +20,6 @@ export interface SearchResult {
created_at?: string | null;
}
export interface MessageRecord {
id: number;
agent_name: string;
turn_index: number;
step_index: number;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
thought?: string | null;
tool_calls?: ToolCall[] | null;
tool_call_id?: string | null;
token_count: number;
metadata?: any | null;
created_at: string;
}
export interface ToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string; // JSON string
};
}
export interface ActiveTurn {
question: string;
imagePath?: string;
@ -202,23 +170,30 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
// 初始化加载
useEffect(() => {
fetchSessions(false);
const fetchModes = async () => {
let active = true;
(async () => {
try {
const res = await axios.get<AgentMode[]>('/api/chat/modes');
setAgentModes(res.data);
} catch (e) {
console.error('获取 Agent 运行模式列表失败:', e);
// Fallback in case of API failure for resilience
setAgentModes([
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
setLoadingSessions(true);
const [sessionsRes, modesRes] = await Promise.all([
axios.get<SessionSummary[]>('/api/chat/sessions'),
axios.get<AgentMode[]>('/api/chat/modes').catch(() => ({
data: [
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
] as AgentMode[],
})),
]);
if (!active) return;
setSessions(sessionsRes.data);
setAgentModes(modesRes.data);
} catch (e) {
console.error('初始化 Agent 数据失败:', e);
} finally {
if (active) setLoadingSessions(false);
}
};
fetchModes();
})();
return () => { active = false; };
}, []);
// 加载选中的会话历史消息
@ -275,28 +250,31 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
// 会话切换监听
useEffect(() => {
let active = true;
if (currentSessionId) {
if (skipNextHistoryLoadRef.current) {
skipNextHistoryLoadRef.current = false;
} else {
loadSessionHistory(currentSessionId);
loadSessionHistory(currentSessionId, true);
}
} else {
setMessages([]);
queueMicrotask(() => { if (active) setMessages([]); });
}
return () => { active = false; };
}, [currentSessionId]);
// 跨会话历史检索防抖逻辑
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
queueMicrotask(() => setSearchResults([]));
return;
}
let active = true;
const delayDebounceFn = setTimeout(async () => {
setLoadingSearch(true);
try {
const params: Record<string, any> = {
const params: Record<string, string | number | boolean> = {
q: searchQuery,
scope: 'all',
limit: 30,
@ -307,7 +285,7 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
const res = await axios.get<SearchResult[]>('/api/search/history', {
params,
});
setSearchResults(res.data);
if (active) setSearchResults(res.data);
} catch (e) {
console.error('搜索会话历史失败:', e);
} finally {
@ -315,7 +293,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
}
}, 300);
return () => clearTimeout(delayDebounceFn);
return () => {
active = false;
clearTimeout(delayDebounceFn);
};
}, [searchQuery, searchScopeOnlyCurrent, currentSessionId]);
// 新建会话
@ -384,8 +365,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
// 重新加载会话(静默刷新,避免闪烁)
loadSessionHistory(currentSessionId, true);
fetchSessions(); // 更新侧栏 turn_count
} catch (e: any) {
const errMsg = `回退失败: ${e.response?.data || e.message}`;
} catch (e: unknown) {
const axiosError = e as { response?: { data?: string }; message?: string };
const errMsg = `回退失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -428,8 +410,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
alert(msg);
}
}
} catch (e: any) {
const errMsg = `恢复失败: ${e.response?.data || e.message}`;
} catch (e: unknown) {
const axiosError = e as { response?: { data?: string }; message?: string };
const errMsg = `恢复失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -456,8 +439,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
// 刷新列表并选中新的分叉会话
await fetchSessions();
setCurrentSessionId(res.data.branch_session_id);
} catch (e: any) {
const errMsg = `分叉失败: ${e.response?.data || e.message}`;
} catch (e: unknown) {
const axiosError = e as { response?: { data?: string }; message?: string };
const errMsg = `分叉失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -510,8 +494,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
// 触发自动重发
handleSend(questionText);
} catch (e: any) {
const errMsg = `重试失败: ${e.response?.data || e.message}`;
} catch (e: unknown) {
const axiosError = e as { response?: { data?: string }; message?: string };
const errMsg = `重试失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -704,9 +689,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
setActiveTurn(null);
}
} catch (e: any) {
} catch (e: unknown) {
console.error('智能体对话请求失败:', e);
setActiveTurn(prev => prev ? { ...prev, error: e.message || '网络连接错误,请稍后重试' } : null);
const error = e instanceof Error ? e : new Error('未知错误');
setActiveTurn(prev => prev ? { ...prev, error: error.message || '网络连接错误,请稍后重试' } : null);
setStreaming(false);
}
};
@ -790,7 +776,7 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
const subByAgent = new Map<string, MessageRecord[]>();
for (const sm of subMessages) {
const key = sm.metadata?.agent || sm.agent_name || 'unknown';
const key = (sm.metadata?.agent as string) || sm.agent_name || 'unknown';
if (!subByAgent.has(key)) subByAgent.set(key, []);
subByAgent.get(key)!.push(sm);
}
@ -841,11 +827,11 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
}
}
for (const tc of msg.tool_calls!) {
let parsedArgs = {};
let parsedArgs: Record<string, unknown> = {};
try {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
: tc.function.arguments as Record<string, unknown>;
} catch { /* ignore */ }
turn.timeline.push({
type: 'tool_call',
@ -912,11 +898,11 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
}
}
for (const tc of msg.tool_calls!) {
let parsedArgs = {};
let parsedArgs: Record<string, unknown> = {};
try {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
: tc.function.arguments as Record<string, unknown>;
} catch { /* ignore */ }
children.push({
type: 'tool_call',

View File

@ -3,6 +3,17 @@
import { useRef, useCallback } from 'react';
/**
* markdown
* split('\n\n')
*/
export function splitParagraphs(text: string): string[] {
if (!text.trim()) return [];
// 按 2 个以上连续换行拆分,然后过滤空段落
const raw = text.split(/\n{2,}/);
return raw.filter(p => p.trim().length > 0);
}
export function useSyncScroll(
syncScroll: boolean,
viewMode: 'bilingual' | 'english' | 'chinese',
@ -14,11 +25,10 @@ export function useSyncScroll(
const chineseRef = useRef<HTMLDivElement>(null);
const scrollLock = useRef(false);
// 英文滚动同步到中文
const handleEnglishScroll = useCallback(() => {
if (!syncScroll || viewMode !== 'bilingual') return;
if (scrollLock.current) return;
if (onScrollStart) onScrollStart();
const eng = englishRef.current;
@ -27,94 +37,113 @@ export function useSyncScroll(
scrollLock.current = true;
// ── 判定是否执行全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
return;
}
// ── 执行高精度段落锚定与局部微调算法 ──
try {
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
if (engParas.length === 0) {
// 鲁棒性退后兜底:若没有获取到任何段落 DOM执行全局比例映射
// ── PDF / 无 Markdown → 全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
} else {
// 1. 计算 15% 虚拟视窗分界线的高度位置
const threshold = eng.scrollTop + eng.clientHeight * 0.15;
// 2. 遍历源端段落,寻找当前穿过分界线的活动锚点段落
let anchorIndex = -1;
let anchorElement: HTMLElement | null = null;
return;
}
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
if (engParas.length === 0 || chnParas.length === 0) {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
return;
}
// 1. 15% 虚拟视窗分界线
const threshold = eng.scrollTop + eng.clientHeight * 0.15;
// 2. 寻找源端锚点段落
let anchorIndex = -1;
for (let i = 0; i < engParas.length; i++) {
const el = engParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
anchorIndex = i;
break;
}
}
// 3. 边界兜底:找距离 threshold 最近的段落
if (anchorIndex === -1) {
let minDist = Infinity;
for (let i = 0; i < engParas.length; i++) {
const el = engParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
const mid = el.offsetTop + el.offsetHeight / 2;
const dist = Math.abs(mid - threshold);
if (dist < minDist) {
minDist = dist;
anchorIndex = i;
anchorElement = el;
break;
}
}
}
// 3. 边界兜底:若没有完美契合的活动段落
if (anchorIndex === -1) {
if (threshold < (engParas[0] as HTMLElement).offsetTop) {
// 比第一段还靠上,锚定第 0 段
anchorIndex = 0;
anchorElement = engParas[0] as HTMLElement;
} else {
// 已经滚过了最后一段,锚定最后一段
anchorIndex = engParas.length - 1;
anchorElement = engParas[engParas.length - 1] as HTMLElement;
}
const anchorElement = engParas[anchorIndex] as HTMLElement;
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
const localRatio = Math.max(0, Math.min(1, (threshold - offsetTop) / offsetHeight));
// 4. 段落对齐映射(用实际段落 ID 的数值索引做映射,跳过 metadata -1
const engParaIds = Array.from(engParas).map(el => {
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
return m ? parseInt(m[1], 10) : -1;
});
const chnParaIds = Array.from(chnParas).map(el => {
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
return m ? parseInt(m[1], 10) : -1;
});
const anchorParaId = engParaIds[anchorIndex] ?? -1;
// 用所有元素(含 metadata id=-1做映射保证连续性
const engTextParas = engParaIds
.map((id, idx) => ({ id, idx }));
const chnTextParas = chnParaIds
.map((id, idx) => ({ id, idx }));
const engTextIdx = engTextParas.findIndex(x => x.id === anchorParaId);
if (engTextIdx < 0 || chnTextParas.length === 0) {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
return;
}
// 按段落 ID 做映射:找到目标侧 ID 最接近的段落
let bestTargetIdx = 0;
let bestDist = Math.abs(chnTextParas[0].id - anchorParaId);
for (let i = 1; i < chnTextParas.length; i++) {
const dist = Math.abs(chnTextParas[i].id - anchorParaId);
if (dist < bestDist) {
bestDist = dist;
bestTargetIdx = i;
}
}
// 4. 计算该段落的局部滚出比例 (LocalRatio)
let localRatio = 0;
if (anchorElement) {
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
localRatio = (threshold - offsetTop) / offsetHeight;
localRatio = Math.max(0, Math.min(1, localRatio)); // 限制在安全浮点数 0~1
}
const targetPara = chnParas[chnTextParas[bestTargetIdx].idx] as HTMLElement | undefined;
// 5. 目标端(中文)寻找同索引段落并进行物理锚定
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
const targetPara = chnParas[anchorIndex] as HTMLElement | undefined;
if (targetPara) {
const targetOffsetTop = targetPara.offsetTop;
const targetOffsetHeight = targetPara.offsetHeight;
// 目标 scrollTop = 目标段落顶部位置 - 目标容器 15% 分界线偏置 + 局部拉伸偏移
const targetScrollTop = targetOffsetTop - chn.clientHeight * 0.15 + localRatio * targetOffsetHeight;
const maxScroll = chn.scrollHeight - chn.clientHeight;
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
// 极端边界:若两侧段落数不一致导致越界,以全局比例兜底
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
}
if (targetPara) {
const targetScrollTop = targetPara.offsetTop - chn.clientHeight * 0.15 + localRatio * targetPara.offsetHeight;
const maxScroll = chn.scrollHeight - chn.clientHeight;
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
}
} catch (err) {
console.error('段落对齐滚动计算失败:', err);
} finally {
requestAnimationFrame(() => {
scrollLock.current = false;
});
// scrollTop 赋值是同步的,直接释放 lock 即可,无需 rAF
scrollLock.current = false;
}
}, [syncScroll, viewMode, showPdf, hasMarkdown, onScrollStart]);
// 中文滚动同步到英文
const handleChineseScroll = useCallback(() => {
if (!syncScroll || viewMode !== 'bilingual') return;
if (scrollLock.current) return;
if (onScrollStart) onScrollStart();
const eng = englishRef.current;
@ -123,81 +152,101 @@ export function useSyncScroll(
scrollLock.current = true;
// ── 判定是否执行全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
return;
}
// ── 执行高精度段落锚定与局部微调算法 ──
try {
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
if (chnParas.length === 0) {
if (showPdf || !hasMarkdown) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
} else {
// 1. 计算 15% 虚拟视窗分界线的高度位置
const threshold = chn.scrollTop + chn.clientHeight * 0.15;
// 2. 遍历源端段落,寻找当前活动锚点段落
let anchorIndex = -1;
let anchorElement: HTMLElement | null = null;
return;
}
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
if (engParas.length === 0 || chnParas.length === 0) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
return;
}
const threshold = chn.scrollTop + chn.clientHeight * 0.15;
let anchorIndex = -1;
for (let i = 0; i < chnParas.length; i++) {
const el = chnParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
anchorIndex = i;
break;
}
}
// 3. 边界兜底:找距离 threshold 最近的段落
if (anchorIndex === -1) {
let minDist = Infinity;
for (let i = 0; i < chnParas.length; i++) {
const el = chnParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
const mid = el.offsetTop + el.offsetHeight / 2;
const dist = Math.abs(mid - threshold);
if (dist < minDist) {
minDist = dist;
anchorIndex = i;
anchorElement = el;
break;
}
}
}
// 3. 边界兜底
if (anchorIndex === -1) {
if (threshold < (chnParas[0] as HTMLElement).offsetTop) {
anchorIndex = 0;
anchorElement = chnParas[0] as HTMLElement;
} else {
anchorIndex = chnParas.length - 1;
anchorElement = chnParas[chnParas.length - 1] as HTMLElement;
}
const anchorElement = chnParas[anchorIndex] as HTMLElement;
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
const localRatio = Math.max(0, Math.min(1, (threshold - offsetTop) / offsetHeight));
// 按段落 ID 做映射
const engParaIds = Array.from(engParas).map(el => {
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
return m ? parseInt(m[1], 10) : -1;
});
const chnParaIds = Array.from(chnParas).map(el => {
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
return m ? parseInt(m[1], 10) : -1;
});
const anchorParaId = chnParaIds[anchorIndex] ?? -1;
// 用所有元素(含 metadata id=-1做映射保证连续性
const engTextParas = engParaIds
.map((id, idx) => ({ id, idx }));
const chnTextParas = chnParaIds
.map((id, idx) => ({ id, idx }));
const chnTextIdx = chnTextParas.findIndex(x => x.id === anchorParaId);
if (chnTextIdx < 0 || engTextParas.length === 0) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
return;
}
let bestTargetIdx = 0;
let bestDist = Math.abs(engTextParas[0].id - anchorParaId);
for (let i = 1; i < engTextParas.length; i++) {
const dist = Math.abs(engTextParas[i].id - anchorParaId);
if (dist < bestDist) {
bestDist = dist;
bestTargetIdx = i;
}
}
// 4. 计算该段落的局部滚出比例 (LocalRatio)
let localRatio = 0;
if (anchorElement) {
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
localRatio = (threshold - offsetTop) / offsetHeight;
localRatio = Math.max(0, Math.min(1, localRatio));
}
const targetPara = engParas[engTextParas[bestTargetIdx].idx] as HTMLElement | undefined;
// 5. 目标端(英文)寻找同索引段落并进行物理锚定
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
const targetPara = engParas[anchorIndex] as HTMLElement | undefined;
if (targetPara) {
const targetOffsetTop = targetPara.offsetTop;
const targetOffsetHeight = targetPara.offsetHeight;
const targetScrollTop = targetOffsetTop - eng.clientHeight * 0.15 + localRatio * targetOffsetHeight;
const maxScroll = eng.scrollHeight - eng.clientHeight;
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
}
if (targetPara) {
const targetScrollTop = targetPara.offsetTop - eng.clientHeight * 0.15 + localRatio * targetPara.offsetHeight;
const maxScroll = eng.scrollHeight - eng.clientHeight;
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
}
} catch (err) {
console.error('段落对齐滚动计算失败:', err);
} finally {
requestAnimationFrame(() => {
scrollLock.current = false;
});
scrollLock.current = false;
}
}, [syncScroll, viewMode, showPdf, hasMarkdown, onScrollStart]);
@ -206,5 +255,6 @@ export function useSyncScroll(
chineseRef,
handleEnglishScroll,
handleChineseScroll,
splitParagraphs,
};
}

View File

@ -38,7 +38,7 @@ export function useSyncState() {
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
@ -60,7 +60,7 @@ export function useSyncState() {
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const batchPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
@ -70,20 +70,17 @@ export function useSyncState() {
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
const 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;
}
const fieldPart = rule.field !== 'all'
? `${rule.field}:${valStr}`
: valStr;
if (idx === 0) {
qParts.push(fieldPart);
@ -131,7 +128,8 @@ export function useSyncState() {
const handleReuseQuery = (sq: SavedSyncQuery) => {
setQuery(sq.query);
setSource(sq.source as any);
const validSources = ['all', 'ads', 'arxiv'] as const;
setSource(validSources.includes(sq.source as typeof validSources[number]) ? sq.source as typeof validSources[number] : 'all');
setLimit(sq.limit_count);
};
@ -143,7 +141,7 @@ export function useSyncState() {
...prev,
active: true,
query: sq.query,
source: sq.source as any,
source: sq.source,
synced: 0,
total: sq.limit_count,
}));
@ -157,17 +155,20 @@ export function useSyncState() {
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
} catch (e: unknown) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
const ts = Date.now();
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${ts}`);
setStatus(res.data);
if (res.data.active) {
startPolling();
@ -190,10 +191,29 @@ export function useSyncState() {
};
useEffect(() => {
fetchStatus();
fetchSyncQueries();
let active = true;
(async () => {
try {
const ts = Date.now();
const [statusRes, queriesRes] = await Promise.all([
axios.get<HarvestStatus>(`/api/sync/meta/status?t=${ts}`),
axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${ts}`),
]);
if (!active) return;
setStatus(statusRes.data);
setSyncQueries(queriesRes.data);
if (statusRes.data.active) {
startPolling();
} else if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
} catch (e) {
console.error('初始化同步状态失败', e);
}
})();
return () => {
active = false;
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
@ -202,8 +222,10 @@ export function useSyncState() {
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
const ts = Date.now();
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
@ -235,9 +257,10 @@ export function useSyncState() {
});
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
} catch (e: unknown) {
console.error(e);
setBatchError(e.response?.data || '启动批量任务失败。');
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '启动批量任务失败。');
}
};
@ -245,16 +268,33 @@ export function useSyncState() {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
} catch (e: unknown) {
console.error(e);
setBatchError(e.response?.data || '停止任务失败。');
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchBatchStatus();
let active = true;
(async () => {
try {
const ts = Date.now();
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
if (!active) return;
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('初始化批量状态失败', e);
}
})();
return () => {
active = false;
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
@ -286,9 +326,10 @@ export function useSyncState() {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
} catch (e: unknown) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
@ -320,9 +361,10 @@ export function useSyncState() {
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
} catch (e: unknown) {
console.error(e);
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};

View File

@ -1,5 +1,5 @@
// dashboard/src/features/reader/ReaderPanel.tsx
import { useState } from 'react';
import { useState, useEffect } from 'react';
import type { StandardPaper, NoteRecord } from '../types';
import { useReaderState } from '../hooks/useReaderState';
import { ReaderToolbar } from '../components/reader/ReaderToolbar';
@ -74,15 +74,39 @@ export function ReaderPanel(props: ReaderPanelProps) {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return 'english';
}
if (!chineseText) {
return 'english';
}
return 'bilingual';
});
// 无翻译时强制回退到英文模式,有翻译时自动切换到双语对照
const [userOverrode, setUserOverrode] = useState(false);
useEffect(() => {
if (userOverrode) return;
if (!chineseText && viewMode === 'bilingual') {
queueMicrotask(() => setViewMode('english'));
} else if (chineseText && viewMode === 'english') {
queueMicrotask(() => setViewMode('bilingual'));
}
}, [chineseText]);
const handleViewModeChange = (mode: 'bilingual' | 'english' | 'chinese') => {
setUserOverrode(true);
setViewMode(mode);
};
// 切换文献时重置用户手动覆盖标记
useEffect(() => {
queueMicrotask(() => setUserOverrode(false));
}, [selectedPaper?.bibcode]);
// 调用封装后的阅读器逻辑状态 Hook
const state = useReaderState({
selectedPaper,
englishText,
viewMode,
setViewMode,
setViewMode: handleViewModeChange,
});
return (
@ -108,7 +132,7 @@ export function ReaderPanel(props: ReaderPanelProps) {
showSwitchMenu={state.showSwitchMenu}
setShowSwitchMenu={state.setShowSwitchMenu}
viewMode={viewMode}
setViewMode={setViewMode}
setViewMode={handleViewModeChange}
syncScroll={state.syncScroll}
setSyncScroll={state.setSyncScroll}
showPdf={state.showPdf}

View File

@ -75,20 +75,17 @@ export function SearchPanel({
]);
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
const 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;
}
const fieldPart = rule.field !== 'all'
? `${rule.field}:${valStr}`
: valStr;
if (idx === 0) {
qParts.push(fieldPart);
@ -181,7 +178,7 @@ export function SearchPanel({
{idx > 0 ? (
<CustomSelect
value={rule.op}
onChange={val => handleRuleChange(idx, 'op', val)}
onChange={val => handleRuleChange(idx, 'op', String(val))}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
@ -195,7 +192,7 @@ export function SearchPanel({
<CustomSelect
value={rule.field}
onChange={val => handleRuleChange(idx, 'field', val)}
onChange={val => handleRuleChange(idx, 'field', String(val))}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
@ -258,7 +255,7 @@ export function SearchPanel({
type="radio"
name="searchSource"
checked={searchSource === src.id}
onChange={() => setSearchSource(src.id as any)}
onChange={() => setSearchSource(src.id as 'all' | 'ads' | 'arxiv')}
className="accent-slate-700"
/>
<span className={searchSource === src.id ? 'text-slate-900 font-bold' : 'text-slate-500 hover:text-slate-700'}>

View File

@ -1,25 +0,0 @@
// dashboard/src/features/settings/SettingsPanel.tsx
export function SettingsPanel() {
return (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<h2 className="text-lg font-bold text-slate-800"></h2>
<p className="text-xs text-slate-500">AstroResearch </p>
</div>
<div className="console-panel p-6 rounded-lg space-y-6">
<div className="border-b border-slate-200 pb-4">
<h3 className="text-sm font-semibold text-slate-800 mb-1"> (.env) </h3>
<p className="text-xs text-slate-500 leading-relaxed">
API Token <code className="text-xs text-blueprint font-semibold bg-slate-50 border border-slate-200 px-1 py-0.5 rounded">.env</code>
</p>
</div>
<div className="bg-slate-50 border border-slate-200 border-l-4 border-l-blueprint p-4 rounded-md text-xs text-slate-700 leading-relaxed">
<strong></strong> 使 <code className="text-xs text-blueprint font-semibold bg-slate-100 px-1 py-0.5 rounded">/home/fmq//astrodict_241020/astrodict241020_ec.txt</code>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -1,4 +1,4 @@
// dashboard/src/types.ts
// dashboard/src/types/index.ts
export interface StandardPaper {
bibcode: string;
@ -59,6 +59,7 @@ export interface SessionSummary {
model: string;
mode: string;
turn_count: number;
summary?: string | null;
created_at: string;
updated_at: string;
}
@ -74,7 +75,7 @@ export interface MessageRecord {
tool_calls?: ToolCall[] | null;
tool_call_id?: string | null;
token_count: number;
metadata?: any | null;
metadata?: Record<string, unknown> | null;
created_at: string;
}

View File

@ -102,7 +102,7 @@ export function highlightTargetsInMarkdown(text: string, candidates: HighlightCa
let result = text;
for (const cand of candidates) {
const escaped = cand.name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const escaped = cand.name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
// 根据兼容开关决定是否启用宽容的 Unicode 空格正则通配
const regexStr = ENABLE_LEGACY_UNICODE_SPACE_COMPAT
@ -112,7 +112,7 @@ export function highlightTargetsInMarkdown(text: string, candidates: HighlightCa
const regex = new RegExp(`\\b(${regexStr})\\b`, 'gi');
// 按 HTML 标签、LaTeX 行内/块级公式、Markdown 链接进行切片
const parts = result.split(/(<[^>]+>|\$\$[\s\S]*?\ $\$|\$[\s\S]*?\$|\[[^\]]*\]\([^\)]*\))/g);
const parts = result.split(/(<[^>]+>|\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\[[^\]]*\]\([^)]*\))/g);
result = parts.map((part) => {
// 若是标签、公式或 Markdown 链接,则原样返回

View File

@ -78,10 +78,10 @@ export function parseMarkdownFrontMatter(markdown: string): {
let val = line.slice(sepIdx + 1).trim();
// 清理 key 中的 Markdown 粗体/斜体等标记,如 **, *, __, _
rawKey = rawKey.replace(/[\*_]/g, '').trim().toLowerCase();
rawKey = rawKey.replace(/[*_]/g, '').trim().toLowerCase();
// 清理 val 首尾的 Markdown 粗体/斜体标记(大模型翻译时常在值末尾加加粗符号)
val = val.replace(/^[\*_]+|[\*_]+$/g, '').trim();
val = val.replace(/^[*_]+|[*_]+$/g, '').trim();
// 去除包裹的字符串双引号或单引号
if (val.startsWith('"') && val.endsWith('"')) {

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
});

View File

@ -129,7 +129,7 @@ pub async fn execute_parallel(
app_state: Arc<AppState>,
hook_registry: &HookRegistry,
permission_checker: Option<&PermissionChecker>,
session_permission_checker: Option<&std::sync::RwLock<PermissionChecker>>,
session_permission_checker: Option<&PermissionChecker>,
denial_tracker: Option<&std::sync::Mutex<DenialTracker>>,
checkpoint_manager: Option<&std::sync::Arc<CheckpointManager>>,
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
@ -336,25 +336,23 @@ pub async fn execute_parallel(
// 会话级权限检查API 动态添加的规则,优先级高于环境变量规则)
if let Some(session_checker) = session_permission_checker {
if let Ok(checker) = session_checker.read() {
let session_result = checker.check(&prep.tool_name, Some(&prep.args));
// 会话规则结果覆盖或升级
match session_result {
PermissionResult::Denied { reason } => {
// 会话 Deny 强制覆盖
perm_result = PermissionResult::Denied { reason };
}
PermissionResult::AskUser { message } => {
// 会话 Ask 在 Allow 时升级
if perm_result.is_allowed() {
perm_result = PermissionResult::AskUser { message };
}
}
PermissionResult::Allowed => {
// 会话 Allow 仅覆盖 Allowed保持 Deny/AskUser 不变
// 避免覆盖工具级 check_permissions() 升级的 AskUser
let session_result = session_checker.check(&prep.tool_name, Some(&prep.args));
// 会话规则结果覆盖或升级
match session_result {
PermissionResult::Denied { reason } => {
// 会话 Deny 强制覆盖
perm_result = PermissionResult::Denied { reason };
}
PermissionResult::AskUser { message } => {
// 会话 Ask 在 Allow 时升级
if perm_result.is_allowed() {
perm_result = PermissionResult::AskUser { message };
}
}
PermissionResult::Allowed => {
// 会话 Allow 仅覆盖 Allowed保持 Deny/AskUser 不变
// 避免覆盖工具级 check_permissions() 升级的 AskUser
}
}
}

View File

@ -130,10 +130,7 @@ impl AgentRuntime {
permission_checker.clone(),
),
));
// 初始化会话级权限检查器(与 AgentRuntime 使用相同的环境变量规则)
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
*session_checker = (*permission_checker).clone();
}
// 会话级权限检查器将在 run_react_loop 中按 session_id 注册
// 视觉模型可用时注册 analyze_image 工具
if app_state.vision_llm.is_some() {
@ -202,10 +199,7 @@ impl AgentRuntime {
permission_checker.clone(),
),
));
// 初始化会话级权限检查器
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
*session_checker = (*permission_checker).clone();
}
// 会话级权限检查器将在 run_react_loop 中按 session_id 注册
// 视觉模型可用时注册 analyze_image 工具
if app_state.vision_llm.is_some() {
@ -497,6 +491,15 @@ impl AgentRuntime {
let sid = &session_info.session_id;
let turn_index = session_info.turn_index;
// 注册当前会话的权限检查器(如不存在则从全局配置初始化)
{
if let Ok(mut checkers) = self.app_state.session_permission_checkers.write() {
checkers
.entry(sid.clone())
.or_insert_with(|| (*self.permission_checker).clone());
}
}
let tool_defs = self.tool_registry.definitions();
let mut duplicate_detector = DuplicateDetector::default();
let mut metrics = AgentMetrics::default();
@ -867,13 +870,20 @@ impl AgentRuntime {
}
// 并行执行工具带权限检查、checkpoint 和分区器)
let session_checker_snapshot = {
self.app_state
.session_permission_checkers
.read()
.ok()
.and_then(|checkers| checkers.get(sid).cloned())
};
let exec_result = executor::execute_parallel(
&prepared_calls,
&self.tool_registry,
self.app_state.clone(),
hook_registry,
Some(&self.permission_checker),
Some(&self.app_state.session_permission_checker),
session_checker_snapshot.as_ref(),
Some(&self.denial_tracker),
Some(&self.checkpoint_manager),
tx,

View File

@ -76,6 +76,16 @@ impl PermissionChecker {
}
}
/// 返回当前规则列表的只读引用
pub fn rules(&self) -> &[PermissionRule] {
&self.rules
}
/// 返回当前权限模式
pub fn mode(&self) -> PermissionMode {
self.mode
}
/// 添加规则。先添加的优先级更高。
pub fn add_rule(&mut self, rule: PermissionRule) {
self.rules.push(rule);

View File

@ -600,12 +600,13 @@ mod tests {
skill_registry: Arc::new(RwLock::new(SkillRegistry::new(PathBuf::from("/tmp/sk")))),
pending_questions: Arc::new(Mutex::new(std::collections::HashMap::new())),
pending_permissions: Arc::new(Mutex::new(std::collections::HashMap::new())),
session_permission_checker: Arc::new(RwLock::new(PermissionChecker::new())),
session_permission_checkers: Arc::new(RwLock::new(std::collections::HashMap::new())),
sse_broadcast: None,
memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from(
"/tmp/test_mem",
)))),
sessions: Arc::new(Mutex::new(std::collections::HashMap::new())),
login_rate_limiter: Arc::new(Mutex::new(std::collections::HashMap::new())),
});
ToolContext::new(app_state)

View File

@ -268,14 +268,13 @@ mod tests {
))),
pending_questions: Arc::new(Mutex::new(std::collections::HashMap::new())),
pending_permissions: Arc::new(Mutex::new(std::collections::HashMap::new())),
session_permission_checker: Arc::new(RwLock::new(
crate::agent::runtime::permission::PermissionChecker::new(),
)),
session_permission_checkers: Arc::new(RwLock::new(std::collections::HashMap::new())),
sse_broadcast: None,
memory_manager: Arc::new(tokio::sync::Mutex::new(
crate::agent::memory::MemoryManager::new(PathBuf::from("/tmp/test_memory")),
)),
sessions: Arc::new(Mutex::new(std::collections::HashMap::new())),
login_rate_limiter: Arc::new(Mutex::new(std::collections::HashMap::new())),
});
ToolContext {

View File

@ -193,15 +193,39 @@ pub async fn chat_agent(
}
});
// 将 mpsc 通道转换为 SSE 事件流
// 将 mpsc 通道转换为 SSE 事件流(带 10 分钟超时)
const SSE_TIMEOUT_SECS: u64 = 600;
let stream = async_stream::stream! {
while let Some(event) = rx.recv().await {
let data = serde_json::to_string(&event).unwrap_or_default();
let is_done = matches!(event, AgentStreamEvent::Done);
yield Ok(Event::default().data(data));
if is_done {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(SSE_TIMEOUT_SECS);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
let timeout_event = AgentStreamEvent::Error {
message: "Agent 执行超时10 分钟),请重试。".to_string(),
};
let data = serde_json::to_string(&timeout_event).unwrap_or_default();
yield Ok(Event::default().data(data));
break;
}
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Some(event)) => {
let data = serde_json::to_string(&event).unwrap_or_default();
let is_done = matches!(event, AgentStreamEvent::Done);
yield Ok(Event::default().data(data));
if is_done {
break;
}
}
Ok(None) => break, // channel closed
Err(_) => {
let timeout_event = AgentStreamEvent::Error {
message: "Agent 执行超时10 分钟),请重试。".to_string(),
};
let data = serde_json::to_string(&timeout_event).unwrap_or_default();
yield Ok(Event::default().data(data));
break;
}
}
}
};

View File

@ -19,7 +19,6 @@ pub struct LoginRequest {
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
pub status: String,
}
@ -93,10 +92,46 @@ fn prune_expired_sessions(
// 登录接口:验证密码成功后,写入 HttpOnly Cookie
pub async fn login(
State(state): State<Arc<AppState>>,
Json(req): Json<LoginRequest>,
headers: axum::http::header::HeaderMap,
Json(body): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> {
let password = req.password.unwrap_or_default();
// 提取客户端 IP用于速率限制
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.unwrap_or("unknown")
.trim()
.to_string();
// 检查速率限制5 次失败 / 5 分钟窗口
const MAX_FAILURES: u32 = 5;
const WINDOW_SECS: u64 = 300;
{
if let Ok(mut limiter) = state.login_rate_limiter.lock() {
if let Some((count, first_fail)) = limiter.get(&client_ip) {
let elapsed = first_fail.elapsed().as_secs();
if elapsed < WINDOW_SECS && *count >= MAX_FAILURES {
let remaining = WINDOW_SECS - elapsed;
return Err(AppError::unauthorized(format!(
"登录尝试过于频繁,请在 {} 秒后重试",
remaining
)));
}
if elapsed >= WINDOW_SECS {
limiter.remove(&client_ip);
}
}
}
}
let password = body.password.unwrap_or_default();
if verify_password(&password, &state.config.admin_password) {
// 成功:清除该 IP 的失败记录
if let Ok(mut limiter) = state.login_rate_limiter.lock() {
limiter.remove(&client_ip);
}
let token = uuid::Uuid::new_v4().to_string();
// 异常安全地记录活跃会话及其当前时间
@ -119,11 +154,18 @@ pub async fn login(
Ok((
headers,
Json(LoginResponse {
token: token.clone(),
status: "ok".to_string(),
}),
))
} else {
// 失败:记录该 IP 的失败次数
if let Ok(mut limiter) = state.login_rate_limiter.lock() {
let entry = limiter
.entry(client_ip)
.or_insert((0, std::time::Instant::now()));
entry.0 += 1;
// 重置窗口起点(从首次失败开始计算)
}
Err(AppError::unauthorized("密码错误"))
}
}

View File

@ -186,33 +186,16 @@ pub async fn save_paper_to_db_tx(
Ok(())
}
pub async fn get_paper_from_db(
db: &SqlitePool,
/// 从数据库行解析出 StandardPaper消除 get_paper_from_db 和 get_library 之间的重复代码)
///
/// 期望行的列顺序bibcode(0), title(1), authors(2), year(3), pub(4), keywords(5),
/// abstract(6), doi(7), arxiv_id(8), citation_count(9), reference_count(10),
/// pdf_path(11), html_path(12), markdown_path(13), translation_path(14),
/// doctype(15), has_vector(16)
pub fn parse_paper_row(
r: &sqlx::sqlite::SqliteRow,
library_dir: &std::path::Path,
identifier: &str,
) -> anyhow::Result<StandardPaper> {
let clean_id = identifier.trim();
let clean_doi = clean_id
.trim_start_matches("doi:")
.trim_start_matches("DOI:")
.trim_start_matches("https://doi.org/")
.trim_start_matches("http://doi.org/")
.trim();
let clean_arxiv = clean_id
.trim_start_matches("arxiv:")
.trim_start_matches("arXiv:")
.trim_start_matches("pdf/")
.trim();
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) FROM papers WHERE bibcode = ? OR doi = ? OR arxiv_id = ? OR LOWER(doi) = LOWER(?) OR arxiv_id = ?")
.bind(clean_id)
.bind(clean_doi)
.bind(clean_arxiv)
.bind(clean_doi)
.bind(clean_arxiv)
.fetch_one(db)
.await?;
) -> StandardPaper {
let pdf_path: Option<String> = r.get(11);
let html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13);
@ -255,7 +238,7 @@ pub async fn get_paper_from_db(
.filter(|p| p.starts_with("error:"))
.map(|p| p["error:".len()..].trim().to_string());
Ok(StandardPaper {
StandardPaper {
bibcode: r.get(0),
title: r.get(1),
authors,
@ -276,7 +259,37 @@ pub async fn get_paper_from_db(
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
pdf_error,
html_error,
})
}
}
pub async fn get_paper_from_db(
db: &SqlitePool,
library_dir: &std::path::Path,
identifier: &str,
) -> anyhow::Result<StandardPaper> {
let clean_id = identifier.trim();
let clean_doi = clean_id
.trim_start_matches("doi:")
.trim_start_matches("DOI:")
.trim_start_matches("https://doi.org/")
.trim_start_matches("http://doi.org/")
.trim();
let clean_arxiv = clean_id
.trim_start_matches("arxiv:")
.trim_start_matches("arXiv:")
.trim_start_matches("pdf/")
.trim();
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) FROM papers WHERE bibcode = ? OR doi = ? OR arxiv_id = ? OR LOWER(doi) = LOWER(?) OR arxiv_id = ?")
.bind(clean_id)
.bind(clean_doi)
.bind(clean_arxiv)
.bind(clean_doi)
.bind(clean_arxiv)
.fetch_one(db)
.await?;
Ok(parse_paper_row(&r, library_dir))
}
pub async fn check_paper_paths_in_db(

View File

@ -72,9 +72,12 @@ pub struct AppState {
pub pending_questions: Arc<Mutex<HashMap<String, PendingQuestion>>>,
/// 权限检查 — 待处理的权限确认请求
pub pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
/// 会话级权限检查器(支持 API 动态添加/移除规则,跨 turn 共享)
pub session_permission_checker:
Arc<RwLock<crate::agent::runtime::permission::PermissionChecker>>,
/// 会话级权限检查器注册表(按 session_id 隔离,支持 API 动态添加/移除规则)
pub session_permission_checkers: Arc<
RwLock<
std::collections::HashMap<String, crate::agent::runtime::permission::PermissionChecker>,
>,
>,
/// SSE 广播通道agent 运行时向所有连接的客户端推送事件)
pub sse_broadcast: Option<broadcast::Sender<AppEvent>>,
/// 项目记忆管理器(跨会话持久化)
@ -82,6 +85,9 @@ pub struct AppState {
/// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理)
pub sessions:
Arc<std::sync::Mutex<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>,
/// 登录速率限制器IP -> 最近失败次数 + 首次失败时间)
pub login_rate_limiter:
Arc<std::sync::Mutex<std::collections::HashMap<String, (u32, std::time::Instant)>>>,
}
// 统一标准化的文献格式,用于向前端传输
@ -147,7 +153,9 @@ pub mod handlers {
PaperDetailResponse, ParseRequest, ParseResponse, SearchParams, TranslateRequest,
TranslateResponse,
};
pub use super::permissions::{update_permission_mode, update_permission_rules};
pub use super::permissions::{
list_permission_rules, update_permission_mode, update_permission_rules,
};
pub use super::search::{search as search_history, SearchParams as SearchHistoryParams};
pub use super::sync::{
delete_sync_query, get_asset_batch_status, get_meta_sync_count, get_meta_sync_status,

View File

@ -6,14 +6,14 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::fs;
use std::sync::Arc;
use tracing::{error, info};
use tokio::fs;
use tracing::{error, info, warn};
use super::error::{ApiResult, AppError};
use super::helpers::{
check_paper_paths_in_db, convert_ads_doc_to_standard, get_paper_from_db, save_paper_to_db,
SQLITE_PARAM_LIMIT,
check_paper_paths_in_db, convert_ads_doc_to_standard, get_paper_from_db, parse_paper_row,
save_paper_to_db, SQLITE_PARAM_LIMIT,
};
use super::{AppState, StandardPaper};
@ -158,7 +158,7 @@ pub async fn translate_paper(
if let Some(tr_rel) = tr_opt {
let tr_abs = state.config.library_dir.join(&tr_rel);
if tr_abs.exists() {
if let Ok(content) = fs::read_to_string(&tr_abs) {
if let Ok(content) = fs::read_to_string(&tr_abs).await {
return Ok(Json(TranslateResponse {
translation: content,
}));
@ -187,7 +187,7 @@ pub async fn translate_paper(
return Err(AppError::bad_request("解析 Markdown 文件丢失"));
}
let english_markdown = fs::read_to_string(&md_abs).map_err(|e| {
let english_markdown = fs::read_to_string(&md_abs).await.map_err(|e| {
error!("文献 {} 翻译失败:读取解析内容失败: {}", req.bibcode, e);
AppError::internal(format!("读取解析内容失败: {}", e))
})?;
@ -253,23 +253,31 @@ pub async fn get_citation_network(
if let Some(doc) = docs.first() {
let standard_paper = convert_ads_doc_to_standard(doc);
// 保存至数据库缓存,并保存引用关联
let _ = save_paper_to_db(&state.db, &standard_paper).await;
if let Err(e) = save_paper_to_db(&state.db, &standard_paper).await {
warn!("保存引用文献至数据库失败: {}", e);
}
if let Some(refs) = &doc.reference {
for ref_bib in refs {
let _ = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
if let Err(e) = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
.bind(&standard_paper.bibcode)
.bind(ref_bib)
.execute(&state.db)
.await;
.await
{
warn!("保存引用关系失败 ({} -> {}): {}", standard_paper.bibcode, ref_bib, e);
}
}
}
if let Some(cits) = &doc.citation {
for cit_bib in cits {
let _ = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
if let Err(e) = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
.bind(cit_bib)
.bind(&standard_paper.bibcode)
.execute(&state.db)
.await;
.await
{
warn!("保存被引关系失败 ({} -> {}): {}", cit_bib, standard_paper.bibcode, e);
}
}
}
standard_paper
@ -299,7 +307,10 @@ pub async fn get_citation_network(
.bind(&params.bibcode)
.fetch_all(&state.db)
.await
.unwrap_or_default();
.unwrap_or_else(|e| {
warn!("查询引用关系失败: {}", e);
Vec::new()
});
let references: Vec<String> = refs_rows.iter().map(|row| row.get(0)).collect();
// 加载被引用的文献
@ -308,7 +319,10 @@ pub async fn get_citation_network(
.bind(&params.bibcode)
.fetch_all(&state.db)
.await
.unwrap_or_default();
.unwrap_or_else(|e| {
warn!("查询被引关系失败: {}", e);
Vec::new()
});
let citations: Vec<String> = cits_rows.iter().map(|row| row.get(0)).collect();
// 加载关联文献的被引数量 (从 SQLite papers 表获取)
@ -336,7 +350,10 @@ pub async fn get_citation_network(
for b in chunk {
query = query.bind(b);
}
let rows = query.fetch_all(&state.db).await.unwrap_or_default();
let rows = query.fetch_all(&state.db).await.unwrap_or_else(|e| {
warn!("批量查询文献引用数失败: {}", e);
Vec::new()
});
for row in rows {
let bib: String = row.get(0);
let count: i32 = row.get(1);
@ -378,10 +395,18 @@ pub async fn get_paper_detail(
.map_err(|e| AppError::internal(e.to_string()))?
.unwrap_or_default();
let english_content =
md_opt.and_then(|rel| fs::read_to_string(state.config.library_dir.join(rel)).ok());
let translation_content =
tr_opt.and_then(|rel| fs::read_to_string(state.config.library_dir.join(rel)).ok());
let english_content = match md_opt {
Some(rel) => fs::read_to_string(state.config.library_dir.join(rel))
.await
.ok(),
None => None,
};
let translation_content = match tr_opt {
Some(rel) => fs::read_to_string(state.config.library_dir.join(rel))
.await
.ok(),
None => None,
};
Ok(Json(PaperDetailResponse {
paper,
@ -402,68 +427,7 @@ pub async fn get_library(
let mut list = Vec::new();
for r in rows {
let pdf_path: Option<String> = r.get(11);
let html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13);
let translation_path: Option<String> = r.get(14);
let doctype_val: Option<String> = r.get(15);
let has_vector: bool = r.get(16);
let authors_str: Option<String> = r.get(2);
let authors: Vec<String> = authors_str
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let keywords_str: Option<String> = r.get(5);
let keywords: Vec<String> = keywords_str
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let is_pdf_exist = pdf_path
.as_ref()
.map(|p| state.config.library_dir.join(p).exists())
.unwrap_or(false);
let is_html_exist = html_path
.as_ref()
.map(|p| state.config.library_dir.join(p).exists())
.unwrap_or(false);
let pdf_error = pdf_path
.as_ref()
.filter(|p| p.starts_with("error:"))
.map(|p| p["error:".len()..].trim().to_string());
let html_error = html_path
.as_ref()
.filter(|p| p.starts_with("error:"))
.map(|p| p["error:".len()..].trim().to_string());
list.push(StandardPaper {
bibcode: r.get(0),
title: r.get(1),
authors,
year: r.get(3),
pub_journal: r.get(4),
keywords,
abstract_text: r.get(6),
doi: r.get(7),
arxiv_id: r.get(8),
citation_count: r.get(9),
reference_count: r.get(10),
is_downloaded: is_pdf_exist || is_html_exist,
has_pdf: is_pdf_exist,
has_html: is_html_exist,
has_markdown: markdown_path
.as_ref()
.map(|p| state.config.library_dir.join(p).exists())
.unwrap_or(false),
has_translation: translation_path
.as_ref()
.map(|p| state.config.library_dir.join(p).exists())
.unwrap_or(false),
has_vector,
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
pdf_error,
html_error,
});
list.push(parse_paper_row(&r, &state.config.library_dir));
}
Ok(Json(list))
@ -534,6 +498,16 @@ pub async fn upload_paper_file(
if bibcode.is_empty() {
return Err(AppError::bad_request("缺少 bibcode 参数"));
}
// 校验 bibcode 字符安全:仅允许字母、数字、点号、冒号、连接符、斜杠
// 防止路径遍历攻击(如 "../../etc/passwd"
if !bibcode
.chars()
.all(|c| c.is_ascii_alphanumeric() || ".:/-_".contains(c))
{
return Err(AppError::bad_request("bibcode 包含非法字符"));
}
if file_bytes.is_empty() {
return Err(AppError::bad_request("上传文件为空或读取失败"));
}
@ -757,6 +731,7 @@ pub async fn embed_paper(
}
let markdown_content = fs::read_to_string(&md_abs)
.await
.map_err(|e| AppError::internal(format!("读取 Markdown 文件失败: {}", e)))?;
let chunk_count = crate::services::rag::ingest_paper(

View File

@ -105,7 +105,10 @@ pub async fn update_permission_rules(
},
};
if let Ok(mut checker) = state.session_permission_checker.write() {
if let Ok(mut checkers) = state.session_permission_checkers.write() {
let checker = checkers
.entry(session_id.clone())
.or_insert_with(PermissionChecker::new);
checker.add_rule_dynamic(session_rule);
Json(PermissionActionResponse {
success: true,
@ -119,8 +122,12 @@ pub async fn update_permission_rules(
}
}
"remove" => {
if let Ok(mut checker) = state.session_permission_checker.write() {
let removed = checker.remove_rule_dynamic(&req.rule, &req.kind);
if let Ok(mut checkers) = state.session_permission_checkers.write() {
let removed = if let Some(checker) = checkers.get_mut(&session_id) {
checker.remove_rule_dynamic(&req.rule, &req.kind)
} else {
0
};
Json(PermissionActionResponse {
success: removed > 0,
message: if removed > 0 {
@ -157,12 +164,19 @@ pub async fn update_permission_mode(
session_id, mode_str
);
if let Ok(mut checker) = state.session_permission_checker.write() {
checker.set_mode(mode);
Json(PermissionActionResponse {
success: true,
message: format!("权限模式已切换为: {}", mode_str),
})
if let Ok(mut checkers) = state.session_permission_checkers.write() {
if let Some(checker) = checkers.get_mut(&session_id) {
checker.set_mode(mode);
Json(PermissionActionResponse {
success: true,
message: format!("权限模式已切换为: {}", mode_str),
})
} else {
Json(PermissionActionResponse {
success: false,
message: format!("会话 {} 不存在或无活跃 Agent", session_id),
})
}
} else {
Json(PermissionActionResponse {
success: false,
@ -171,16 +185,56 @@ pub async fn update_permission_mode(
}
}
/// 列出当前会话的所有权限规则。
/// 列出指定会话的所有权限规则。
pub async fn list_permission_rules(
State(_state): State<Arc<AppState>>,
Path(session_id): Path<String>,
State(state): State<Arc<AppState>>,
) -> Json<PermissionRulesResponse> {
// Read-only: return an empty snapshot (the checker doesn't expose its rules list directly).
// For now, return empty — future enhancement can add a snapshot method.
Json(PermissionRulesResponse {
deny_rules: Vec::new(),
allow_rules: Vec::new(),
ask_rules: Vec::new(),
mode: "default".to_string(),
})
let checkers = match state.session_permission_checkers.read() {
Ok(c) => c,
Err(_) => {
return Json(PermissionRulesResponse {
deny_rules: Vec::new(),
allow_rules: Vec::new(),
ask_rules: Vec::new(),
mode: "default".to_string(),
});
}
};
if let Some(checker) = checkers.get(&session_id) {
let mut deny_rules = Vec::new();
let mut allow_rules = Vec::new();
let mut ask_rules = Vec::new();
// 通过 explain 方法获取规则信息PermissionChecker 不直接暴露规则列表)
// 这里用工具名列表做近似展示
for rule in checker.rules() {
match rule {
crate::agent::tools::PermissionRule::Deny { tool_name, .. } => {
deny_rules.push(tool_name.clone());
}
crate::agent::tools::PermissionRule::Allow { tool_name, .. } => {
allow_rules.push(tool_name.clone());
}
crate::agent::tools::PermissionRule::Ask { tool_name, .. } => {
ask_rules.push(tool_name.clone());
}
}
}
Json(PermissionRulesResponse {
deny_rules,
allow_rules,
ask_rules,
mode: format!("{:?}", checker.mode()),
})
} else {
Json(PermissionRulesResponse {
deny_rules: Vec::new(),
allow_rules: Vec::new(),
ask_rules: Vec::new(),
mode: "default".to_string(),
})
}
}

View File

@ -190,7 +190,8 @@ pub async fn extract_paper_targets(
return Err(AppError::not_found("文献 Markdown 文件未找到,请重新解析"));
}
let markdown_content = std::fs::read_to_string(&md_abs)
let markdown_content = tokio::fs::read_to_string(&md_abs)
.await
.map_err(|e| AppError::internal(format!("读取 Markdown 文件失败: {}", e)))?;
// 重新识别前先清除该文献已有的天体关联记录,确保陈旧和错误绑定的天体得到重置与刷新

View File

@ -237,10 +237,9 @@ async fn main() -> anyhow::Result<()> {
memory_manager: Arc::new(tokio::sync::Mutex::new(
astroresearch::agent::memory::MemoryManager::new(config.library_dir.clone()),
)),
session_permission_checker: Arc::new(RwLock::new(
astroresearch::agent::runtime::permission::PermissionChecker::new(),
)),
session_permission_checkers: Arc::new(RwLock::new(std::collections::HashMap::new())),
sessions: Arc::new(Mutex::new(std::collections::HashMap::new())),
login_rate_limiter: Arc::new(Mutex::new(std::collections::HashMap::new())),
});
// 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管
@ -364,7 +363,7 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/chat/sessions/:id/permissions/rules",
post(handlers::update_permission_rules),
get(handlers::list_permission_rules).post(handlers::update_permission_rules),
)
.route(
"/chat/sessions/:id/permissions/mode",
@ -408,7 +407,21 @@ async fn main() -> anyhow::Result<()> {
info!("天文学科研服务已成功监听 http://localhost:{}", config.port);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
let server = axum::serve(listener, app);
// 优雅关停:监听 SIGTERM / Ctrl+C
let shutdown_signal = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
info!("收到关停信号,正在优雅关闭...");
};
server
.with_graceful_shutdown(shutdown_signal)
.await
.expect("Server error");
info!("服务已安全关闭。");
Ok(())
}

View File

@ -60,6 +60,13 @@ impl JournalParser for Ar5ivParser {
.replace_all(&h, "^{$1}")
.to_string();
// <sub id="..." class="ltx_sub">content</sub> → <sub>content</sub>
// 剥离属性,使下游 convert_html_math_to_latex 的正则能匹配
h = Regex::new(r#"<sub\s+[^>]*class="[^"]*ltx_sub[^"]*"[^>]*>"#)
.unwrap()
.replace_all(&h, "<sub>")
.to_string();
// <a class="ltx_ref" href="...">text</a> → [text](url),供后续内部链接剥离
// 匹配 class 在 href 之前或之后两种顺序
for pat in &[

View File

@ -141,15 +141,20 @@ async fn save_citation_topology(
for chunk in pairs.chunks(SQLITE_PARAM_LIMIT / 2) {
if let Ok(mut tx) = db.begin().await {
for (src, tgt) in chunk {
let _ = sqlx::query(
if let Err(e) = sqlx::query(
"INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)",
)
.bind(src)
.bind(tgt)
.execute(&mut *tx)
.await;
.await
{
warn!("保存引用关系失败 ({} -> {}): {}", src, tgt, e);
}
}
if let Err(e) = tx.commit().await {
warn!("提交引用关系事务失败: {}", e);
}
let _ = tx.commit().await;
}
}
}

View File

@ -535,7 +535,9 @@ pub async fn extract_and_cache_targets(
error!("天体信息写入缓存失败: {}", e);
}
}
let _ = tx.commit().await;
if let Err(e) = tx.commit().await {
error!("提交天体信息缓存事务失败: {}", e);
}
}
}