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:
parent
5f2d2d83f6
commit
c5fd5b0d66
2044
dashboard/package-lock.json
generated
2044
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 |
@ -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 |
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
76
dashboard/src/components/ErrorBoundary.test.tsx
Normal file
76
dashboard/src/components/ErrorBoundary.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
60
dashboard/src/components/ErrorBoundary.tsx
Normal file
60
dashboard/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
23
dashboard/src/components/Logo.tsx
Normal file
23
dashboard/src/components/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'}`} />
|
||||
|
||||
@ -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}`}
|
||||
|
||||
@ -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: '默认(不指定)' },
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`, '操作出错');
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 缓存
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/test/setup.ts
Normal file
1
dashboard/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 链接,则原样返回
|
||||
|
||||
@ -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('"')) {
|
||||
|
||||
12
dashboard/vitest.config.ts
Normal file
12
dashboard/vitest.config.ts
Normal 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}'],
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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("密码错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(¶ms.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(¶ms.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(
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))?;
|
||||
|
||||
// 重新识别前先清除该文献已有的天体关联记录,确保陈旧和错误绑定的天体得到重置与刷新
|
||||
|
||||
23
src/main.rs
23
src/main.rs
@ -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(())
|
||||
}
|
||||
|
||||
@ -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 &[
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user