refactor: 全栈质量硬化

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,5 +1,5 @@
// dashboard/src/App.tsx // dashboard/src/App.tsx
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios'; import axios from 'axios';
import { Loader, BookOpen, GitFork, Lock } from 'lucide-react'; import { Loader, BookOpen, GitFork, Lock } from 'lucide-react';
import { Sidebar } from './components/layout/Sidebar'; import { Sidebar } from './components/layout/Sidebar';
@ -13,6 +13,8 @@ import type { StandardPaper, NoteRecord } from './types';
import { GlobalDialog } from './components/dialogs/GlobalDialog'; import { GlobalDialog } from './components/dialogs/GlobalDialog';
import { UncachedPaperModal } from './components/dialogs/UncachedPaperModal'; import { UncachedPaperModal } from './components/dialogs/UncachedPaperModal';
import { PaperDetailModal } from './components/dialogs/PaperDetailModal'; import { PaperDetailModal } from './components/dialogs/PaperDetailModal';
import { Logo } from './components/Logo';
import { ErrorBoundary } from './components/ErrorBoundary';
// 引入拆分出的业务逻辑 Hooks // 引入拆分出的业务逻辑 Hooks
import { useAuth } from './hooks/useAuth'; import { useAuth } from './hooks/useAuth';
@ -52,7 +54,8 @@ export default function App() {
// 2. 局部状态定义 (多组件共用或全局网络进度状态) // 2. 局部状态定义 (多组件共用或全局网络进度状态)
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => { const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => {
const saved = localStorage.getItem('astro_active_tab'); 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(''); const [englishText, setEnglishText] = useState('');
@ -71,10 +74,14 @@ export default function App() {
const citations = useCitations(); const citations = useCitations();
// 协调:进入阅读器方法 (需要拉取正文和笔记) // 协调:进入阅读器方法 (需要拉取正文和笔记)
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
const openReader = useCallback(async (paper: StandardPaper, skipTabSwitch = false) => { 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); localStorage.setItem('last_read_bibcode', paper.bibcode);
library.addPaperToRecent(paper); lib.addPaperToRecent(paper);
setEnglishText(''); setEnglishText('');
setChineseText(''); setChineseText('');
@ -116,6 +123,8 @@ export default function App() {
openReader, openReader,
}); });
useEffect(() => { libraryRef.current = library; });
const search = useSearch({ showAlert }); const search = useSearch({ showAlert });
// 协调:从 RAG 问答结果跳转至目标文献段落并高亮 // 协调:从 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="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="flex flex-col items-center text-center mb-7 select-none">
<div className="w-14 h-14 mb-3"> <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"> <Logo gradientId="loginStarGrad" />
<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>
</div> </div>
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">AstroResearch</h2> <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> <p className="text-[10px] text-[#5c6b84] font-medium tracking-wide"> · </p>
@ -330,7 +326,7 @@ export default function App() {
<div className={`w-full flex-1 flex flex-col min-h-0 ${ <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 === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
}`}> }`}>
<ErrorBoundary>
{activeTab === 'search' && ( {activeTab === 'search' && (
<SearchPanel <SearchPanel
searchQuery={search.searchQuery} searchQuery={search.searchQuery}
@ -413,7 +409,7 @@ export default function App() {
</div> </div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3> <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 className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
"馆藏管理"
</p> </p>
<button <button
onClick={() => setActiveTab('library')} onClick={() => setActiveTab('library')}
@ -445,7 +441,7 @@ export default function App() {
</div> </div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3> <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 className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
- "引用 - 被引""馆藏管理"
</p> </p>
<button <button
onClick={() => setActiveTab('library')} onClick={() => setActiveTab('library')}
@ -467,6 +463,7 @@ export default function App() {
showAlert={showAlert} showAlert={showAlert}
/> />
)} )}
</ErrorBoundary>
</div> </div>
</div> </div>
</main> </main>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -90,7 +90,20 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
}; };
useEffect(() => { 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 // 提取工具调用排行取前10

View File

@ -100,11 +100,12 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
return next; return next;
}); });
onAnswered?.(); onAnswered?.();
} catch (e: any) { } catch (e: unknown) {
console.error('提交答案失败:', e); 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 })); setError(prev => ({ ...prev, [questionId]: msg }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,10 +2,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react'; import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react';
import type { StandardPaper } from '../../types'; import type { StandardPaper } from '../../types';
import { Logo } from '../Logo';
export type TabId = 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
interface SidebarProps { interface SidebarProps {
activeTab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'; activeTab: TabId;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void; setActiveTab: (tab: TabId) => void;
selectedPaper: StandardPaper | null; selectedPaper: StandardPaper | null;
loadCitations: (bibcode: string) => void; loadCitations: (bibcode: string) => void;
onLogout: () => void; onLogout: () => void;
@ -14,23 +17,6 @@ interface SidebarProps {
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) { export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false); 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 ( return (
<aside <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) ${ 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} title={isCollapsed ? "展开导航" : undefined}
> >
<div className={`transition-all duration-300 flex items-center justify-center ${isCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}> <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> </div>
</button> </button>
@ -92,22 +78,21 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
{/* 导航菜单列表 */} {/* 导航菜单列表 */}
<nav className="space-y-1"> <nav className="space-y-1">
{[ {[
{ id: 'search', label: '统一检索', icon: Search }, { id: 'search' as TabId, label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library }, { id: 'library' as TabId, label: '馆藏管理', icon: Library },
{ id: 'reader', label: '双语阅读', icon: BookOpen }, { id: 'reader' as TabId, label: '双语阅读', icon: BookOpen },
{ id: 'citation', label: '引用星系', icon: GitFork }, { id: 'citation' as TabId, label: '引用星系', icon: GitFork },
{ id: 'sync', label: '批量任务', icon: RefreshCw }, { id: 'sync' as TabId, label: '批量任务', icon: RefreshCw },
{ id: 'agent', label: '智能科研', icon: Sparkles }, { id: 'agent' as TabId, label: '智能科研', icon: Sparkles },
].map((tab: { id: string; label: string; icon: any; disabled?: boolean }) => { ].map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const isActive = activeTab === tab.id; const isActive = activeTab === tab.id;
return ( return (
<button <button
key={tab.id} key={tab.id}
disabled={tab.disabled}
title={isCollapsed ? tab.label : undefined} title={isCollapsed ? tab.label : undefined}
onClick={() => { onClick={() => {
setActiveTab(tab.id as any); setActiveTab(tab.id);
if (tab.id === 'citation' && selectedPaper) { if (tab.id === 'citation' && selectedPaper) {
loadCitations(selectedPaper.bibcode); loadCitations(selectedPaper.bibcode);
} }
@ -117,8 +102,6 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
} ${ } ${
isActive isActive
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs' ? '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' : 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`} }`}
> >

View File

@ -12,6 +12,7 @@ import type { StandardPaper, NoteRecord } from '../../types';
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState'; import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial'; import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial';
import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper'; import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper';
import { splitParagraphs } from '../../hooks/useSyncScroll';
interface BilingualViewerProps { interface BilingualViewerProps {
selectedPaper: StandardPaper; selectedPaper: StandardPaper;
@ -44,11 +45,11 @@ interface BilingualViewerProps {
/** /**
* Metadata Dossier * 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; if (!metadata || (!metadata.title && !metadata.author)) return null;
return ( 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" /> <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"> <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 matchedNote = notes.find(n => n.paragraph_index === idx);
const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null; const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null;
return ( 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"> <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 <div
key={idx} key={idx}
id={`paragraph-block-${idx}`} id={`paragraph-block-${idx}`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
// dashboard/src/features/reader/ReaderPanel.tsx // dashboard/src/features/reader/ReaderPanel.tsx
import { useState } from 'react'; import { useState, useEffect } from 'react';
import type { StandardPaper, NoteRecord } from '../types'; import type { StandardPaper, NoteRecord } from '../types';
import { useReaderState } from '../hooks/useReaderState'; import { useReaderState } from '../hooks/useReaderState';
import { ReaderToolbar } from '../components/reader/ReaderToolbar'; import { ReaderToolbar } from '../components/reader/ReaderToolbar';
@ -74,15 +74,39 @@ export function ReaderPanel(props: ReaderPanelProps) {
if (typeof window !== 'undefined' && window.innerWidth < 768) { if (typeof window !== 'undefined' && window.innerWidth < 768) {
return 'english'; return 'english';
} }
if (!chineseText) {
return 'english';
}
return 'bilingual'; 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 // 调用封装后的阅读器逻辑状态 Hook
const state = useReaderState({ const state = useReaderState({
selectedPaper, selectedPaper,
englishText, englishText,
viewMode, viewMode,
setViewMode, setViewMode: handleViewModeChange,
}); });
return ( return (
@ -108,7 +132,7 @@ export function ReaderPanel(props: ReaderPanelProps) {
showSwitchMenu={state.showSwitchMenu} showSwitchMenu={state.showSwitchMenu}
setShowSwitchMenu={state.setShowSwitchMenu} setShowSwitchMenu={state.setShowSwitchMenu}
viewMode={viewMode} viewMode={viewMode}
setViewMode={setViewMode} setViewMode={handleViewModeChange}
syncScroll={state.syncScroll} syncScroll={state.syncScroll}
setSyncScroll={state.setSyncScroll} setSyncScroll={state.setSyncScroll}
showPdf={state.showPdf} showPdf={state.showPdf}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -129,7 +129,7 @@ pub async fn execute_parallel(
app_state: Arc<AppState>, app_state: Arc<AppState>,
hook_registry: &HookRegistry, hook_registry: &HookRegistry,
permission_checker: Option<&PermissionChecker>, permission_checker: Option<&PermissionChecker>,
session_permission_checker: Option<&std::sync::RwLock<PermissionChecker>>, session_permission_checker: Option<&PermissionChecker>,
denial_tracker: Option<&std::sync::Mutex<DenialTracker>>, denial_tracker: Option<&std::sync::Mutex<DenialTracker>>,
checkpoint_manager: Option<&std::sync::Arc<CheckpointManager>>, checkpoint_manager: Option<&std::sync::Arc<CheckpointManager>>,
tx: &mpsc::UnboundedSender<AgentStreamEvent>, tx: &mpsc::UnboundedSender<AgentStreamEvent>,
@ -336,8 +336,7 @@ pub async fn execute_parallel(
// 会话级权限检查API 动态添加的规则,优先级高于环境变量规则) // 会话级权限检查API 动态添加的规则,优先级高于环境变量规则)
if let Some(session_checker) = session_permission_checker { if let Some(session_checker) = session_permission_checker {
if let Ok(checker) = session_checker.read() { let session_result = session_checker.check(&prep.tool_name, Some(&prep.args));
let session_result = checker.check(&prep.tool_name, Some(&prep.args));
// 会话规则结果覆盖或升级 // 会话规则结果覆盖或升级
match session_result { match session_result {
PermissionResult::Denied { reason } => { PermissionResult::Denied { reason } => {
@ -356,7 +355,6 @@ pub async fn execute_parallel(
} }
} }
} }
}
match perm_result { match perm_result {
PermissionResult::Denied { reason } => { PermissionResult::Denied { reason } => {

View File

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

View File

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

View File

@ -600,12 +600,13 @@ mod tests {
skill_registry: Arc::new(RwLock::new(SkillRegistry::new(PathBuf::from("/tmp/sk")))), skill_registry: Arc::new(RwLock::new(SkillRegistry::new(PathBuf::from("/tmp/sk")))),
pending_questions: Arc::new(Mutex::new(std::collections::HashMap::new())), pending_questions: Arc::new(Mutex::new(std::collections::HashMap::new())),
pending_permissions: 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, sse_broadcast: None,
memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from( memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from(
"/tmp/test_mem", "/tmp/test_mem",
)))), )))),
sessions: Arc::new(Mutex::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())),
}); });
ToolContext::new(app_state) ToolContext::new(app_state)

View File

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

View File

@ -193,9 +193,22 @@ pub async fn chat_agent(
} }
}); });
// 将 mpsc 通道转换为 SSE 事件流 // 将 mpsc 通道转换为 SSE 事件流(带 10 分钟超时)
const SSE_TIMEOUT_SECS: u64 = 600;
let stream = async_stream::stream! { let stream = async_stream::stream! {
while let Some(event) = rx.recv().await { 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 data = serde_json::to_string(&event).unwrap_or_default();
let is_done = matches!(event, AgentStreamEvent::Done); let is_done = matches!(event, AgentStreamEvent::Done);
yield Ok(Event::default().data(data)); yield Ok(Event::default().data(data));
@ -203,6 +216,17 @@ pub async fn chat_agent(
break; 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;
}
}
}
}; };
Ok(Sse::new(stream)) Ok(Sse::new(stream))

View File

@ -19,7 +19,6 @@ pub struct LoginRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct LoginResponse { pub struct LoginResponse {
pub token: String,
pub status: String, pub status: String,
} }
@ -93,10 +92,46 @@ fn prune_expired_sessions(
// 登录接口:验证密码成功后,写入 HttpOnly Cookie // 登录接口:验证密码成功后,写入 HttpOnly Cookie
pub async fn login( pub async fn login(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<LoginRequest>, headers: axum::http::header::HeaderMap,
Json(body): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> { ) -> 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) { 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(); let token = uuid::Uuid::new_v4().to_string();
// 异常安全地记录活跃会话及其当前时间 // 异常安全地记录活跃会话及其当前时间
@ -119,11 +154,18 @@ pub async fn login(
Ok(( Ok((
headers, headers,
Json(LoginResponse { Json(LoginResponse {
token: token.clone(),
status: "ok".to_string(), status: "ok".to_string(),
}), }),
)) ))
} else { } 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("密码错误")) Err(AppError::unauthorized("密码错误"))
} }
} }

View File

@ -186,33 +186,16 @@ pub async fn save_paper_to_db_tx(
Ok(()) Ok(())
} }
pub async fn get_paper_from_db( /// 从数据库行解析出 StandardPaper消除 get_paper_from_db 和 get_library 之间的重复代码)
db: &SqlitePool, ///
/// 期望行的列顺序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, library_dir: &std::path::Path,
identifier: &str, ) -> StandardPaper {
) -> 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?;
let pdf_path: Option<String> = r.get(11); let pdf_path: Option<String> = r.get(11);
let html_path: Option<String> = r.get(12); let html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13); 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:")) .filter(|p| p.starts_with("error:"))
.map(|p| p["error:".len()..].trim().to_string()); .map(|p| p["error:".len()..].trim().to_string());
Ok(StandardPaper { StandardPaper {
bibcode: r.get(0), bibcode: r.get(0),
title: r.get(1), title: r.get(1),
authors, authors,
@ -276,7 +259,37 @@ pub async fn get_paper_from_db(
doctype: doctype_val.unwrap_or_else(|| "article".to_string()), doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
pdf_error, pdf_error,
html_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( pub async fn check_paper_paths_in_db(

View File

@ -72,9 +72,12 @@ pub struct AppState {
pub pending_questions: Arc<Mutex<HashMap<String, PendingQuestion>>>, pub pending_questions: Arc<Mutex<HashMap<String, PendingQuestion>>>,
/// 权限检查 — 待处理的权限确认请求 /// 权限检查 — 待处理的权限确认请求
pub pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>, pub pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
/// 会话级权限检查器(支持 API 动态添加/移除规则,跨 turn 共享) /// 会话级权限检查器注册表(按 session_id 隔离,支持 API 动态添加/移除规则)
pub session_permission_checker: pub session_permission_checkers: Arc<
Arc<RwLock<crate::agent::runtime::permission::PermissionChecker>>, RwLock<
std::collections::HashMap<String, crate::agent::runtime::permission::PermissionChecker>,
>,
>,
/// SSE 广播通道agent 运行时向所有连接的客户端推送事件) /// SSE 广播通道agent 运行时向所有连接的客户端推送事件)
pub sse_broadcast: Option<broadcast::Sender<AppEvent>>, pub sse_broadcast: Option<broadcast::Sender<AppEvent>>,
/// 项目记忆管理器(跨会话持久化) /// 项目记忆管理器(跨会话持久化)
@ -82,6 +85,9 @@ pub struct AppState {
/// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理) /// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理)
pub sessions: pub sessions:
Arc<std::sync::Mutex<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>, 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, PaperDetailResponse, ParseRequest, ParseResponse, SearchParams, TranslateRequest,
TranslateResponse, 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::search::{search as search_history, SearchParams as SearchHistoryParams};
pub use super::sync::{ pub use super::sync::{
delete_sync_query, get_asset_batch_status, get_meta_sync_count, get_meta_sync_status, delete_sync_query, get_asset_batch_status, get_meta_sync_count, get_meta_sync_status,

View File

@ -6,14 +6,14 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use std::fs;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info}; use tokio::fs;
use tracing::{error, info, warn};
use super::error::{ApiResult, AppError}; use super::error::{ApiResult, AppError};
use super::helpers::{ use super::helpers::{
check_paper_paths_in_db, convert_ads_doc_to_standard, get_paper_from_db, save_paper_to_db, check_paper_paths_in_db, convert_ads_doc_to_standard, get_paper_from_db, parse_paper_row,
SQLITE_PARAM_LIMIT, save_paper_to_db, SQLITE_PARAM_LIMIT,
}; };
use super::{AppState, StandardPaper}; use super::{AppState, StandardPaper};
@ -158,7 +158,7 @@ pub async fn translate_paper(
if let Some(tr_rel) = tr_opt { if let Some(tr_rel) = tr_opt {
let tr_abs = state.config.library_dir.join(&tr_rel); let tr_abs = state.config.library_dir.join(&tr_rel);
if tr_abs.exists() { 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 { return Ok(Json(TranslateResponse {
translation: content, translation: content,
})); }));
@ -187,7 +187,7 @@ pub async fn translate_paper(
return Err(AppError::bad_request("解析 Markdown 文件丢失")); 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); error!("文献 {} 翻译失败:读取解析内容失败: {}", req.bibcode, e);
AppError::internal(format!("读取解析内容失败: {}", e)) AppError::internal(format!("读取解析内容失败: {}", e))
})?; })?;
@ -253,23 +253,31 @@ pub async fn get_citation_network(
if let Some(doc) = docs.first() { if let Some(doc) = docs.first() {
let standard_paper = convert_ads_doc_to_standard(doc); 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 { if let Some(refs) = &doc.reference {
for ref_bib in refs { 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(&standard_paper.bibcode)
.bind(ref_bib) .bind(ref_bib)
.execute(&state.db) .execute(&state.db)
.await; .await
{
warn!("保存引用关系失败 ({} -> {}): {}", standard_paper.bibcode, ref_bib, e);
}
} }
} }
if let Some(cits) = &doc.citation { if let Some(cits) = &doc.citation {
for cit_bib in cits { 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(cit_bib)
.bind(&standard_paper.bibcode) .bind(&standard_paper.bibcode)
.execute(&state.db) .execute(&state.db)
.await; .await
{
warn!("保存被引关系失败 ({} -> {}): {}", cit_bib, standard_paper.bibcode, e);
}
} }
} }
standard_paper standard_paper
@ -299,7 +307,10 @@ pub async fn get_citation_network(
.bind(&params.bibcode) .bind(&params.bibcode)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .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(); let references: Vec<String> = refs_rows.iter().map(|row| row.get(0)).collect();
// 加载被引用的文献 // 加载被引用的文献
@ -308,7 +319,10 @@ pub async fn get_citation_network(
.bind(&params.bibcode) .bind(&params.bibcode)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .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(); let citations: Vec<String> = cits_rows.iter().map(|row| row.get(0)).collect();
// 加载关联文献的被引数量 (从 SQLite papers 表获取) // 加载关联文献的被引数量 (从 SQLite papers 表获取)
@ -336,7 +350,10 @@ pub async fn get_citation_network(
for b in chunk { for b in chunk {
query = query.bind(b); 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 { for row in rows {
let bib: String = row.get(0); let bib: String = row.get(0);
let count: i32 = row.get(1); let count: i32 = row.get(1);
@ -378,10 +395,18 @@ pub async fn get_paper_detail(
.map_err(|e| AppError::internal(e.to_string()))? .map_err(|e| AppError::internal(e.to_string()))?
.unwrap_or_default(); .unwrap_or_default();
let english_content = let english_content = match md_opt {
md_opt.and_then(|rel| fs::read_to_string(state.config.library_dir.join(rel)).ok()); Some(rel) => fs::read_to_string(state.config.library_dir.join(rel))
let translation_content = .await
tr_opt.and_then(|rel| fs::read_to_string(state.config.library_dir.join(rel)).ok()); .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 { Ok(Json(PaperDetailResponse {
paper, paper,
@ -402,68 +427,7 @@ pub async fn get_library(
let mut list = Vec::new(); let mut list = Vec::new();
for r in rows { for r in rows {
let pdf_path: Option<String> = r.get(11); list.push(parse_paper_row(&r, &state.config.library_dir));
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,
});
} }
Ok(Json(list)) Ok(Json(list))
@ -534,6 +498,16 @@ pub async fn upload_paper_file(
if bibcode.is_empty() { if bibcode.is_empty() {
return Err(AppError::bad_request("缺少 bibcode 参数")); 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() { if file_bytes.is_empty() {
return Err(AppError::bad_request("上传文件为空或读取失败")); return Err(AppError::bad_request("上传文件为空或读取失败"));
} }
@ -757,6 +731,7 @@ pub async fn embed_paper(
} }
let markdown_content = fs::read_to_string(&md_abs) let markdown_content = fs::read_to_string(&md_abs)
.await
.map_err(|e| AppError::internal(format!("读取 Markdown 文件失败: {}", e)))?; .map_err(|e| AppError::internal(format!("读取 Markdown 文件失败: {}", e)))?;
let chunk_count = crate::services::rag::ingest_paper( let chunk_count = crate::services::rag::ingest_paper(

View File

@ -105,7 +105,10 @@ pub async fn update_permission_rules(
}, },
}; };
if let Ok(mut checker) = state.session_permission_checker.write() { if let Ok(mut checkers) = state.session_permission_checkers.write() {
let checker = checkers
.entry(session_id.clone())
.or_insert_with(PermissionChecker::new);
checker.add_rule_dynamic(session_rule); checker.add_rule_dynamic(session_rule);
Json(PermissionActionResponse { Json(PermissionActionResponse {
success: true, success: true,
@ -119,8 +122,12 @@ pub async fn update_permission_rules(
} }
} }
"remove" => { "remove" => {
if let Ok(mut checker) = state.session_permission_checker.write() { if let Ok(mut checkers) = state.session_permission_checkers.write() {
let removed = checker.remove_rule_dynamic(&req.rule, &req.kind); let removed = if let Some(checker) = checkers.get_mut(&session_id) {
checker.remove_rule_dynamic(&req.rule, &req.kind)
} else {
0
};
Json(PermissionActionResponse { Json(PermissionActionResponse {
success: removed > 0, success: removed > 0,
message: if removed > 0 { message: if removed > 0 {
@ -157,12 +164,19 @@ pub async fn update_permission_mode(
session_id, mode_str session_id, mode_str
); );
if let Ok(mut checker) = state.session_permission_checker.write() { if let Ok(mut checkers) = state.session_permission_checkers.write() {
if let Some(checker) = checkers.get_mut(&session_id) {
checker.set_mode(mode); checker.set_mode(mode);
Json(PermissionActionResponse { Json(PermissionActionResponse {
success: true, success: true,
message: format!("权限模式已切换为: {}", mode_str), message: format!("权限模式已切换为: {}", mode_str),
}) })
} else {
Json(PermissionActionResponse {
success: false,
message: format!("会话 {} 不存在或无活跃 Agent", session_id),
})
}
} else { } else {
Json(PermissionActionResponse { Json(PermissionActionResponse {
success: false, success: false,
@ -171,16 +185,56 @@ pub async fn update_permission_mode(
} }
} }
/// 列出当前会话的所有权限规则。 /// 列出指定会话的所有权限规则。
pub async fn list_permission_rules( pub async fn list_permission_rules(
State(_state): State<Arc<AppState>>, Path(session_id): Path<String>,
State(state): State<Arc<AppState>>,
) -> Json<PermissionRulesResponse> { ) -> Json<PermissionRulesResponse> {
// Read-only: return an empty snapshot (the checker doesn't expose its rules list directly). let checkers = match state.session_permission_checkers.read() {
// For now, return empty — future enhancement can add a snapshot method. 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 { Json(PermissionRulesResponse {
deny_rules: Vec::new(), deny_rules: Vec::new(),
allow_rules: Vec::new(), allow_rules: Vec::new(),
ask_rules: Vec::new(), ask_rules: Vec::new(),
mode: "default".to_string(), mode: "default".to_string(),
}) })
}
} }

View File

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

View File

@ -237,10 +237,9 @@ async fn main() -> anyhow::Result<()> {
memory_manager: Arc::new(tokio::sync::Mutex::new( memory_manager: Arc::new(tokio::sync::Mutex::new(
astroresearch::agent::memory::MemoryManager::new(config.library_dir.clone()), astroresearch::agent::memory::MemoryManager::new(config.library_dir.clone()),
)), )),
session_permission_checker: Arc::new(RwLock::new( session_permission_checkers: Arc::new(RwLock::new(std::collections::HashMap::new())),
astroresearch::agent::runtime::permission::PermissionChecker::new(),
)),
sessions: Arc::new(Mutex::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 仪表盘静态资源托管 // 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管
@ -364,7 +363,7 @@ async fn main() -> anyhow::Result<()> {
) )
.route( .route(
"/chat/sessions/:id/permissions/rules", "/chat/sessions/:id/permissions/rules",
post(handlers::update_permission_rules), get(handlers::list_permission_rules).post(handlers::update_permission_rules),
) )
.route( .route(
"/chat/sessions/:id/permissions/mode", "/chat/sessions/:id/permissions/mode",
@ -408,7 +407,21 @@ async fn main() -> anyhow::Result<()> {
info!("天文学科研服务已成功监听 http://localhost:{}", config.port); info!("天文学科研服务已成功监听 http://localhost:{}", config.port);
let listener = tokio::net::TcpListener::bind(addr).await?; 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(()) Ok(())
} }

View File

@ -60,6 +60,13 @@ impl JournalParser for Ar5ivParser {
.replace_all(&h, "^{$1}") .replace_all(&h, "^{$1}")
.to_string(); .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),供后续内部链接剥离 // <a class="ltx_ref" href="...">text</a> → [text](url),供后续内部链接剥离
// 匹配 class 在 href 之前或之后两种顺序 // 匹配 class 在 href 之前或之后两种顺序
for pat in &[ for pat in &[

View File

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

View File

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