refactor: 全栈架构重构与质量硬化——API 错误统一、工具域重组、安全加固、前端组件化

后端核心变更:
  - API 层: 新增 AppError 枚举统一错误类型,替代散落的 (StatusCode, String)
  - Agent 工具域: 重组为 astro/system/ 和 astro/research/ 两级域,新增 ProcessPaperTool 流水线工具
  - 安全: 新增 SSRF 双层防护 (同步字符串级 + 异步 DNS 解析级),覆盖 IPv4/IPv6 私网段
  - 弱密码检测: 扩展弱密码列表并增加最小长度检查
  - LLM 客户端: 新增 ChatCompleter/Embedder trait,支持依赖注入与批量向量化 embed_batch
  - 批量处理: AssetBatch 从串行改为 Semaphore 并发池 (BATCH_CONCURRENCY=3)
  - 分块器: 重写为三阶段结构化管线 (章节解析→短节合并→带标题路径子块)
  - RAG: embedding 计算移出事务,RetrievalResult 新增 headings/section_index 字段
  - 检索: ADS/arXiv 并行检索 (tokio::join!),去重改用 HashSet,本地库回填批量 IN 查询
  - 天体查询: Sesame API 升级到 v4,新增视差误差/自行/视向速度/多波段测光字段
  - 迁移: 14 个增量文件合并为单一 init.sql,支持 sqlx::migrate! 内存库集成测试
  - 测试: circuit_breaker/hooks/task_board/session/memory/streaming_executor 新增修正 15+ 测试

  前端架构重构:
  - 目录重组: features/ → pages/ + components/ + hooks/ 三层分离
  - App.tsx 从 1181 行压缩至 ~174 行 (逻辑抽入 9 个自定义 Hook)
  - Agent 面板拆分为 AgentSessionSidebar/AgentMessageList/AgentInputArea 子组件
  - 新增 GlobalDialog/PaperDetailModal/UncachedPaperModal 通用对话框组件
  - 工具函数抽取: celestial.ts (天体坐标格式), paper.tsx (文献信息渲染)
This commit is contained in:
Asfmq 2026-06-25 23:45:37 +08:00
parent b11b8ad015
commit 5db4cc5998
131 changed files with 13934 additions and 7887 deletions

View File

@ -45,6 +45,17 @@ LLM_MODEL=gpt-4o-mini
# 向量维度(需与所选模型输出维度一致,默认 1536
# EMBEDDING_DIM=1536
# ─────────────────────────────────────────────────────────────────────────────
# Chunker 结构化切片参数(影响向量化粒度)
# ─────────────────────────────────────────────────────────────────────────────
# 子章节最大字符数(超出则递归切分),默认 2000
# CHUNKER_MAX_CHILD_CHARS=2000
# 短章节合并阈值(字符数),默认 200
# CHUNKER_SHORT_THRESHOLD=200
# 上下文重叠字符数,默认 100
# CHUNKER_OVERLAP_CHARS=100
# ─────────────────────────────────────────────────────────────────────────────
# 2. 七牛云对象存储PDF 解析后的配图托管)
# ─────────────────────────────────────────────────────────────────────────────

View File

@ -144,15 +144,14 @@ The embedding dimension is controlled by `EMBEDDING_DIM` env var (default 1536).
### Frontend (dashboard/)
React 19 + TypeScript + Vite + Tailwind CSS 4. Features are organized by domain:
React 19 + TypeScript + Vite + Tailwind CSS 4. Organized by technical layer (Type-Based):
- `features/search/` — Cross-source paper search panel
- `features/library/` — Local library management
- `features/reader/` — Bilingual reader with highlight annotations (KaTeX for math)
- `features/citation/` — Canvas-based force-directed citation graph
- `features/sync/` — Batch sync control panel
- `features/agent/` — Agent chat: ResearchAgentPanel (timeline view with thought/tool_call/answer/subagent), AgentMetricsPanel (tool stats), AskUserQuestionCard (interactive Q&A), AuditLogViewer
- `features/settings/` — System configuration
- `pages/` — Page-level panel view components (SearchPanel, LibraryPanel, ReaderPanel, CitationPanel, SyncPanel, ResearchAgentPanel, SettingsPanel)
- `components/` — Reusable and layout components (sub-folders: agent, reader, sync, layout, dialogs)
- `hooks/` — Global and feature-specific custom stateful React Hooks (e.g., useLibrary, useSearch, useNotes)
- `types/` — Global TypeScript type definitions (types/index.ts)
- `utils/` — Common utility helper functions
- `assets/` — Static assets and global stylesheet styles
Dependencies: `react-markdown` + `rehype-katex` + `remark-math` for Markdown/LaTeX rendering, `framer-motion` for animations, `lucide-react` for icons.

1
Cargo.lock generated
View File

@ -160,6 +160,7 @@ dependencies = [
"sha1 0.10.6",
"sqlite-vec",
"sqlx",
"subtle",
"tempfile",
"thiserror 1.0.69",
"tokio",

View File

@ -38,6 +38,7 @@ regex = "1.10"
chrono = { version = "0.4", features = ["serde"] }
sha1 = "0.10"
hmac = "0.12"
subtle = "2" # 恒定时间密码比较,防时序侧信道攻击
base64 = "0.22"
urlencoding = "2.1"
url = "2.5"

View File

@ -128,8 +128,8 @@ AstroResearch/
│ │ ├── translation.rs # LLM 翻译 + Trie 天文词典
│ │ └── rag.rs # 向量检索增强生成
│ └── bin/ # 独立二进制 (cli, health_check, reparse)
├── dashboard/ # React 19 + Vite + TypeScript 前端
│ └── src/features/ # search/ library/ reader/ citation/ agent/ sync/ settings/
├── dashboard/ # React 19 + Vite + TypeScript 前端 (按技术类型划分)
│ └── src/ # pages/ components/ hooks/ types/ utils/ assets/
├── skills/ # Agent Skills (Markdown 知识模块)
├── migrations/ # SQLite 迁移脚本
├── library/ # 本地文献存储 (PDF/HTML/Markdown/Translation)

View File

@ -22,13 +22,10 @@ npm run dev
## 2. 核心功能及文件结构 (Core Modules)
- `src/components/layout/`:侧边栏与基本布局组件。
- `src/components/CitationGalaxyCanvas.tsx`:自研的 Canvas 力导向引文星系图渲染器。
- `src/features/search/`:统一检索面板,支持跨源搜索与收藏。
- `src/features/library/`:馆藏管理卡片,提供下载状态实时监测及重新下载操作。
- `src/features/reader/`:左右对齐的双分栏阅读器,内置划词高亮笔记及 LLM 重新翻译触发。
- `src/features/sync/`:批量同步面板,支持后台元数据大批量采集、过滤及文献资源批量下载/解析流水线任务管理。
- `src/types.ts`:全局 TypeScript 静态类型定义。
- `src/pages/`:页面级大组件视图(如统一检索、馆藏管理、对照阅读、引文星系、批量同步、智能科研、系统设置)。
- `src/components/`:通用与专有 UI 组件(如力导向引文星系图、文献阅读器子组件、流水线任务终端等)。
- `src/hooks/`:抽取出的全局共享及业务数据逻辑 Hooks`useLibrary`、`useReaderState`、`useSyncState` 等)。
- `src/types/`:全局 TypeScript 静态类型定义(`types/index.ts`)。
---

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,8 @@ import {
ThoughtCard, ToolCallCard, SubAgentContainer,
PARENT_COLORS,
routeThought, routeToolCall, routeToolResult, routeTextDelta,
} from '../../components/agent';
import type { SSEEventHandlers, TimelineItem } from '../../components/agent';
} from './agent';
import type { SSEEventHandlers, TimelineItem } from './agent';
interface RetrievalSource {
bibcode: string;

View File

@ -0,0 +1,195 @@
import React from 'react';
import {
Brain, Compass, BookOpen, Send, Square, Paperclip, X
} from 'lucide-react';
import type { AgentMode } from '../../hooks/useResearchAgent';
interface AgentInputAreaProps {
input: string;
setInput: (val: string) => void;
streaming: boolean;
thinking: boolean;
setThinking: (val: boolean) => void;
agentMode: string;
setAgentMode: (val: string) => void;
agentModes: AgentMode[];
pendingImage: { data?: string; path?: string; mime_type: string; name: string } | null;
setPendingImage: (val: { data?: string; path?: string; mime_type: string; name: string } | null) => void;
onSend: (text: string) => void;
onStop: () => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
attachImage: (file: File) => void;
handlePaste: (e: React.ClipboardEvent) => void;
}
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Compass,
BookOpen
};
function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> {
return ICON_MAP[iconName] || Brain;
}
export function AgentInputArea({
input,
setInput,
streaming,
thinking,
setThinking,
agentMode,
setAgentMode,
agentModes,
pendingImage,
setPendingImage,
onSend,
onStop,
fileInputRef,
attachImage,
handlePaste,
}: AgentInputAreaProps) {
return (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-transparent pointer-events-none shrink-0 z-30 flex flex-col gap-3">
<form
onSubmit={(e) => {
e.preventDefault();
onSend(input);
}}
className="max-w-4xl mx-auto w-full pointer-events-auto"
>
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all shadow-lg flex flex-col gap-2">
{/* 图片预览 */}
{pendingImage && (
<div className="relative inline-block mb-1 group select-none self-start">
<img
src={pendingImage.path ? `/api/files/${pendingImage.path}` : `data:${pendingImage.mime_type};base64,${pendingImage.data}`}
alt="Preview"
className="w-12 h-12 object-cover rounded-lg border border-slate-200 shadow-sm"
/>
<button
type="button"
onClick={() => setPendingImage(null)}
className="absolute -top-1.5 -right-1.5 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full p-0.5 shadow-sm transition-colors cursor-pointer flex items-center justify-center"
title="移除图片"
>
<X className="w-2.5 h-2.5" />
</button>
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend(input);
}
}}
disabled={streaming}
placeholder="向科研智能体提问(可粘贴或上传图片,配合深度研究模式分析图表)..."
rows={2}
onPaste={handlePaste}
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 leading-relaxed font-semibold min-h-[44px] max-h-40"
/>
<div className="flex justify-between items-center mt-2.5 pt-2 border-t border-slate-100/80">
<div className="flex flex-wrap gap-2 items-center">
{/* 图片上传 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) attachImage(file);
e.target.value = ''; // 允许重复选择同一文件
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={streaming}
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${
pendingImage
? 'bg-sky-50 border-sky-300 text-sky-700'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Paperclip className="w-3.5 h-3.5" />
</button>
{/* Agent 模式选择器 */}
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
{agentModes.map((m) => {
const IconComp = getIconComponent(m.icon);
const isSelected = agentMode === m.id;
return (
<button
key={m.id}
type="button"
onClick={() => setAgentMode(m.id)}
disabled={streaming}
title={m.description}
className={`px-2 py-1.5 rounded-md text-[10px] font-extrabold transition-all border cursor-pointer flex items-center gap-1 shrink-0 ${
isSelected
? 'bg-white text-indigo-700 shadow-xs border-slate-200/50'
: 'bg-transparent border-transparent text-slate-400 hover:text-indigo-600'
} disabled:opacity-60`}
>
<IconComp className={`w-3.5 h-3.5 ${isSelected ? 'text-indigo-500' : ''}`} />
<span className="hidden sm:inline">{m.name}</span>
</button>
);
})}
</div>
{/* 思考模式开关 */}
{agentMode === 'default' && (
<>
<div className="h-4 w-px bg-slate-200 mx-1 hidden sm:block" />
<button
type="button"
onClick={() => setThinking(!thinking)}
disabled={streaming}
title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'}
className={`px-2.5 py-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 shrink-0 ${
thinking
? 'bg-sky-50 border-sky-205 text-sky-700 shadow-3xs'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Brain className={`w-3.5 h-3.5 ${thinking ? 'text-sky-500' : ''}`} />
<span className="hidden sm:inline"></span>
</button>
</>
)}
</div>
<div>
{streaming ? (
<button
type="button"
onClick={onStop}
className="p-2 rounded-xl bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行"
>
<Square className="w-3.5 h-3.5 fill-white" />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="p-2 rounded-xl bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,445 @@
import React from 'react';
import {
PanelLeftOpen, BarChart3, ScrollText, Trash2, Loader, Compass, Rewind,
CheckCircle2, AlertTriangle, RotateCcw, RefreshCw, GitBranch
} from 'lucide-react';
import { AgentMarkdown } from './AgentMarkdown';
import { ThoughtCard } from './ThoughtCard';
import { ToolCallCard } from './ToolCallCard';
import { AnswerCard } from './AnswerCard';
import { SubAgentContainer } from './SubAgentContainer';
import { PARENT_COLORS } from './constants';
import type { ColorScheme } from './constants';
import type { TimelineItem } from './types';
import type { ProcessedTurn, ActiveTurn, SessionSummary } from '../../hooks/useResearchAgent';
import { AskUserQuestionCard } from './AskUserQuestionCard';
import { PermissionRequestCard } from './PermissionRequestCard';
import { AgentMetricsPanel } from './AgentMetricsPanel';
import { AuditLogViewer } from './AuditLogViewer';
const QUICK_PROMPTS = [
"检索 2023 年以后关于热亚矮星 (Subdwarf) 双星演化的研究成果",
"读取文献 2024ApJ...960..123A 并总结其核心观测结论",
"在馆藏中语义搜索关于白矮星非径向振动的讨论",
"查询天体 GD 358 的物理参数"
];
interface AgentMessageListProps {
turns: ProcessedTurn[];
activeTurn: ActiveTurn | null;
streaming: boolean;
currentSessionId: string | null;
expandedThoughts: Record<string, boolean>;
expandedArgs: Record<string, boolean>;
expandedResults: Record<string, boolean>;
collapsedSubAgents: Record<string, boolean>;
toggleThought: (key: string) => void;
toggleArgs: (tcId: string) => void;
toggleResult: (tcId: string) => void;
toggleSubAgent: (id: string) => void;
handleRewind: (messageId?: number, n?: number) => Promise<void>;
handleRestoreRewind: () => Promise<void>;
handleRetry: () => Promise<void>;
handleBranch: () => Promise<void>;
handleSend: (questionText: string) => Promise<void>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
chatEndRef: React.RefObject<HTMLDivElement | null>;
handleScroll: (e: React.UIEvent<HTMLDivElement>) => void;
showMetrics: boolean;
setShowMetrics: (val: boolean) => void;
showAuditLog: boolean;
setShowAuditLog: (val: boolean) => void;
hasPendingPermission: boolean;
setHasPendingPermission: (val: boolean) => void;
hasPendingQuestion: boolean;
setHasPendingQuestion: (val: boolean) => void;
showAlert?: (message: string, title?: string) => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (val: boolean) => void;
sessions: SessionSummary[];
handleDeleteSession: (sessionId: string, e: React.MouseEvent) => Promise<void>;
loadSessionHistory: (sessionId: string, silent: boolean, onSuccess?: () => void) => Promise<void>;
}
export function AgentMessageList({
turns,
activeTurn,
streaming,
currentSessionId,
expandedThoughts,
expandedArgs,
expandedResults,
collapsedSubAgents,
toggleThought,
toggleArgs,
toggleResult,
toggleSubAgent,
handleRewind,
handleRestoreRewind,
handleRetry,
handleBranch,
handleSend,
scrollContainerRef,
chatEndRef,
handleScroll,
showMetrics,
setShowMetrics,
showAuditLog,
setShowAuditLog,
hasPendingPermission,
setHasPendingPermission,
hasPendingQuestion,
setHasPendingQuestion,
showAlert,
sidebarCollapsed,
setSidebarCollapsed,
sessions,
handleDeleteSession,
loadSessionHistory,
}: AgentMessageListProps) {
const renderTimelineItem = (
item: TimelineItem,
idx: number,
isStreaming: boolean,
colors: ColorScheme = PARENT_COLORS,
) => {
switch (item.type) {
case 'subagent_container':
return (
<SubAgentContainer
key={`subagent-${item.id}-${idx}`}
status={item.status}
children={item.children}
summary={item.summary}
isStreaming={isStreaming}
isCollapsed={collapsedSubAgents[item.id] !== false}
onToggle={() => toggleSubAgent(item.id)}
expandedThoughts={expandedThoughts}
expandedArgs={expandedArgs}
expandedResults={expandedResults}
onToggleThought={toggleThought}
onToggleArgs={toggleArgs}
onToggleResult={toggleResult}
/>
);
case 'thought': {
const thoughtKey = `thought-${item.step}`;
return (
<ThoughtCard
key={`${thoughtKey}-${idx}`}
step={item.step}
content={item.content}
isStreaming={isStreaming}
colorScheme={colors}
isExpanded={expandedThoughts[thoughtKey] === true}
onToggle={() => toggleThought(thoughtKey)}
/>
);
}
case 'tool_call': {
const tcId = item.id;
return (
<ToolCallCard
key={`tc-${tcId}-${idx}`}
step={item.step}
name={item.name}
arguments={item.arguments}
result={item.result}
isStreaming={isStreaming}
colorScheme={colors}
isArgsExpanded={expandedArgs[tcId] === true}
isResultExpanded={expandedResults[tcId] === true}
onToggleArgs={() => toggleArgs(tcId)}
onToggleResult={() => toggleResult(tcId)}
/>
);
}
case 'answer':
return (
<AnswerCard
key={`answer-${idx}`}
content={item.content}
colorScheme={colors}
/>
);
default:
return null;
}
};
return (
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
{/* 会话头部 */}
<div className="px-5 py-4 border-b border-slate-200 flex items-center justify-between shrink-0 bg-white">
<div className="min-w-0 pr-4 flex items-center gap-2">
{sidebarCollapsed && (
<button
onClick={() => setSidebarCollapsed(false)}
className="p-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-all cursor-pointer mr-1 shrink-0"
title="展开侧栏"
>
<PanelLeftOpen className="w-4 h-4" />
</button>
)}
<div>
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
{currentSessionId
? (sessions.find(s => s.session_id === currentSessionId)?.title || '未命名会话')
: '探索性科研研讨'}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
(ReAct Thought Action Observation)
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* 指标面板按钮 */}
<button
onClick={() => { setShowMetrics(!showMetrics); setShowAuditLog(false); }}
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
showMetrics
? 'bg-sky-50 border-sky-200 text-sky-700'
: 'bg-white border-slate-200 text-slate-500 hover:text-sky-600 hover:border-sky-200'
}`}
title="运行指标"
>
<BarChart3 className="w-3.5 h-3.5" />
</button>
{/* 审计日志按钮 */}
{currentSessionId && (
<button
onClick={() => { setShowAuditLog(!showAuditLog); setShowMetrics(false); }}
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
showAuditLog
? 'bg-amber-50 border-amber-200 text-amber-700'
: 'bg-white border-slate-200 text-slate-500 hover:text-amber-600 hover:border-amber-200'
}`}
title="审计日志"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
)}
{currentSessionId && (
<button
onClick={(e) => handleDeleteSession(currentSessionId, e)}
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 text-slate-500 hover:text-red-700 transition-all cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span></span>
</button>
)}
</div>
</div>
{/* Agent 运行指标面板 — 固定在聊天区域上方 */}
{showMetrics && (
<div className="border-b border-sky-200 bg-white px-5 py-4 max-h-[45vh] overflow-y-auto scrollbar-thin shrink-0">
<AgentMetricsPanel showAlert={showAlert} />
</div>
)}
{/* 会话审计日志 — 固定在聊天区域上方 */}
{showAuditLog && currentSessionId && (
<div className="border-b border-amber-200 bg-white px-5 py-4 max-h-[45vh] overflow-y-auto scrollbar-thin shrink-0">
<AuditLogViewer
sessionId={currentSessionId}
onClose={() => setShowAuditLog(false)}
/>
</div>
)}
{/* 消息滚动区域 */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin transition-all duration-300 ${
(hasPendingPermission || hasPendingQuestion) ? 'pb-80' : 'pb-36'
}`}
>
{turns.length === 0 && !activeTurn ? (
<div className="py-12 space-y-8 max-w-xl mx-auto">
<div className="text-center space-y-2">
<Compass className="w-12 h-12 mx-auto text-sky-500 opacity-60" />
<h3 className="text-sm font-extrabold text-slate-800 tracking-wider"></h3>
<p className="text-xs text-slate-500 leading-relaxed font-semibold">
NASA ADS PDF/HTML SQLite-Vec RAG CDS Sesame
</p>
</div>
{/* 快速提示词 */}
<div className="space-y-3">
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase block">:</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{QUICK_PROMPTS.map((q, idx) => (
<button
key={idx}
onClick={() => handleSend(q)}
className="w-full text-left bg-white hover:bg-sky-50/20 border border-slate-200 hover:border-sky-300 rounded-xl p-3.5 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-2xs hover:shadow-xs cursor-pointer flex flex-col gap-1 group"
>
<span className="group-hover:text-sky-700 font-semibold">{q}</span>
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider"></span>
</button>
))}
</div>
</div>
</div>
) : (
<div className="space-y-6 max-w-4xl mx-auto">
{/* 历史消息渲染 */}
{turns.map((turn) => (
<div key={turn.turn_index} className="space-y-4">
{/* 用户提问 */}
<div className="flex flex-col items-end space-y-1 group relative">
<span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="flex items-center gap-2 max-w-[85%]">
{turn.questionMessageId && (
<button
onClick={() => handleRewind(turn.questionMessageId)}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-amber-600 p-1.5 rounded-lg hover:bg-slate-100 transition-all cursor-pointer shrink-0"
title="回退到此消息之前"
>
<Rewind className="w-3.5 h-3.5" />
</button>
)}
<div className="rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text flex-1 space-y-2">
{turn.imagePath && (
<img src={turn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" />
)}
<div>{turn.question}</div>
</div>
</div>
</div>
{/* 时间线 — 思考/工具/答案 */}
{turn.timeline.length > 0 && (
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
{turn.timeline.map((item: TimelineItem, idx: number) => renderTimelineItem(item, idx, false))}
</div>
)}
{/* Token 用量 */}
{turn.usage && turn.usage.total_tokens > 0 && (
<div className="flex items-center gap-1 text-[9px] text-slate-400 font-mono pl-4">
<span>Tokens: ~{turn.usage.total_tokens}</span>
</div>
)}
</div>
))}
{/* 流式中活跃的步骤展示 */}
{activeTurn && (
<div className="space-y-4">
{/* 当前提问 */}
<div className="flex flex-col items-end space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text space-y-2">
{activeTurn.imagePath && (
<img src={activeTurn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" />
)}
<div>{activeTurn.question}</div>
</div>
</div>
{/* SSE Stream Timeline */}
{activeTurn.timeline.length > 0 && (
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
{activeTurn.timeline.map((item: TimelineItem, idx: number) => renderTimelineItem(item, idx, true))}
</div>
)}
{/* 最终回答 */}
{activeTurn.finalAnswer && (
<div className="flex flex-col items-start space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5 text-sky-600 animate-pulse" />
<span></span>
</span>
<div className="max-w-[90%] rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 select-text prose prose-sm max-w-none prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
<AgentMarkdown>{activeTurn.finalAnswer}</AgentMarkdown>
</div>
</div>
)}
{/* 活跃的加载指示器 */}
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
<div className="flex items-center space-x-2 text-slate-500 pl-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-[10px] font-bold">...</span>
</div>
)}
{/* Error block */}
{activeTurn.error && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-750 rounded-lg p-3 text-xs w-[90%] font-semibold">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div>
<div className="font-bold"></div>
<div className="text-[10px] opacity-80 mt-0.5">{activeTurn.error}</div>
</div>
</div>
)}
</div>
)}
{/* 会话操作小图标栏 */}
{currentSessionId && turns.length > 0 && !streaming && (
<div className="flex items-center gap-1 pt-2.5 justify-start border-t border-slate-100/60 mt-4 max-w-[200px]">
<button
onClick={() => handleRewind(undefined, 1)}
className="p-1.5 rounded-lg text-slate-400 hover:text-amber-600 hover:bg-slate-100 transition-all cursor-pointer"
title="回退最近一轮对话 (undo)"
>
<Rewind className="w-4 h-4" />
</button>
<button
onClick={handleRestoreRewind}
className="p-1.5 rounded-lg text-slate-400 hover:text-green-600 hover:bg-slate-100 transition-all cursor-pointer"
title="恢复上次回退操作"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={handleRetry}
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-slate-100 transition-all cursor-pointer"
title="重试最后一轮对话"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={handleBranch}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-slate-100 transition-all cursor-pointer"
title="分叉当前会话"
>
<GitBranch className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
<div ref={chatEndRef} />
</div>
{/* 悬浮交互卡片区域AskUserQuestion 和 PermissionRequest */}
{currentSessionId && (
<div className="absolute bottom-16 left-0 right-0 px-4 pointer-events-none z-25 flex flex-col gap-3">
<div className="max-w-4xl mx-auto w-full pointer-events-auto space-y-3">
<AskUserQuestionCard
onAnswered={() => {
if (currentSessionId) {
loadSessionHistory(currentSessionId, true);
}
}}
onQuestionCountChange={(count: number) => setHasPendingQuestion(count > 0)}
/>
<PermissionRequestCard
sessionId={currentSessionId}
onRequestCountChange={(count: number) => setHasPendingPermission(count > 0)}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,209 @@
import React from 'react';
import {
Clock, Plus, PanelLeftClose, Search, X, MessageSquare, BookOpen, Trash2, Loader
} from 'lucide-react';
import type { SessionSummary, SearchResult } from '../../hooks/useResearchAgent';
interface AgentSessionSidebarProps {
sessions: SessionSummary[];
currentSessionId: string | null;
setCurrentSessionId: (id: string | null) => void;
loadingSessions: boolean;
searchQuery: string;
setSearchQuery: (val: string) => void;
searchScopeOnlyCurrent: boolean;
setSearchScopeOnlyCurrent: (val: boolean) => void;
searchResults: SearchResult[];
loadingSearch: boolean;
collapsed: boolean;
onToggleCollapse: (val: boolean) => void;
onNewSession: () => void;
onDeleteSession: (sessionId: string, e: React.MouseEvent) => void;
}
export function AgentSessionSidebar({
sessions,
currentSessionId,
setCurrentSessionId,
loadingSessions,
searchQuery,
setSearchQuery,
searchScopeOnlyCurrent,
setSearchScopeOnlyCurrent,
searchResults,
loadingSearch,
collapsed,
onToggleCollapse,
onNewSession,
onDeleteSession,
}: AgentSessionSidebarProps) {
return (
<div className={`transition-all duration-300 ease-in-out ${collapsed ? 'w-0 overflow-hidden opacity-0 border-r-0' : 'w-64 border-r border-slate-200'} bg-slate-50 flex flex-col justify-between shrink-0 select-none`}>
<div className="flex flex-col min-h-0 flex-1">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between shrink-0">
<span className="text-xs font-extrabold text-slate-800 tracking-wider flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-sky-600" />
{!collapsed && <span></span>}
</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onNewSession}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 hover:text-slate-800 transition-all cursor-pointer shadow-2xs hover:scale-105"
title="新建会话"
>
<Plus className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onToggleCollapse(true)}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-all cursor-pointer"
title="收起侧栏"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 搜索框区域 */}
<div className="px-3 py-2 border-b border-slate-200/60 bg-white flex flex-col gap-1.5 shrink-0">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索历史会话或消息内容..."
className="w-full pl-8 pr-7 py-1.5 rounded-lg bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-[11px] font-medium"
/>
<div className="absolute left-2.5 top-2.5 text-slate-400">
<Search className="w-3.5 h-3.5 text-slate-400" />
</div>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-2 p-0.5 rounded-full hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{currentSessionId && (
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer font-medium select-none ml-0.5">
<input
type="checkbox"
checked={searchScopeOnlyCurrent}
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
className="rounded text-sky-600 border-slate-300 focus:ring-sky-500 w-3 h-3 cursor-pointer"
/>
<span></span>
</label>
)}
</div>
<div className="flex-1 overflow-y-auto p-2.5 space-y-1.5 scrollbar-thin">
{searchQuery.trim() ? (
loadingSearch ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
<span>...</span>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-[11px] italic">
</div>
) : (
searchResults.map((result, idx) => {
const isActive = result.session_id === currentSessionId;
const isMessage = result.result_type.startsWith('message/');
const role = isMessage ? result.result_type.split('/')[1] : null;
return (
<button
key={`${result.session_id}-${idx}`}
onClick={() => setCurrentSessionId(result.session_id)}
className={`w-full text-left p-2.5 rounded-lg border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
isActive
? 'bg-sky-50/70 border-sky-200 text-sky-850 font-bold shadow-2xs'
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
}`}
>
<div className="flex items-center justify-between w-full">
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
{isMessage ? (
<>
<MessageSquare className="w-3 h-3 text-purple-400" />
<span>{role === 'user' ? '提问' : '解答'}</span>
</>
) : (
<>
<BookOpen className="w-3 h-3 text-sky-500" />
<span></span>
</>
)}
</span>
{result.created_at && (
<span className="text-[8px] text-slate-400 font-mono">
{result.created_at.split(' ')[0]}
</span>
)}
</div>
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
{result.title || '无标题会话'}
</span>
<p
className="text-[10px] text-slate-500 font-medium leading-normal block text-left line-clamp-3 bg-slate-50/50 p-1.5 rounded border border-slate-100/50"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
</button>
);
})
)
) : (
// 正常的会话列表渲染
loadingSessions ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
<span>...</span>
</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-[11px] italic">
</div>
) : (
sessions.map(session => {
const isActive = session.session_id === currentSessionId;
return (
<button
key={session.session_id}
onClick={() => setCurrentSessionId(session.session_id)}
className={`w-full text-left p-3 rounded-lg border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
isActive
? 'bg-sky-50 border-sky-200 text-sky-850 font-bold shadow-2xs'
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-650'
}`}
>
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left">
<span className="text-xs leading-snug line-clamp-2 block text-left">
{session.title || '无标题会话'}
</span>
<span className="text-[9px] text-slate-400 font-semibold block text-left">
{session.turn_count} {session.updated_at.split(' ')[0]}
</span>
</div>
<button
onClick={(e) => onDeleteSession(session.session_id, e)}
className="text-slate-400 hover:text-red-655 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-white border border-transparent hover:border-slate-200 transition-all cursor-pointer shrink-0"
title="删除此会话"
>
<Trash2 className="w-3 h-3" />
</button>
</button>
);
})
)
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
interface GlobalDialogProps {
dialog: {
type: 'alert' | 'confirm';
title: string;
message: string;
onConfirm: () => void;
onCancel?: () => void;
} | null;
onClose: () => void;
}
export function GlobalDialog({ dialog, onClose }: GlobalDialogProps) {
if (!dialog) return null;
const handleCancel = () => {
if (dialog.onCancel) dialog.onCancel();
onClose();
};
const handleConfirm = () => {
dialog.onConfirm();
onClose();
};
return (
<div
onClick={handleCancel}
className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${dialog.type === 'confirm' ? 'bg-[var(--accent-blueprint)]' : 'bg-[#ef4444]'} animate-pulse`} />
<span>{dialog.title}</span>
</h3>
<button
onClick={handleCancel}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-[var(--text-muted)] leading-relaxed whitespace-pre-line">{dialog.message}</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={handleConfirm}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
{dialog.type === 'confirm' && (
<button
onClick={handleCancel}
className="px-4 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,360 @@
import axios from 'axios';
import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../../utils/paper';
interface PaperDetailModalProps {
paper: StandardPaper | null | undefined;
onClose: () => void;
uploadingBibcode: string | null;
downloadingBibcodes: Record<string, boolean>;
handleManualUpload: (bibcode: string, type: 'pdf' | 'html', file: File) => Promise<void>;
handleMarkNoResource: (bibcode: string, clear: boolean) => Promise<void>;
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
openReader: (paper: StandardPaper) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
setSelectedPaper: (paper: StandardPaper) => void;
loadCitations: (bibcode: string) => Promise<void>;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
}
export function PaperDetailModal({
paper,
onClose,
uploadingBibcode,
downloadingBibcodes,
handleManualUpload,
handleMarkNoResource,
handleDownload,
openReader,
setActiveTab,
setSelectedPaper,
loadCitations,
showConfirm,
}: PaperDetailModalProps) {
if (!paper) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-lg w-full p-6 space-y-4 cursor-default animate-fade-in"
>
{/* 标题 & 关闭 */}
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
<div className="space-y-1 pr-4">
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] uppercase tracking-wider"></span>
<h3 className="text-xs font-bold text-[var(--text-main)] leading-snug">
{getDoctypeBadge(paper.doctype)}
<span className="align-middle ml-1">{paper.title}</span>
</h3>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 详情内容 */}
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
{/* 作者 */}
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
<span className="text-[var(--text-muted)] font-bold"></span>
<p className="text-[var(--text-main)] leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
</div>
{/* 期刊 & 年份 */}
<div className="grid grid-cols-5 gap-4 border-y border-slate-100 py-2.5 shrink-0 h-14">
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span>
<span
className="text-[var(--text-main)] font-bold italic truncate block text-[11px] leading-tight"
title={paper.pub_journal || '未标注'}
>
{paper.pub_journal || '未标注'}
</span>
</div>
<div className="space-y-0.5 flex flex-col justify-center col-span-1">
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span>
<span className="text-[var(--text-main)] font-extrabold block text-[11px] leading-tight">{paper.year}</span>
</div>
</div>
{/* 摘要 */}
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
<span className="text-[var(--text-muted)] font-bold block"></span>
<p className="text-[var(--text-main)] leading-relaxed font-normal bg-[#f8fafc] p-3 flex-1 rounded-lg border border-[var(--border-precision)] text-justify overflow-y-auto scrollbar-thin select-text">
{paper.abstract_text || '该文献暂无摘要数据。'}
</p>
</div>
{/* 关键字 */}
<div className="space-y-1.5 flex flex-col h-16 shrink-0">
<span className="text-[var(--text-muted)] font-bold block"></span>
{paper.keywords && paper.keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1">
{paper.keywords.map(kw => (
<span key={kw} className="px-2 py-0.5 rounded bg-[#f1f5f9] border border-[var(--border-precision)] text-[var(--text-muted)] font-bold text-[9px]">
{kw}
</span>
))}
</div>
) : (
<div className="flex items-center justify-center flex-1 bg-[#f8fafc] border border-[var(--border-precision)] rounded-lg text-[10px] text-[var(--text-muted)] font-bold select-none">
</div>
)}
</div>
{/* 标识符 */}
<div className="border-t border-slate-100 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">BIBCODE</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}>
{paper.bibcode === paper.arxiv_id ? '暂无' : (
<a
href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline"
>
{paper.bibcode}
</a>
)}
</span>
</div>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">DOI</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.doi || '无'}>
{paper.doi ? (
<a
href={`https://doi.org/${paper.doi}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline"
>
{paper.doi}
</a>
) : '无'}
</span>
</div>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">ARXIV ID</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.arxiv_id || '无'}>
{paper.arxiv_id ? (
<a
href={`https://arxiv.org/abs/${paper.arxiv_id}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline"
>
{paper.arxiv_id}
</a>
) : '无'}
</span>
</div>
</div>
{/* 手动上传文件(应对防爬阻断) */}
<div className="border-t border-slate-100 pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[var(--text-muted)] font-bold block">线</span>
<span className="text-[9px] text-amber-700 font-bold bg-amber-50 px-2 py-0.5 rounded border border-amber-200">/</span>
</div>
<p className="text-[10px] text-[var(--text-muted)] leading-relaxed">
PDF HTML
</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleManualUpload(paper.bibcode, 'pdf', file);
}}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
disabled={uploadingBibcode === paper.bibcode}
/>
{paper.has_pdf && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10">
</span>
)}
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline">
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 PDF 文献'}
</span>
<span className="text-[8px] text-[var(--text-muted)]"> .pdf </span>
</div>
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="text/html,.html"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleManualUpload(paper.bibcode, 'html', file);
}}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
disabled={uploadingBibcode === paper.bibcode}
/>
{paper.has_html && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10">
</span>
)}
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline">
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 HTML 文献'}
</span>
<span className="text-[8px] text-[var(--text-muted)]"> .html </span>
</div>
</div>
{!paper.is_downloaded && (
<div className="pt-1">
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<button
type="button"
onClick={() => handleMarkNoResource(paper.bibcode, true)}
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<RefreshCw className="w-3 h-3" /> ()
</button>
) : (
<button
type="button"
onClick={() => handleMarkNoResource(paper.bibcode, false)}
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<AlertTriangle className="w-3 h-3 text-[var(--accent-star)]" /> ()
</button>
)}
</div>
)}
</div>
{/* 自动下载失败诊断 */}
{!paper.is_downloaded && (paper.pdf_error || paper.html_error) && (
<div className={`rounded-lg p-3 text-[10px] space-y-1 ${
(paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? 'bg-amber-50 border border-amber-150 text-amber-850'
: 'bg-rose-50 border border-rose-150 text-rose-850'
}`}>
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<div className="leading-relaxed">
<div className="font-bold flex items-center gap-1.5 text-amber-900 text-xs mb-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
</div>
</div>
) : (
<>
<div className="font-bold flex items-center gap-1.5 text-rose-900">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" />
</div>
{paper.pdf_error && (
<div className="leading-relaxed">
<span className="font-semibold text-rose-750">PDF : </span>
{paper.pdf_error}
</div>
)}
{paper.html_error && (
<div className="leading-relaxed mt-0.5">
<span className="font-semibold text-rose-750">HTML : </span>
{paper.html_error}
</div>
)}
</>
)}
</div>
)}
</div>
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-100">
{paper.is_downloaded ? (
<>
<button
onClick={() => {
onClose();
openReader(paper);
}}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
onClose();
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode);
}}
className="flex-1 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
showConfirm('确定要强制重新下载吗?这会覆盖本地文件。', () => {
handleDownload(paper.bibcode, true);
}, '确认重新下载');
}}
disabled={downloadingBibcodes[paper.bibcode]}
className="px-4 btn-console btn-console-secondary text-[var(--accent-star)] py-2.5 rounded-lg text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[paper.bibcode] ? '重下中...' : '重新下载'}
</button>
</>
) : (
<>
<button
onClick={() => {
handleDownload(paper.bibcode);
}}
disabled={downloadingBibcodes[paper.bibcode]}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[paper.bibcode] ? (
<>
<Loader className="w-3.5 h-3.5 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-3.5 h-3.5" />
<span></span>
</>
)}
</button>
<button
onClick={() => {
onClose();
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode);
}}
className="px-6 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import axios from 'axios';
interface UncachedPaperModalProps {
bibcode: string | null;
onClose: () => void;
loadCitations: (bibcode: string, reset: boolean) => Promise<void>;
}
export function UncachedPaperModal({ bibcode, onClose, loadCitations }: UncachedPaperModalProps) {
if (!bibcode) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--accent-star)] animate-pulse" />
<span></span>
</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-[var(--text-main)] leading-relaxed">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-[var(--accent-blueprint)] font-bold select-all">{bibcode}</span>
</p>
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed">
线 NASA ADS
</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => {
loadCitations(bibcode, false);
onClose();
}}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
axios.post('/api/active_bibcode', { bibcode }).catch(() => {});
window.open(`https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`, '_blank');
}}
className="flex-1 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
ADS
</button>
<button
onClick={onClose}
className="px-3 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,448 @@
// dashboard/src/components/reader/BilingualViewer.tsx
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeKatex from 'rehype-katex';
import rehypeSanitize from 'rehype-sanitize';
import 'katex/dist/katex.min.css';
import { FileText, Loader, Languages, BookOpen } from 'lucide-react';
import type { StandardPaper, NoteRecord } from '../../types';
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial';
import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper';
interface BilingualViewerProps {
selectedPaper: StandardPaper;
parsing: boolean;
translating: boolean;
englishText: string;
chineseText: string;
notes: NoteRecord[];
handleTextSelection: (paragraphIdx: number) => void;
showNotesPanel: boolean;
// 来自 useReaderState 的状态和 Ref
viewMode: 'bilingual' | 'english' | 'chinese';
showPdf: boolean;
setShowPdf: (show: boolean) => void;
englishRef: React.RefObject<HTMLDivElement | null>;
chineseRef: React.RefObject<HTMLDivElement | null>;
handleEnglishScroll: () => void;
handleChineseScroll: () => void;
handleMouseOver: (e: React.MouseEvent<HTMLDivElement>) => void;
clearHoverTimeout: () => void;
handleContainerMouseLeave: () => void;
handleMouseLeaveTarget: () => void;
highlightCandidates: HighlightCandidate[];
hoveredTarget: TargetInfo | null;
hoverCardPos: { x: number; y: number; isAbove?: boolean } | null;
children?: React.ReactNode;
}
/**
* Metadata Dossier
*/
function PaperMetadataDossier({ metadata, isChinese }: { metadata: PaperMetadata; isChinese?: boolean }) {
if (!metadata || (!metadata.title && !metadata.author)) return null;
return (
<div className="mb-6 rounded-xl p-5 bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200 shadow-xs relative overflow-hidden select-text">
{/* 科技风背景网格点缀 */}
<div className="absolute top-0 right-0 w-24 h-24 bg-sky-550/5 rounded-bl-full pointer-events-none border-l border-b border-sky-500/10" />
{/* 顶部档案标徽 */}
<div className="flex items-center gap-2 mb-3 select-none">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-extrabold bg-sky-600 text-white uppercase tracking-wider">
{isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'}
</span>
{metadata.date && (
<span className="text-[10px] font-bold text-slate-400 font-mono">
Year: {metadata.date}
</span>
)}
</div>
{/* 标题 */}
<h1 className="text-sm font-bold text-slate-900 leading-snug mb-3 select-text">
{metadata.title}
</h1>
{/* 作者群 */}
{metadata.author && metadata.author.length > 0 && (
<div className="text-xs text-slate-600 mb-4 font-medium leading-relaxed select-text">
<span className="text-slate-400 font-semibold mr-1">{isChinese ? '作者群体:' : 'Authors:'}</span>
<span className="italic text-slate-750">{metadata.author.join(', ')}</span>
</div>
)}
{/* 底部期刊与跳转 ADS 按钮 */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-slate-200/60 select-none">
<div className="text-[10px] text-slate-500 font-semibold">
<span className="text-slate-400 mr-1">{isChinese ? '发表期刊/预印本:' : 'Publisher:'}</span>
<span className="text-slate-700">{metadata.publisher || 'arXiv Preprint'}</span>
</div>
{metadata.source && (
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[10px] font-extrabold px-3 py-1 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 hover:text-sky-850 transition-all cursor-pointer shadow-xs active:scale-95"
title="在 NASA ADS 官方文献数据库中查看源页面"
>
<span>ADS NASA Portal</span>
<span className="text-[8px]"></span>
</a>
)}
</div>
</div>
);
}
export function BilingualViewer({
selectedPaper,
parsing,
translating,
englishText,
chineseText,
notes,
handleTextSelection,
showNotesPanel,
viewMode,
showPdf,
setShowPdf,
englishRef,
chineseRef,
handleEnglishScroll,
handleChineseScroll,
handleMouseOver,
clearHoverTimeout,
handleContainerMouseLeave,
handleMouseLeaveTarget,
highlightCandidates,
hoveredTarget,
hoverCardPos,
children,
}: BilingualViewerProps) {
// 解析英文和中文段落的 Front Matter 头部元数据
const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText);
const { metadata: chnMeta, pureMarkdown: chnPure } = parseMarkdownFrontMatter(chineseText);
// 智能合并元数据,以防中文翻译中部分字段丢失或解析失败时,可优雅复用英文侧的数据进行降级补充
const mergedChnMeta = (() => {
if (!engMeta && !chnMeta) return null;
if (!engMeta) return chnMeta;
if (!chnMeta) return engMeta;
return {
title: chnMeta.title || engMeta.title,
author: (chnMeta.author && chnMeta.author.length > 0) ? chnMeta.author : engMeta.author,
publisher: chnMeta.publisher || engMeta.publisher,
source: chnMeta.source || engMeta.source,
date: chnMeta.date || engMeta.date,
tags: chnMeta.tags || engMeta.tags,
};
})();
return (
<div
className="flex-1 grid gap-6 overflow-hidden w-full min-h-0"
style={{
gridTemplateColumns: viewMode === 'bilingual'
? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr')
: (showNotesPanel ? '1fr 380px' : '1fr')
}}
>
{/* 英文正文视窗 (或双语对照) */}
{(viewMode === 'english' || viewMode === 'bilingual') && (
<div
onMouseOver={handleMouseOver}
onMouseLeave={handleContainerMouseLeave}
className="console-panel rounded-xl p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden"
>
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-800"></span>
{selectedPaper.has_pdf && (
<button
type="button"
onClick={() => setShowPdf(!showPdf)}
className="text-[9px] font-bold px-2 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-1"
>
{showPdf ? (
<>
<FileText className="w-3 h-3" />
</>
) : (
<>
<BookOpen className="w-3 h-3" /> PDF
</>
)}
</button>
)}
</div>
</div>
{showPdf ? (
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<iframe
src={`/api/files/PDF/${selectedPaper.bibcode}.pdf#navpanes=0&pagemode=none&view=FitH`}
className="w-full h-full border-none"
title="文献 PDF"
/>
</div>
) : parsing ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs font-bold text-center"> Markdown ...</p>
</div>
) : engPure ? (
<div
ref={englishRef}
onScroll={handleEnglishScroll}
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
>
<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} />}
{engPure.split('\n\n').map((para, idx) => {
const matchedNote = notes.find(n => n.paragraph_index === idx);
const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null;
return (
<div
key={idx}
id={`paragraph-block-${idx}`}
onMouseUp={() => handleTextSelection(idx)}
className={`cursor-text relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 ${
highlightStyle
? `${highlightStyle.bg} ${highlightStyle.border} border`
: 'hover:bg-slate-100'
}`}
>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('#celestial-')) {
const targetName = decodeURIComponent(href.slice('#celestial-'.length));
return (
<span
className="celestial-target cursor-pointer font-bold text-sky-600 underline decoration-dotted decoration-2 hover:text-sky-850"
data-target-name={targetName}
>
{children}
</span>
);
}
return (
<a href={href} className="text-sky-600 hover:underline" {...props}>
{children}
</a>
);
}
}}
>
{highlightTargetsInMarkdown(para, highlightCandidates)}
</ReactMarkdown>
</div>
);
})}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
<FileText className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
)}
</div>
)}
{/* 中文翻译视窗 (或双语对照) */}
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
<div
className="console-panel rounded-xl p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0"
>
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
<span className="text-xs font-bold text-slate-800"></span>
</div>
{translating ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs font-bold text-center">...</p>
</div>
) : chnPure ? (
<div
ref={chineseRef}
onScroll={handleChineseScroll}
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
>
<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} />}
{chnPure.split('\n\n').map((para, idx) => (
<div
key={idx}
id={`paragraph-block-${idx}`}
className="relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 hover:bg-slate-100"
>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
>
{para}
</ReactMarkdown>
</div>
))}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
<Languages className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
)}
</div>
)}
{children}
{/* 天体详情悬浮卡片 (React Portal 挂载在 body 顶层防止裁剪) */}
{hoveredTarget && hoverCardPos && createPortal(
<div
onMouseEnter={clearHoverTimeout}
onMouseLeave={handleMouseLeaveTarget}
style={{
position: 'fixed',
left: `${hoverCardPos.x}px`,
top: `${hoverCardPos.y}px`,
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
zIndex: 9999,
}}
className="console-panel rounded-xl p-4 bg-white border border-slate-200 shadow-md w-72 text-xs space-y-2 pointer-events-auto select-text animate-in fade-in duration-200"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-1.5">
<span className="font-bold text-slate-950 text-sm">{hoveredTarget.target_name}</span>
<div className="flex items-center gap-2">
<a
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 hover:underline cursor-pointer"
title="在 SIMBAD 中查询该天体详情"
>
SIMBAD
</a>
<span className="text-[9px] text-slate-300">|</span>
<a
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 hover:underline cursor-pointer"
title="在 VizieR 中查询相关文献和表数据"
>
VizieR
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-500">
{/* 天体类型 & 官方名称 */}
<div className="col-span-2 flex items-center gap-2">
{hoveredTarget.otype && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200 text-[10px] font-bold font-mono">
{hoveredTarget.otype}
</span>
)}
{hoveredTarget.oname && hoveredTarget.oname !== hoveredTarget.target_name && (
<span className="text-[10px] text-slate-400 font-medium truncate">
aka {hoveredTarget.oname}
</span>
)}
</div>
{/* 坐标 */}
<div>
<span className="text-slate-400 font-semibold">RA (J2000):</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.ra || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold">Dec (J2000):</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.dec || '未知'}</div>
</div>
{/* 光谱型 & 视星等 */}
<div>
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">{hoveredTarget.spectral_type || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold"> (V):</span>
<div className="font-bold text-slate-800">
{hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined
? `${hoveredTarget.v_magnitude.toFixed(2)}`
: '未知'}
</div>
</div>
{/* 视差 + 误差 + 估算距离 */}
<div className="col-span-2">
<span className="text-slate-400 font-semibold"> / :</span>
<div className="font-bold text-slate-800">
{hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined
? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
: '未知'}
</div>
</div>
{/* 自行 */}
<div className="col-span-2">
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">
{hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null
? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr`
: '未知'}
</div>
</div>
{/* 视向速度 */}
<div className="col-span-2">
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">
{hoveredTarget.radial_velocity != null
? `${hoveredTarget.radial_velocity.toFixed(1)} km/s`
: '未知'}
</div>
</div>
</div>
{/* 多波段测光 */}
{hoveredTarget.photometry && Object.keys(hoveredTarget.photometry).length > 1 && (
<div className="border-t border-slate-100 pt-1.5">
<span className="text-slate-400 font-semibold block mb-1">:</span>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] font-mono text-slate-600">
{Object.entries(hoveredTarget.photometry).map(([band, mag]) => (
<span key={band} className="whitespace-nowrap">
<span className="text-slate-400">{band}</span>{' '}
<span className="font-bold text-slate-700">{mag.toFixed(3)}</span>
</span>
))}
</div>
</div>
)}
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
<div className="border-t border-slate-100 pt-1.5">
<span className="text-slate-400 font-semibold block mb-0.5">:</span>
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
{hoveredTarget.aliases.slice(0, 8).join(', ')}
{hoveredTarget.aliases.length > 8 && ' ...'}
</div>
</div>
)}
</div>,
document.body
)}
</div>
);
}

View File

@ -0,0 +1,285 @@
// dashboard/src/components/reader/ReaderNotesSidebar.tsx
import React from 'react';
import { X, Loader, Sparkles, PlusCircle, Pencil, Trash2 } from 'lucide-react';
import type { StandardPaper, NoteRecord } from '../../types';
import { AIAssistantPanel } from '../AIAssistantPanel';
import { NOTE_COLORS } from '../../hooks/useReaderState';
import { type TargetInfo } from '../../utils/celestial';
interface ReaderNotesSidebarProps {
selectedPaper: StandardPaper;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notes: NoteRecord[];
selectedParagraphIdx: number | null;
setSelectedParagraphIdx: (idx: number | null) => void;
selectedText: string;
setSelectedText: (text: string) => void;
newNoteColor: string;
setNewNoteColor: (color: string) => void;
newNoteText: string;
setNewNoteText: (text: string) => void;
handleCreateNote: () => void;
handleDeleteNote: (id: number) => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
// 来自 useReaderState 的状态和处理器
sidebarTab: 'notes' | 'ai';
setSidebarTab: (tab: 'notes' | 'ai') => void;
targets: TargetInfo[];
loadingTargets: boolean;
identifyingTargets: boolean;
associatingTarget: boolean;
manualTargetName: string;
setManualTargetName: (name: string) => void;
handleIdentifyTargets: (bibcode: string) => Promise<void>;
handleAssociateTarget: (e: React.FormEvent) => Promise<void>;
handleJumpToTarget: (target: TargetInfo) => void;
}
export function ReaderNotesSidebar({
selectedPaper,
showNotesPanel,
setShowNotesPanel,
notes,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
newNoteColor,
setNewNoteColor,
newNoteText,
setNewNoteText,
handleCreateNote,
handleDeleteNote,
onJumpToSource,
sidebarTab,
setSidebarTab,
targets,
loadingTargets,
identifyingTargets,
associatingTarget,
manualTargetName,
setManualTargetName,
handleIdentifyTargets,
handleAssociateTarget,
handleJumpToTarget,
}: ReaderNotesSidebarProps) {
if (!showNotesPanel) return null;
return (
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
<span className="text-xs font-bold text-slate-800">
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
</span>
<button onClick={() => setShowNotesPanel(false)} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
<X className="w-4 h-4" />
</button>
</div>
{/* 标签栏选择器 */}
<div className="flex border-b border-slate-200 bg-white select-none shrink-0">
<button
type="button"
onClick={() => setSidebarTab('notes')}
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
sidebarTab === 'notes'
? 'border-sky-600 text-sky-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
({notes.length})
</button>
<button
type="button"
onClick={() => setSidebarTab('ai')}
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
sidebarTab === 'ai'
? 'border-sky-600 text-sky-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
AI
</button>
</div>
{/* 根据 Tab 渲染内容 */}
{sidebarTab === 'notes' ? (
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
{/* 天体标识符列表 */}
<div className="px-4 py-3 border-b border-slate-200 bg-white space-y-2 shrink-0">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase"> (CDS)</span>
<div className="flex items-center gap-1.5 select-none">
{(loadingTargets || identifyingTargets) && <Loader className="w-3 h-3 animate-spin text-slate-400" />}
{selectedPaper.has_markdown && (
<button
type="button"
onClick={() => handleIdentifyTargets(selectedPaper.bibcode)}
disabled={identifyingTargets || loadingTargets}
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-0.5"
title="从文献正文中自动识别天体目标并查询 CDS Sesame"
>
<Sparkles className="w-2.5 h-2.5" />
{targets.length > 0 ? '重新识别' : '自动识别'}
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 scrollbar-thin">
{targets.length === 0 ? (
<span className="text-[10px] text-slate-400 italic"></span>
) : (
targets.map((t, idx) => (
<button
key={idx}
type="button"
onClick={() => handleJumpToTarget(t)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-sky-50 hover:bg-sky-100 text-sky-700 hover:text-sky-900 border border-sky-100 hover:border-sky-200 text-[10px] font-bold cursor-pointer transition-all hover:scale-105 active:scale-95"
title={
[
`类型: ${t.otype || '?'}`,
`RA: ${t.ra || '?'} Dec: ${t.dec || '?'}`,
t.parallax != null
? `视差: ${t.parallax}${t.parallax_err != null ? `±${t.parallax_err}` : ''} mas`
: '视差: ?',
`V星等: ${t.v_magnitude != null ? t.v_magnitude : '?'}`,
t.pm_ra != null && t.pm_de != null
? `自行: RA ${t.pm_ra}, Dec ${t.pm_de} mas/yr`
: '自行: ?',
`视向速度: ${t.radial_velocity != null ? t.radial_velocity + ' km/s' : '?'}`,
`官方名: ${t.oname || t.target_name}`,
`别名: ${t.aliases?.join(', ') || '无'}`,
].join('\n')
}
>
{t.target_name}
{t.spectral_type && <span className="text-slate-400 font-medium font-mono">({t.spectral_type})</span>}
</button>
))
)}
</div>
{/* 手动关联输入框 */}
<form onSubmit={handleAssociateTarget} className="flex gap-1.5 mt-2">
<input
type="text"
value={manualTargetName}
onChange={e => setManualTargetName(e.target.value)}
placeholder="手动关联天体(如 M 31)..."
className="flex-1 bg-slate-50 border border-slate-200 rounded px-2.5 py-1 text-[10px] text-slate-900 placeholder-slate-400 leading-relaxed font-semibold focus:outline-none focus:bg-white focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10"
/>
<button
type="submit"
disabled={!manualTargetName.trim() || associatingTarget}
className="btn-console btn-console-primary px-2.5 py-1 rounded text-[10px] font-bold disabled:opacity-50 cursor-pointer"
>
{associatingTarget ? '...' : '关联'}
</button>
</form>
</div>
{/* 新建笔记输入区 */}
{selectedParagraphIdx !== null && (
<div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3 shrink-0">
<div className="text-xs font-bold text-slate-700"> #{selectedParagraphIdx + 1}</div>
{selectedText && (
<div className="text-xs text-slate-600 italic line-clamp-2 bg-slate-55 px-2.5 py-1.5 rounded border border-slate-200">
"{selectedText}"
</div>
)}
{/* 颜色标记 */}
<div className="flex gap-2 items-center">
<span className="text-xs text-slate-500 font-semibold">:</span>
<div className="flex gap-1.5">
{Object.entries(NOTE_COLORS).map(([color, style]) => {
const s = style as { bg: string; label: string };
return (
<button
key={color}
onClick={() => setNewNoteColor(color)}
className={`w-4 h-4 rounded-full border transition-transform ${
newNoteColor === color ? 'border-slate-800 scale-120 shadow-sm' : 'border-transparent'
} ${s.bg.replace('border-cyan-200', '')}`}
title={s.label}
/>
);
})}
</div>
</div>
<textarea
value={newNoteText}
onChange={e => setNewNoteText(e.target.value)}
placeholder="记录段落心得或重点..."
rows={3}
className="w-full bg-white border border-slate-300 rounded-lg text-xs text-slate-900 placeholder-slate-400 px-3 py-2 resize-none focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed"
/>
<div className="flex gap-2">
<button
onClick={handleCreateNote}
className="btn-console btn-console-primary flex-1 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1"
>
<PlusCircle className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setSelectedParagraphIdx(null); setSelectedText(''); setNewNoteText(''); }}
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold"
>
</button>
</div>
</div>
)}
{/* 笔记列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0 scrollbar-thin">
{notes.length === 0 ? (
<div className="text-center text-slate-400 text-xs py-12 space-y-2 select-none">
<Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
<div></div>
</div>
) : (
notes.map(note => {
const style = NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
return (
<div
key={note.id}
className="p-4 rounded-lg border text-xs bg-white border-slate-200 relative group overflow-hidden"
>
{/* 彩色左标记条 */}
<div className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`} />
<div className="text-slate-500 text-[10px] font-bold mb-1"> #{note.paragraph_index + 1}</div>
{note.selected_text && (
<div className="text-slate-505 italic line-clamp-2 mb-2 border-l-2 border-slate-200 pl-2">
"{note.selected_text}"
</div>
)}
<p className="text-slate-800 leading-relaxed font-medium">{note.note_text}</p>
<div className="flex items-center justify-between mt-3 border-t border-slate-100 pt-2 text-[10px] select-none">
<span className="text-slate-400 font-semibold">{note.created_at.split('T')[0]}</span>
<button
onClick={() => handleDeleteNote(note.id)}
className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})
)}
</div>
</div>
) : (
<div className="flex-1 overflow-hidden h-full">
<AIAssistantPanel
bibcode={selectedPaper.bibcode}
onJumpToSource={onJumpToSource}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,314 @@
// dashboard/src/components/reader/ReaderToolbar.tsx
import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History } from 'lucide-react';
import type { StandardPaper } from '../../types';
interface ReaderToolbarProps {
selectedPaper: StandardPaper;
library: StandardPaper[];
recentlySelected: StandardPaper[];
onSwitchPaper: (paper: StandardPaper) => void;
parsing: boolean;
handleParse: (bibcode: string, force?: boolean) => void;
translating: boolean;
handleTranslate: (bibcode: string, force?: boolean) => void;
vectorizing: boolean;
handleVectorize: (bibcode: string) => void;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notesCount: number;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
englishText: string;
chineseText: string;
// 来自 useReaderState 的状态
showSwitchMenu: boolean;
setShowSwitchMenu: (show: boolean) => void;
viewMode: 'bilingual' | 'english' | 'chinese';
setViewMode: (mode: 'bilingual' | 'english' | 'chinese') => void;
syncScroll: boolean;
setSyncScroll: (sync: boolean) => void;
showPdf: boolean;
}
export function ReaderToolbar({
selectedPaper,
library,
recentlySelected,
onSwitchPaper,
parsing,
handleParse,
translating,
handleTranslate,
vectorizing,
handleVectorize,
showNotesPanel,
setShowNotesPanel,
notesCount,
showConfirm,
englishText,
chineseText,
showSwitchMenu,
setShowSwitchMenu,
viewMode,
setViewMode,
syncScroll,
setSyncScroll,
showPdf,
}: ReaderToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-slate-200 pb-3 shrink-0">
<div className="flex-1 min-w-0 pr-4">
<h2 className="text-sm font-bold text-slate-900 line-clamp-1 leading-snug" title={selectedPaper.title}>
{selectedPaper.title}
</h2>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1 font-semibold">
<span>: {selectedPaper.pub_journal || '未标注'}</span>
<span></span>
<span>: {selectedPaper.bibcode}</span>
</div>
</div>
<div className="flex gap-2 items-center relative">
{/* 快速切换文献菜单 */}
<div className="relative shrink-0">
<button
type="button"
onClick={() => setShowSwitchMenu(!showSwitchMenu)}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0"
>
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
<span></span>
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
</button>
{showSwitchMenu && (
<>
<div className="fixed inset-0 z-45" onClick={() => setShowSwitchMenu(false)} />
<div className="absolute right-0 mt-1.5 w-72 rounded-xl bg-white border border-slate-200 shadow-xl py-1.5 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
{/* 最近阅读部分 */}
{recentlySelected.length > 0 && (
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
<History className="w-3.5 h-3.5 text-slate-400" />
<span></span>
</div>
{recentlySelected.map(paper => (
<button
key={`recent-${paper.bibcode}`}
onClick={() => {
onSwitchPaper(paper);
setShowSwitchMenu(false);
}}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold'
: 'border-transparent text-slate-700 font-medium'
}`}
>
<span className="truncate block leading-tight text-left">{paper.title}</span>
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
</button>
))}
</div>
)}
{/* 全部馆藏已下载文献 */}
<div>
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
<FileText className="w-3.5 h-3.5 text-slate-400" />
<span></span>
</div>
{library.filter(p => p.is_downloaded).length === 0 ? (
<div className="px-3 py-2 text-slate-400 italic"></div>
) : (
library
.filter(p => p.is_downloaded)
.map(paper => (
<button
key={`lib-${paper.bibcode}`}
onClick={() => {
onSwitchPaper(paper);
setShowSwitchMenu(false);
}}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold'
: 'border-transparent text-slate-700 font-medium'
}`}
>
<span className="truncate block leading-tight text-left">{paper.title}</span>
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
</button>
))
)}
</div>
</div>
</>
)}
</div>
{/* 原文/中文/对照视图切换按钮 */}
{(englishText || chineseText) && (
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
<button
type="button"
onClick={() => setViewMode('english')}
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'english'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('chinese')}
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'chinese'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('bilingual')}
className={`hidden md:block px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'bilingual'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
)}
{/* 同步滚动开关 */}
{(englishText || chineseText) && viewMode === 'bilingual' && (
<button
type="button"
onClick={() => {
if (showPdf) {
alert("【PDF同步滚动技术限制说明】\n\nPDF 预览采用的是浏览器原生的 PDF 插件沙箱。受底层浏览器跨域安全限制和外部插件机制影响,网页脚本无法直接读取和控制 PDF 的滚动位置,因而无法实现物理同步滚动。\n\n【强烈建议】\n点击左侧面板顶部的「显示正文」按钮切换到解析正文模式。在解析正文模式下系统将自动激活「自适应双轨滚动同步引擎方案三提供段落级的高精度无缝对齐同步滚动体验");
return;
}
setSyncScroll(!syncScroll);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 border transition-all cursor-pointer ${
showPdf
? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed opacity-70'
: syncScroll
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
title={showPdf ? 'PDF预览模式下不支持同步滚动请点击左侧“显示正文”切换到正文模式' : '开启或关闭中英段落双轨同步滚动'}
>
<span className={`w-1.5 h-1.5 rounded-full ${(!showPdf && syncScroll) ? 'bg-sky-500 animate-pulse' : 'bg-slate-300'}`} />
<span>{showPdf ? 'PDF暂不支持' : syncScroll ? '开启' : '关闭'}</span>
</button>
)}
{/* 1. 正文解析按钮 */}
{!selectedPaper.has_markdown ? (
<button
onClick={() => handleParse(selectedPaper.bibcode)}
disabled={parsing}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <FileText className="w-3.5 h-3.5" />}
{parsing ? '正文结构解析中...' : '解析源文正文'}
</button>
) : (
<button
onClick={() => {
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
handleParse(selectedPaper.bibcode, true);
}, '确认重新解析');
}}
disabled={parsing}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="覆盖本地解析结果,重新从 HTML/PDF 转换为 Markdown"
>
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
{/* 2. 术语对照翻译按钮 */}
{selectedPaper.has_markdown && !selectedPaper.has_translation && (
<button
onClick={() => handleTranslate(selectedPaper.bibcode)}
disabled={translating}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Languages className="w-3.5 h-3.5" />}
{translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
</button>
)}
{selectedPaper.has_translation && (
<button
onClick={() => handleTranslate(selectedPaper.bibcode, true)}
disabled={translating}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="清除翻译缓存并重新生成大模型对照翻译"
>
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
{/* 3. 知识入库按钮 */}
{selectedPaper.has_markdown && !selectedPaper.has_vector && (
<button
onClick={() => handleVectorize(selectedPaper.bibcode)}
disabled={vectorizing}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="对文献进行知识入库,以开启学术 AI 研讨"
>
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
{vectorizing ? '正在进行知识入库...' : '知识入库'}
</button>
)}
{selectedPaper.has_markdown && selectedPaper.has_vector && (
<button
onClick={() => {
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
handleVectorize(selectedPaper.bibcode);
}, '确认重新知识入库');
}}
disabled={vectorizing}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="重新为该文献解析知识点并入库"
>
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
{/* 4. 学术助手按钮 */}
<button
onClick={() => {
setShowNotesPanel(!showNotesPanel);
}}
className={`btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer ${
showNotesPanel ? 'bg-sky-50 text-sky-700 border-sky-200 shadow-xs' : 'btn-console-secondary'
}`}
title="开启或关闭侧边学术助手(包含阅读手札与 AI 问答)"
>
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
<span></span>
{notesCount > 0 && (
<span className="px-1.5 py-0.2 text-[9px] rounded-full bg-sky-100 text-sky-850 font-extrabold select-none shrink-0">
{notesCount}
</span>
)}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,280 @@
// dashboard/src/components/sync/BatchPipelineCard.tsx
import React from 'react';
import { Download, AlertTriangle, Play, StopCircle, Loader, FileText, RefreshCw, SlidersHorizontal } from 'lucide-react';
import { CustomSelect } from '../CustomSelect';
import type { BatchStatus } from '../../hooks/useSyncState';
interface BatchPipelineCardProps {
targetPhase: 'download' | 'parse' | 'translate' | 'embed' | 'target';
setTargetPhase: (p: 'download' | 'parse' | 'translate' | 'embed' | 'target') => void;
batchLimitCount: number;
setBatchLimitCount: (c: number) => void;
sortOrder: 'default' | 'pub_year_desc' | 'created_at_desc';
setSortOrder: (o: 'default' | 'pub_year_desc' | 'created_at_desc') => void;
skipCompleted: boolean;
setSkipCompleted: (s: boolean) => void;
skipFailed: boolean;
setSkipFailed: (s: boolean) => void;
skipPrecedingFailed: boolean;
setSkipPrecedingFailed: (s: boolean) => void;
skipPrecedingUncompleted: boolean;
setSkipPrecedingUncompleted: (s: boolean) => void;
batchStatus: BatchStatus;
batchError: string | null;
logsContainerRef: React.RefObject<HTMLDivElement | null>;
handleStartBatch: () => Promise<void>;
handleStopBatch: () => Promise<void>;
}
export function BatchPipelineCard({
targetPhase,
setTargetPhase,
batchLimitCount,
setBatchLimitCount,
sortOrder,
setSortOrder,
skipCompleted,
setSkipCompleted,
skipFailed,
setSkipFailed,
skipPrecedingFailed,
setSkipPrecedingFailed,
skipPrecedingUncompleted,
setSkipPrecedingUncompleted,
batchStatus,
batchError,
logsContainerRef,
handleStartBatch,
handleStopBatch,
}: BatchPipelineCardProps) {
return (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden shadow-xs">
<div className="flex flex-col gap-1 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" />
<span>线</span>
</h3>
<p className="text-slate-500 text-xs mt-0.5">
PDF/HTML Markdown
</p>
</div>
{batchError && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{batchError}</div>
</div>
)}
{/* 条件设置 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={val => setTargetPhase(val as any)}
className="w-full"
options={[
{ value: 'download', label: '下载' },
{ value: 'parse', label: '解析' },
{ value: 'translate', label: '翻译' },
{ value: 'embed', label: '向量化' },
{ value: 'target', label: '天体识别' },
]}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<input
type="number"
value={batchLimitCount}
disabled={batchStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={val => setSortOrder(val as any)}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },
{ value: 'pub_year_desc', label: '按出版年份降序' },
{ value: 'created_at_desc', label: '按入库时间降序' },
]}
/>
</div>
</div>
{/* 执行策略 */}
<div className="space-y-2 border-t border-slate-100 pt-4">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 select-none">
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
</div>
</div>
{/* 控制按钮 */}
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{batchStatus.active ? (
<button
type="button"
onClick={handleStopBatch}
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartBatch}
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 cursor-pointer"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 进度与终端日志展示 */}
{(batchStatus.active || batchStatus.total > 0) && (
<div className="space-y-4 pt-4 border-t border-slate-200">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
<span className="flex items-center gap-1.5">
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-650" />}
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600 animate-spin" />}
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'download'
? '正文离线下载进度'
: batchStatus.action === 'parse'
? '结构化排版解析进度'
: batchStatus.action === 'translate'
? '中英双语对照翻译进度'
: batchStatus.action === 'embed'
? '段落向量分块入库进度'
: '天体识别与缓存进度'}
</span>
<span>
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total}
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} )
</span>
)}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200 select-none">
<div
className={`h-full transition-all duration-300 ${
batchStatus.action === 'download'
? 'bg-sky-600'
: batchStatus.action === 'parse'
? 'bg-emerald-600'
: batchStatus.action === 'translate'
? 'bg-indigo-600'
: batchStatus.action === 'embed'
? 'bg-amber-600'
: 'bg-sky-600'
}`}
style={{
width: `${
batchStatus.total > 0
? Math.min(
100,
Math.round(
((batchStatus.action === 'download'
? batchStatus.downloaded + batchStatus.download_failed
: batchStatus.parsed + batchStatus.parse_failed) /
batchStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{batchStatus.active && batchStatus.current_bibcode && (
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200 select-all">{batchStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block select-none"></label>
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all">
{batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic select-none">...</div>
) : (
batchStatus.logs.map((log: string, idx: number) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,74 @@
// dashboard/src/components/sync/BookmarkletToolCard.tsx
import { useRef, useEffect } from 'react';
// 书签代码常量
const BOOKMARKLET_CODE = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘 file://。\\n\\n提示对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
export function BookmarkletToolCard() {
const linkRef = useRef<HTMLAnchorElement | null>(null);
useEffect(() => {
if (linkRef.current) {
linkRef.current.setAttribute('href', BOOKMARKLET_CODE);
}
}, []);
const handleCopyCode = () => {
navigator.clipboard.writeText(BOOKMARKLET_CODE);
alert('书签代码已成功复制到剪贴板!');
};
return (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
Cloudflare/WAF PDF/HTML AstroResearch
</p>
</div>
<div className="space-y-3.5 text-xs text-slate-700">
<div className="space-y-1.5 font-bold select-none">
<span className="text-slate-500"></span>
<p className="text-[11px] text-slate-400 font-normal mt-1 leading-relaxed">
URL JavaScript
</p>
<div className="flex flex-wrap items-center gap-3 mt-2 select-none">
{/* 可直接拖拽的链接按钮 */}
<a
ref={linkRef}
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-650 hover:to-indigo-650 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
title="拖拽我到您的书签栏中"
onClick={(e) => e.preventDefault()}
>
<span style={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', opacity: 0, pointerEvents: 'none' }}>
AstroResearch
</span>
<span></span>
</a>
<button
onClick={handleCopyCode}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
>
JavaScript
</button>
</div>
</div>
<div className="space-y-1.5 font-bold mt-4 select-none">
<span className="text-slate-500">使</span>
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1 leading-relaxed">
<li> <span className="font-bold text-slate-750">BIBCODE / DOI / arXiv ID </span></li>
<li><span className="font-bold text-slate-750"></span></li>
<li><span className="font-bold text-slate-750"> Bibcode</span></li>
</ol>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,270 @@
// dashboard/src/components/sync/MetadataSyncCard.tsx
import { SlidersHorizontal, RefreshCw, Loader, Play, Info, CheckCircle, Lightbulb } from 'lucide-react';
import { CustomSelect } from '../CustomSelect';
import type { HarvestStatus } from '../../hooks/useSyncState';
interface MetadataSyncCardProps {
query: string;
setQuery: (q: string) => void;
source: 'all' | 'ads' | 'arxiv';
setSource: (s: 'all' | 'ads' | 'arxiv') => void;
limit: number;
setLimit: (l: number) => void;
estimating: boolean;
estimatedCount: number | null;
status: HarvestStatus;
showBuilder: boolean;
setShowBuilder: (show: boolean) => void;
rules: Array<{ field: string; op: string; val: string }>;
handleAddRule: () => void;
handleRemoveRule: (idx: number) => void;
handleRuleChange: (idx: number, key: 'field' | 'op' | 'val', value: string) => void;
handleEstimate: () => Promise<void>;
handleStartHarvest: () => Promise<void>;
}
export function MetadataSyncCard({
query,
setQuery,
source,
setSource,
limit,
setLimit,
estimating,
estimatedCount,
status,
showBuilder,
setShowBuilder,
rules,
handleAddRule,
handleRemoveRule,
handleRuleChange,
handleEstimate,
handleStartHarvest,
}: MetadataSyncCardProps) {
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6">
{/* 控制面板卡片 */}
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden bg-white border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span> (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1 cursor-pointer"
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
</button>
</label>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
/>
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
<span>:</span>
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<button
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all cursor-pointer ${
source === src.id
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'btn-console btn-console-secondary'
}`}
>
{src.label}
</button>
))}
</div>
</div>
</div>
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center select-none">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-750 font-bold cursor-pointer"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<CustomSelect
value={rule.op}
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold select-none"></div>
)}
<CustomSelect
value={rule.field}
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题' },
{ value: 'author', label: '作者' },
{ value: 'abs', label: '摘要' },
{ value: 'year', label: '年份' },
]}
/>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '请输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-red-600 hover:text-red-750 text-xs font-bold px-2 py-1.5 cursor-pointer"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span>
<span className="text-[11px] text-slate-450 font-normal"> API</span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="flex items-end gap-3">
<button
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-750 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40 cursor-pointer"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center select-none animate-in fade-in duration-250">
<Info className="w-4 h-4 shrink-0 text-sky-600" />
<div>
<strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div>
</div>
)}
</div>
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xs font-bold text-slate-850 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-655" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold leading-relaxed select-all">
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200 select-none">
<div
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5 select-none">
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
<span> arXiv 3000 </span>
</div>
) : null}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,921 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal, Lightbulb } from 'lucide-react';
import type { SavedSyncQuery } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function SyncPanel() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [limit, setLimit] = useState<number>(200);
const [estimating, setEstimating] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [status, setStatus] = useState<HarvestStatus>({
active: false,
query: '',
source: '',
synced: 0,
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
const [skipFailed, setSkipFailed] = useState<boolean>(false);
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
// 获取历史检索配置
const fetchSyncQueries = async () => {
try {
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
setSyncQueries(res.data);
} catch (e) {
console.error('获取检索配置列表失败', e);
}
};
const handleDeleteQuery = async (id: number) => {
try {
await axios.delete(`/api/sync/queries/${id}`);
fetchSyncQueries();
} catch (e) {
console.error('删除配置失败', e);
}
};
const handleReuseQuery = (sq: SavedSyncQuery) => {
setQuery(sq.query);
setSource(sq.source as any);
setLimit(sq.limit_count);
};
const handleQuickSync = async (sq: SavedSyncQuery) => {
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: sq.query,
source: sq.source as any,
synced: 0,
total: sq.limit_count,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
setStatus(res.data);
if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
fetchSyncQueries();
}
}
} catch (e) {
console.error('获取同步状态失败', e);
}
};
// 开始轮询
const startPolling = () => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(fetchStatus, 1000);
};
useEffect(() => {
fetchStatus();
fetchSyncQueries();
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/batch/asset/run', {
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopBatch = async () => {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchBatchStatus();
return () => {
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部 (仅限定滚动容器内部,不影响整个网页的滚动)
useEffect(() => {
const container = logsContainerRef.current;
if (container) {
// 阈值设为 80px。如果用户滚动到离底部距离小于 80px则视为保持跟踪底部的模式
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [batchStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setEstimating(true);
setEstimatedCount(null);
try {
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: query.trim(),
source,
synced: 0,
total: 0,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6 w-full max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs"> NASA ADS arXiv 线</p>
</div>
{errorMsg && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{errorMsg}</div>
</div>
)}
{/* 控制面板卡片 */}
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span> (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1"
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
</button>
</label>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
/>
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1">
<span>:</span>
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<button
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all ${
source === src.id
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'btn-console btn-console-secondary'
}`}
>
{src.label}
</button>
))}
</div>
</div>
</div>
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-700 font-bold"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<CustomSelect
value={rule.op}
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<CustomSelect
value={rule.field}
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题' },
{ value: 'author', label: '作者' },
{ value: 'abs', label: '摘要' },
{ value: 'year', label: '年份' },
]}
/>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '请输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span>
<span className="text-[11px] text-slate-450 font-normal"> API</span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="flex items-end gap-3">
<button
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center">
<Info className="w-4 h-4 shrink-0 text-sky-600" />
<div>
<strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div>
</div>
)}
</div>
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xs font-bold text-slate-800 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold">
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5">
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
<span> arXiv 3000 </span>
</div>
) : null}
</div>
)}
{/* 批量任务 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden">
<div className="flex flex-col gap-1">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" />
<span>线</span>
</h3>
<p className="text-slate-500 text-xs">
PDF/HTML Markdown
</p>
</div>
{batchError && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{batchError}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={val => setTargetPhase(val as any)}
className="w-full"
options={[
{ value: 'download', label: '下载' },
{ value: 'parse', label: '解析' },
{ value: 'translate', label: '翻译' },
{ value: 'embed', label: '向量化' },
{ value: 'target', label: '天体识别' },
]}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<input
type="number"
value={batchLimitCount}
disabled={batchStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={val => setSortOrder(val)}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },
{ value: 'pub_year_desc', label: '按出版年份降序' },
{ value: 'created_at_desc', label: '按入库时间降序' },
]}
/>
</div>
</div>
<div className="space-y-2 border-t border-slate-100 pt-4">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{batchStatus.active ? (
<button
type="button"
onClick={handleStopBatch}
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartBatch}
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 进度与终端日志展示 */}
{(batchStatus.active || batchStatus.total > 0) && (
<div className="space-y-4 pt-4 border-t border-slate-200">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600">
<span className="flex items-center gap-1">
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'download'
? '正文离线下载进度'
: batchStatus.action === 'parse'
? '结构化排版解析进度'
: batchStatus.action === 'translate'
? '中英双语对照翻译进度'
: batchStatus.action === 'embed'
? '段落向量分块入库进度'
: '天体识别与缓存进度'}
</span>
<span>
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total}
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} )
</span>
)}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className={`h-full transition-all duration-300 ${
batchStatus.action === 'download'
? 'bg-sky-600'
: batchStatus.action === 'parse'
? 'bg-emerald-600'
: batchStatus.action === 'translate'
? 'bg-indigo-600'
: batchStatus.action === 'embed'
? 'bg-amber-600'
: 'bg-sky-600'
}`}
style={{
width: `${
batchStatus.total > 0
? Math.min(
100,
Math.round(
((batchStatus.action === 'download'
? batchStatus.downloaded + batchStatus.download_failed
: batchStatus.parsed + batchStatus.parse_failed) /
batchStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{batchStatus.active && batchStatus.current_bibcode && (
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200">{batchStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative">
{batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic">...</div>
) : (
batchStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
{/* 已存同步检索配置 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-sky-600 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs">
</p>
</div>
{syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-400 italic">
</div>
) : (
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
{syncQueries.map(sq => (
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
<div className="space-y-1">
<div className="font-bold text-slate-800 break-all select-all font-mono">
{sq.query}
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
<span>: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
<span>: <strong className="text-slate-700">{sq.limit_count}</strong></span>
<span>: <strong className="text-slate-700">{sq.last_run}</strong></span>
</div>
</div>
<div className="flex gap-2 shrink-0 self-end sm:self-center">
<button
type="button"
onClick={() => handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
>
</button>
<button
type="button"
disabled={status.active}
onClick={() => handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-750 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 浏览器快捷直推书签工具 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-1">
Cloudflare/WAF PDF/HTML AstroResearch
</p>
</div>
<div className="space-y-3.5 text-xs text-slate-700">
<div className="space-y-1.5 font-bold">
<span className="text-slate-500"></span>
<p className="text-[11px] text-slate-400 font-normal mt-1">
URL JavaScript
</p>
<div className="flex flex-wrap items-center gap-3 mt-2">
{/* 可直接拖拽的链接按钮 */}
<a
ref={(el) => {
if (el) {
el.setAttribute(
'href',
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
);
}
}}
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-600 hover:to-indigo-600 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
title="拖拽我到您的书签栏中"
onClick={(e) => e.preventDefault()}
>
{/* 浏览器拖拽时读取全部 textContentsr-only span 的文字成为书签名 */}
<span style={{position:'absolute',width:'1px',height:'1px',overflow:'hidden',opacity:0,pointerEvents:'none'}}>AstroResearch</span>
<span aria-hidden="true"></span>
</a>
<button
onClick={() => {
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
navigator.clipboard.writeText(bookmarkletCode);
alert('书签代码已成功复制到剪贴板!');
}}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
>
JavaScript
</button>
</div>
</div>
<div className="space-y-1.5 font-bold mt-4">
<span className="text-slate-500">使</span>
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1">
<li> <span className="font-bold">BIBCODE / DOI / arXiv ID </span></li>
<li><span className="font-bold"></span></li>
<li><span className="font-bold"> Bibcode</span></li>
</ol>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
// dashboard/src/hooks/useAuth.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState<string | null>(null);
const [loggingIn, setLoggingIn] = useState(false);
// 全局启用 Axios 跨域凭证
useEffect(() => {
axios.defaults.withCredentials = true;
}, []);
// 校验登录状态
useEffect(() => {
axios.get('/api/auth/check')
.then(() => {
setIsAuthenticated(true);
})
.catch(() => {
setIsAuthenticated(false);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setLoginError('请输入访问密码!');
return;
}
setLoggingIn(true);
setLoginError(null);
try {
await axios.post('/api/auth/login', { password });
setIsAuthenticated(true);
setLoginError(null);
} catch (err: any) {
console.error('登录校验失败:', err);
const errMsg = err.response?.data || '密码错误,请重试。';
setLoginError(errMsg);
} finally {
setLoggingIn(false);
}
};
const handleLogout = async () => {
try {
await axios.post('/api/auth/logout');
} catch (e) {
console.error('登出失败:', e);
}
setIsAuthenticated(false);
setPassword('');
};
return {
isAuthenticated,
password,
setPassword,
loginError,
loggingIn,
handleLogin,
handleLogout,
};
}

View File

@ -0,0 +1,46 @@
// dashboard/src/hooks/useCitations.ts
import { useState, useCallback } from 'react';
import axios from 'axios';
import type { CitationNetwork } from '../types';
export function useCitations() {
const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null);
const [loadingCitations, setLoadingCitations] = useState(false);
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]);
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
const loadCitations = useCallback(async (bibcode: string, reset = false) => {
setLoadingCitations(true);
try {
const res = await axios.get<CitationNetwork>('/api/citations', {
params: { bibcode }
});
setCitationNetwork(res.data);
setCitationHistory(prev => {
if (reset) {
return [res.data];
}
if (prev.some(net => net.bibcode === res.data.bibcode)) {
return prev;
}
return [...prev, res.data];
});
} catch (e) {
console.error('加载引用拓扑失败', e);
} finally {
setLoadingCitations(false);
}
}, []);
return {
citationNetwork,
setCitationNetwork,
loadingCitations,
setLoadingCitations,
citationHistory,
setCitationHistory,
uncachedBibcode,
setUncachedBibcode,
loadCitations,
};
}

View File

@ -0,0 +1,216 @@
// dashboard/src/hooks/useLibrary.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
interface UseLibraryProps {
isAuthenticated: boolean | null;
showAlert: (message: string, title?: string) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
openReader: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
}
export function useLibrary({
isAuthenticated,
showAlert,
showConfirm,
openReader,
}: UseLibraryProps) {
const [library, setLibrary] = useState<StandardPaper[]>([]);
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null);
const [detailBibcode, setDetailBibcode] = useState<string | null>(null);
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(() => {
try {
const saved = localStorage.getItem('astro_recently_selected');
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error('Failed to parse recently selected papers', e);
return [];
}
});
const [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem('astro_recently_selected', JSON.stringify(recentlySelected));
}, [recentlySelected]);
const addPaperToRecent = (paper: StandardPaper) => {
setRecentlySelected(prev => {
const filtered = prev.filter(p => p.bibcode !== paper.bibcode);
return [paper, ...filtered].slice(0, 5);
});
};
const openCitation = (
paper: StandardPaper,
setActiveTab: (tab: any) => void,
loadCitations: (bibcode: string, reset?: boolean) => void
) => {
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode, true);
addPaperToRecent(paper);
};
const fetchLibrary = async () => {
try {
const res = await axios.get<StandardPaper[]>('/api/library');
setLibrary(res.data);
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
if (lastReadBibcode) {
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
if (lastReadPaper) {
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
openReader(lastReadPaper, initialTab !== 'reader');
}
}
} catch (e) {
console.error('加载本地文献库失败', e);
}
};
// 初始化加载本地文献库
useEffect(() => {
if (isAuthenticated === true) {
fetchLibrary();
}
}, [isAuthenticated]);
// 触发文献双格式下载
const handleDownload = async (bibcode: string, force = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: true }));
try {
const res = await axios.post<StandardPaper>('/api/download', { bibcode, force });
if (searchResultsSetter) {
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
}
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
} catch (e) {
console.error('下载文献失败', e);
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
} finally {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
}
};
// 手动上传文献文件以绕过反爬/人机验证
const handleManualUpload = async (bibcode: string, type: 'pdf' | 'html', file: File, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
setUploadingBibcode(bibcode);
const formData = new FormData();
formData.append('bibcode', bibcode);
formData.append('type', type);
formData.append('file', file);
try {
const res = await axios.post<StandardPaper>('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (searchResultsSetter) {
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
}
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
showAlert('手动文献文件上传导入成功!', '上传成功');
} catch (e: any) {
console.error('手动文件上传失败', e);
const errMsg = e.response?.data || '请确保上传的是合法且完整的文件。';
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
} finally {
setUploadingBibcode(null);
}
};
// 手动标记文献为“无有效全文资源”
const handleMarkNoResource = async (bibcode: string, clear = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
const performMark = async () => {
try {
const res = await axios.post<StandardPaper>('/api/no_resource', { bibcode, clear });
if (searchResultsSetter) {
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
}
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
showAlert(
clear
? '已成功清除“无全文资源”标记,该文献已被重新允许重载!'
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
'标记更新'
);
} catch (e: any) {
console.error('标记更新失败', e);
const errMsg = e.response?.data || '请稍后重试。';
showAlert(`标记失败: ${errMsg}`, '操作出错');
}
};
if (clear) {
performMark();
} else {
showConfirm(
'确认将此文献标记为“无有效全文资源”吗?\n标记后未来的批量下载/解析重试任务将自动跳过此文献。',
performMark,
'标记无有效全文资源'
);
}
};
// 衍生状态计算
const getDetailPaper = (searchResults: StandardPaper[]) => {
return library.find(p => p.bibcode === detailBibcode) || searchResults.find(p => p.bibcode === detailBibcode);
};
return {
library,
setLibrary,
selectedPaper,
setSelectedPaper,
detailBibcode,
setDetailBibcode,
recentlySelected,
setRecentlySelected,
downloadingBibcodes,
setDownloadingBibcodes,
uploadingBibcode,
addPaperToRecent,
openCitation,
fetchLibrary,
handleDownload,
handleManualUpload,
handleMarkNoResource,
getDetailPaper,
};
}

View File

@ -0,0 +1,69 @@
// dashboard/src/hooks/useNotes.ts
import { useState } from 'react';
import axios from 'axios';
import type { NoteRecord, StandardPaper } from '../types';
export function useNotes() {
const [notes, setNotes] = useState<NoteRecord[]>([]);
const [showNotesPanel, setShowNotesPanel] = useState(false);
const [newNoteText, setNewNoteText] = useState('');
const [newNoteColor, setNewNoteColor] = useState('yellow');
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<number | null>(null);
const [selectedText, setSelectedText] = useState('');
const handleCreateNote = async (selectedPaper: StandardPaper | null) => {
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim()) return;
try {
const res = await axios.post<NoteRecord>('/api/notes', {
bibcode: selectedPaper.bibcode,
paragraph_index: selectedParagraphIdx,
note_text: newNoteText.trim(),
highlight_color: newNoteColor,
selected_text: selectedText,
});
setNotes(prev => [...prev, res.data]);
setNewNoteText('');
setSelectedParagraphIdx(null);
setSelectedText('');
} catch (e) {
console.error('保存笔记失败', e);
}
};
const handleDeleteNote = async (id: number) => {
try {
await axios.delete('/api/notes', { params: { id } });
setNotes(prev => prev.filter(n => n.id !== id));
} catch (e) {
console.error('删除笔记失败', e);
}
};
// 选中文本时弹出笔记添加面板
const handleTextSelection = (paragraphIdx: number) => {
const sel = window.getSelection();
if (sel && sel.toString().trim().length > 3) {
setSelectedText(sel.toString().trim());
setSelectedParagraphIdx(paragraphIdx);
setShowNotesPanel(true);
}
};
return {
notes,
setNotes,
showNotesPanel,
setShowNotesPanel,
newNoteText,
setNewNoteText,
newNoteColor,
setNewNoteColor,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
handleCreateNote,
handleDeleteNote,
handleTextSelection,
};
}

View File

@ -0,0 +1,317 @@
// dashboard/src/hooks/useReaderState.ts
import { useState, useRef, useEffect, useMemo } from 'react';
import axios from 'axios';
import { defaultSchema } from 'rehype-sanitize';
import type { StandardPaper } from '../types';
import { getHighlightCandidates, type TargetInfo, ENABLE_LEGACY_UNICODE_SPACE_COMPAT } from '../utils/celestial';
import { useSyncScroll } from './useSyncScroll';
// LaTeX 及 HTML 过滤白名单配置
export const safeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
'*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display', 'data-target-name']),
'span': (defaultSchema.attributes?.['span'] || []).concat(['className', 'style', 'data-target-name']),
},
tagNames: (defaultSchema.tagNames || []).concat([
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation'
]),
};
// 读书笔记高亮配色映射表
export const NOTE_COLORS: Record<string, { bg: string; border: string; label: string; text: string }> = {
cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' },
amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' },
purple: { bg: 'bg-purple-50 border-purple-200', border: 'border-purple-300', label: '淡紫色', text: 'text-purple-800' },
green: { bg: 'bg-emerald-50 border-emerald-200', border: 'border-emerald-300', label: '荧光绿', text: 'text-emerald-800' },
};
interface UseReaderStateProps {
selectedPaper: StandardPaper;
englishText: string;
viewMode: 'bilingual' | 'english' | 'chinese';
setViewMode: (mode: 'bilingual' | 'english' | 'chinese') => void;
}
export function useReaderState({
selectedPaper,
englishText,
viewMode,
setViewMode,
}: UseReaderStateProps) {
const [showSwitchMenu, setShowSwitchMenu] = useState(false);
const [showPdf, setShowPdf] = useState(false);
const [syncScroll, setSyncScroll] = useState(true);
const hasMarkdown = !!englishText;
// 绑定双语自适应双轨同步滚动 (方案三)
const {
englishRef,
chineseRef,
handleEnglishScroll,
handleChineseScroll,
} = useSyncScroll(syncScroll, viewMode, showPdf, hasMarkdown, () => {
if (hoveredTarget) setHoveredTarget(null);
});
// 天体识别与 RAG 助手状态
const [targets, setTargets] = useState<TargetInfo[]>([]);
const [loadingTargets, setLoadingTargets] = useState(false);
const [manualTargetName, setManualTargetName] = useState('');
const [associatingTarget, setAssociatingTarget] = useState(false);
const [identifyingTargets, setIdentifyingTargets] = useState(false);
const [sidebarTab, setSidebarTab] = useState<'notes' | 'ai'>('notes');
const [hoveredTarget, setHoveredTarget] = useState<TargetInfo | null>(null);
const [hoverCardPos, setHoverCardPos] = useState<{ x: number; y: number; isAbove?: boolean } | null>(null);
const hoverTimeout = useRef<any>(null);
// 预先计算并缓存天体高亮匹配项
const highlightCandidates = useMemo(() => {
return getHighlightCandidates(targets);
}, [targets]);
// 获取天体关联列表
useEffect(() => {
const fetchTargets = async () => {
setLoadingTargets(true);
try {
const res = await axios.get<TargetInfo[]>('/api/target/list', {
params: { bibcode: selectedPaper.bibcode }
});
setTargets(res.data);
} catch (e) {
console.error('获取天体关联列表失败:', e);
} finally {
setLoadingTargets(false);
}
};
if (selectedPaper?.bibcode) {
fetchTargets();
setSidebarTab('notes');
}
}, [selectedPaper?.bibcode]);
// 自动从正文中提取天体并查询 CDS Sesame 缓存
const handleIdentifyTargets = async (bibcode: string) => {
setIdentifyingTargets(true);
try {
const res = await axios.post<{ targets: TargetInfo[] }>('/api/target/extract', { bibcode });
setTargets(res.data.targets);
} catch (e) {
console.error('天体识别失败:', e);
alert('从文献提取天体识别失败,请检查网络或后端连接。');
} finally {
setIdentifyingTargets(false);
}
};
// 手动关联天体
const handleAssociateTarget = async (e: React.FormEvent) => {
e.preventDefault();
if (!manualTargetName.trim() || associatingTarget) return;
setAssociatingTarget(true);
try {
const res = await axios.post<{ status: string; target: TargetInfo }>('/api/target/associate', {
bibcode: selectedPaper.bibcode,
object_name: manualTargetName.trim()
});
if (!targets.some(t => t.target_name.toLowerCase() === res.data.target.target_name.toLowerCase())) {
setTargets(prev => [...prev, res.data.target]);
}
setManualTargetName('');
} catch (e) {
console.error('手动关联天体失败:', e);
alert('关联天体失败,请确认天体名称是否正确,或者 CDS 接口可访问。');
} finally {
setAssociatingTarget(false);
}
};
const clearHoverTimeout = () => {
if (hoverTimeout.current) {
clearTimeout(hoverTimeout.current);
hoverTimeout.current = null;
}
};
// 悬浮显示气泡
const handleMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
const element = e.target as HTMLElement;
const targetName = element.getAttribute('data-target-name');
if (targetName) {
clearHoverTimeout();
const matchedTarget = targets.find(t =>
t.target_name.toLowerCase() === targetName.toLowerCase() ||
t.aliases.some(a => a.toLowerCase() === targetName.toLowerCase())
);
if (matchedTarget) {
if (!hoveredTarget || hoveredTarget.target_name !== matchedTarget.target_name) {
const rect = element.getBoundingClientRect();
// 使用 fixed 视口定位防止父级 overflow-y-auto 裁切
let x = rect.left;
if (x + 288 > window.innerWidth) {
x = window.innerWidth - 288 - 16;
}
// 像素级高内聚自适应翻转定位 (transform: translateY(-100%) 方案)
const estimatedHeight = 400; // 考虑参数与别名全满时的最大安全预估高度
let isAbove = false;
let y = rect.bottom + 2;
if (y + estimatedHeight > window.innerHeight) {
// 当天体文字靠下时,向上翻转定位在文字上方,并通过 isAbove 标记应用 CSS 平移
isAbove = true;
y = rect.top - 2;
}
setHoverCardPos({ x, y, isAbove });
setHoveredTarget(matchedTarget);
}
}
} else {
if (hoveredTarget && !hoverTimeout.current) {
hoverTimeout.current = setTimeout(() => {
setHoveredTarget(null);
hoverTimeout.current = null;
}, 500);
}
}
};
const handleContainerMouseLeave = () => {
if (hoveredTarget && !hoverTimeout.current) {
hoverTimeout.current = setTimeout(() => {
setHoveredTarget(null);
hoverTimeout.current = null;
}, 500);
}
};
const handleMouseLeaveTarget = () => {
if (!hoverTimeout.current) {
hoverTimeout.current = setTimeout(() => {
setHoveredTarget(null);
hoverTimeout.current = null;
}, 500);
}
};
// 点击侧边栏天体时,跳转并高亮正文中对应的天体
const handleJumpToTarget = (target: TargetInfo) => {
if (!englishText) return;
// 如果当前处于仅中文视图,自动切换为对照视图(移动端切换为英文视图)
if (viewMode === 'chinese') {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
setViewMode(isMobile ? 'english' : 'bilingual');
}
// 生成所有可能匹配的名称变体(包含空格与无空格变体、别名)
const cleanNames = new Set<string>();
// 历史存量数据 Unicode 异型空格兼容清洗函数 (根据 ENABLE_LEGACY_UNICODE_SPACE_COMPAT 开关自适应切换)
const normalizeSpace = (str: string) => {
if (ENABLE_LEGACY_UNICODE_SPACE_COMPAT) {
// 兼容模式:将所有特殊的 Unicode 不换行空格、窄空格、连续多空格统一替换为标准的单半角空格
return str.replace(/[\s\u00a0\u2000-\u200a\u202f\u205f\u3000]+/g, ' ').trim().toLowerCase();
}
// 纯净模式:仅进行标准的首尾空格清理与小写转换(无正则消耗)
return str.trim().toLowerCase();
};
const addNameAndVariants = (name: string) => {
const normalized = normalizeSpace(name);
if (!normalized) return;
cleanNames.add(normalized);
const spaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)\s+(\d+.*)$/i;
const match = normalized.match(spaceRegex);
if (match) {
cleanNames.add(`${match[1]}${match[2]}`);
}
const noSpaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)(\d+.*)$/i;
const matchNoSpace = normalized.match(noSpaceRegex);
if (matchNoSpace) {
cleanNames.add(`${matchNoSpace[1]} ${matchNoSpace[2]}`);
}
};
addNameAndVariants(target.target_name);
if (target.aliases) {
target.aliases.forEach(addNameAndVariants);
}
const paragraphs = englishText.split('\n\n');
const foundIdx = paragraphs.findIndex(para => {
const paraClean = normalizeSpace(para);
return Array.from(cleanNames).some(name => paraClean.includes(name));
});
if (foundIdx !== -1) {
setTimeout(() => {
const element = document.getElementById(`paragraph-block-${foundIdx}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 闪烁高亮段落背景
element.classList.add('bg-sky-50', 'ring-2', 'ring-sky-500/20', 'transition-all');
// 闪烁高亮段落内具体的天体标签
const targetSpans = element.querySelectorAll(`[data-target-name]`);
targetSpans.forEach(span => {
const attrVal = span.getAttribute('data-target-name')?.toLowerCase();
if (attrVal && Array.from(cleanNames).some(name => name === attrVal)) {
span.classList.add('bg-amber-200', 'scale-105', 'px-1', 'rounded', 'transition-all');
setTimeout(() => {
span.classList.remove('bg-amber-200', 'scale-105', 'px-1', 'rounded');
}, 3000);
}
});
setTimeout(() => {
element.classList.remove('bg-sky-50', 'ring-2', 'ring-sky-500/20');
}, 3000);
}
}, 150);
}
};
return {
showSwitchMenu,
setShowSwitchMenu,
showPdf,
setShowPdf,
syncScroll,
setSyncScroll,
targets,
loadingTargets,
manualTargetName,
setManualTargetName,
associatingTarget,
identifyingTargets,
sidebarTab,
setSidebarTab,
hoveredTarget,
setHoveredTarget,
hoverCardPos,
highlightCandidates,
englishRef,
chineseRef,
handleIdentifyTargets,
handleAssociateTarget,
handleMouseOver,
clearHoverTimeout,
handleContainerMouseLeave,
handleMouseLeaveTarget,
handleEnglishScroll,
handleChineseScroll,
handleJumpToTarget,
};
}

View File

@ -0,0 +1,971 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import {
useAutoScroll,
routeThought,
routeToolCall,
routeToolResult,
routeTextDelta,
} from '../components/agent';
import type { TimelineItem } from '../components/agent';
export interface SessionSummary {
session_id: string;
title: string;
model: string;
mode: string;
turn_count: number;
summary?: string | null;
created_at: string;
updated_at: string;
}
export interface SearchResult {
result_type: string;
session_id: string;
title?: string | null;
snippet: string;
created_at?: string | null;
}
export interface MessageRecord {
id: number;
agent_name: string;
turn_index: number;
step_index: number;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
thought?: string | null;
tool_calls?: ToolCall[] | null;
tool_call_id?: string | null;
token_count: number;
metadata?: any | null;
created_at: string;
}
export interface ToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string; // JSON string
};
}
export interface ActiveTurn {
question: string;
imagePath?: string;
timeline: TimelineItem[];
finalAnswer: string;
error?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface ProcessedTurn {
turn_index: number;
question: string;
questionMessageId?: number;
imagePath?: string;
timeline: TimelineItem[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
createdAt: string;
}
export interface RewindResult {
rewound_count: number;
target_preview: string;
new_turn_index: number;
session_id: string;
}
export interface BranchResult {
branch_session_id: string;
forked_at_message_id: number;
copied_count: number;
}
export interface RetryResult {
retried_message: string;
new_turn_index: number;
deleted_count: number;
session_id: string;
image_path?: string | null;
}
export interface AgentMode {
id: string;
name: string;
description: string;
icon: string;
}
interface UseResearchAgentProps {
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
showAlert?: (message: string, title?: string) => void;
}
export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentProps = {}) {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [activeTurn, setActiveTurn] = useState<ActiveTurn | null>(null);
const [streaming, setStreaming] = useState(false);
const [input, setInput] = useState('');
const [agentMode, setAgentMode] = useState('default');
const [agentModes, setAgentModes] = useState<AgentMode[]>([]);
const [thinking, setThinking] = useState(false);
const [pendingImage, setPendingImage] = useState<{ data?: string; path?: string; mime_type: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// 全文搜索历史记录状态
const [searchQuery, setSearchQuery] = useState('');
const [searchScopeOnlyCurrent, setSearchScopeOnlyCurrent] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [loadingSearch, setLoadingSearch] = useState(false);
// 展开折叠控制
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
// 新功能面板状态
const [showMetrics, setShowMetrics] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [hasPendingPermission, setHasPendingPermission] = useState(false);
const [hasPendingQuestion, setHasPendingQuestion] = useState(false);
const skipNextHistoryLoadRef = useRef(false);
const {
chatEndRef,
scrollContainerRef,
setShouldAutoScroll,
scrollToBottom,
handleScroll,
} = useAutoScroll([messages, activeTurn, streaming]);
// 将 File 转为 base64 并设置为 pendingImage
const attachImage = (file: File) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(',');
const data = commaIdx > -1 ? result.substring(commaIdx + 1) : result;
setPendingImage({ data, mime_type: file.type, name: file.name });
};
reader.readAsDataURL(file);
};
// 粘贴事件:从剪贴板获取图片
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) attachImage(file);
return;
}
}
};
// 获取会话列表
const fetchSessions = async (autoSelectFirst = false) => {
setLoadingSessions(true);
try {
const res = await axios.get<SessionSummary[]>('/api/chat/sessions');
setSessions(res.data);
// 如果有会话且当前没有选中,默认选中第一个
if (autoSelectFirst && res.data.length > 0 && !currentSessionId) {
setCurrentSessionId(res.data[0].session_id);
}
} catch (e) {
console.error('获取智能体会话列表失败:', e);
} finally {
setLoadingSessions(false);
}
};
// 初始化加载
useEffect(() => {
fetchSessions(false);
const fetchModes = async () => {
try {
const res = await axios.get<AgentMode[]>('/api/chat/modes');
setAgentModes(res.data);
} catch (e) {
console.error('获取 Agent 运行模式列表失败:', e);
// Fallback in case of API failure for resilience
setAgentModes([
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
]);
}
};
fetchModes();
}, []);
// 加载选中的会话历史消息
const loadSessionHistory = async (sessionId: string, silent: boolean = false, onSuccess?: () => void) => {
if (!silent) {
setLoadingHistory(true);
}
setShouldAutoScroll(false);
try {
const res = await axios.get<{ session: SessionSummary; messages: MessageRecord[] }>(`/api/chat/sessions/${sessionId}`);
const newMessages = res.data.messages;
if (res.data.session && res.data.session.mode) {
setAgentMode(res.data.session.mode);
}
if (onSuccess) {
onSuccess();
}
setMessages(newMessages);
// 加载历史后瞬时跳到底部(无动画),并在渲染稳定后恢复自动滚动状态
requestAnimationFrame(() => {
scrollToBottom('auto');
setTimeout(() => {
setShouldAutoScroll(true);
}, 50);
});
// 自动迁移流式工具调用的展开/折叠状态至历史视图
const newTurnIndex = newMessages.length > 0 ? newMessages[newMessages.length - 1].turn_index : 0;
newMessages.forEach(msg => {
if (msg.turn_index === newTurnIndex && msg.role === 'assistant' && msg.tool_calls) {
msg.tool_calls.forEach(tc => {
setExpandedArgs(prev => {
if (prev[tc.id] !== undefined) return prev;
return { ...prev, [tc.id]: false };
});
setExpandedResults(prev => {
if (prev[tc.id] !== undefined) return prev;
return { ...prev, [tc.id]: false };
});
});
}
});
} catch (e) {
console.error('加载会话消息历史失败:', e);
} finally {
if (!silent) {
setLoadingHistory(false);
}
}
};
// 会话切换监听
useEffect(() => {
if (currentSessionId) {
if (skipNextHistoryLoadRef.current) {
skipNextHistoryLoadRef.current = false;
} else {
loadSessionHistory(currentSessionId);
}
} else {
setMessages([]);
}
}, [currentSessionId]);
// 跨会话历史检索防抖逻辑
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
const delayDebounceFn = setTimeout(async () => {
setLoadingSearch(true);
try {
const params: Record<string, any> = {
q: searchQuery,
scope: 'all',
limit: 30,
};
if (searchScopeOnlyCurrent && currentSessionId) {
params.session_id = currentSessionId;
}
const res = await axios.get<SearchResult[]>('/api/search/history', {
params,
});
setSearchResults(res.data);
} catch (e) {
console.error('搜索会话历史失败:', e);
} finally {
setLoadingSearch(false);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery, searchScopeOnlyCurrent, currentSessionId]);
// 新建会话
const handleNewSession = () => {
setCurrentSessionId(null);
setMessages([]);
setActiveTurn(null);
};
// 删除会话
const handleDeleteSession = async (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
const performDelete = async () => {
try {
await axios.delete(`/api/chat/sessions/${sessionId}`);
setSessions(prev => prev.filter(s => s.session_id !== sessionId));
if (currentSessionId === sessionId) {
setCurrentSessionId(null);
setMessages([]);
}
} catch (err) {
console.error('删除会话失败:', err);
if (showAlert) {
showAlert('删除会话失败,请稍后重试。', '删除出错');
} else {
alert('删除会话失败,请稍后重试。');
}
}
};
if (showConfirm) {
showConfirm('确认删除此研讨会话吗?删除后将无法恢复。', performDelete, '确认删除');
} else {
if (window.confirm('确认删除此研讨会话吗?删除后将无法恢复。')) {
performDelete();
}
}
};
// 回退会话到指定消息
const handleRewind = async (messageId?: number, n?: number) => {
if (!currentSessionId) return;
const msg = messageId
? '确定要回退到此消息之前吗?此后的对话将被移除(可通过“恢复”按钮撤销)。'
: `确定要回退最近 ${n || 1} 个对话轮次吗?`;
const performRewind = async () => {
try {
const body: { n?: number; message_id?: number } = {};
if (messageId) body.message_id = messageId;
else body.n = n || 1;
const res = await axios.post<RewindResult>(
`/api/chat/sessions/${currentSessionId}/rewind`,
body
);
if (showAlert) {
showAlert(`已回退 ${res.data.rewound_count} 条消息`, '成功');
} else {
alert(`已回退 ${res.data.rewound_count} 条消息`);
}
// 重新加载会话(静默刷新,避免闪烁)
loadSessionHistory(currentSessionId, true);
fetchSessions(); // 更新侧栏 turn_count
} catch (e: any) {
const errMsg = `回退失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
if (showConfirm) {
showConfirm(msg, performRewind, '确认回退');
} else {
if (window.confirm(msg)) {
performRewind();
}
}
};
// 恢复上次回退undo-of-undo
const handleRestoreRewind = async () => {
if (!currentSessionId) return;
try {
const res = await axios.post<{ restored_count: number }>(
`/api/chat/sessions/${currentSessionId}/rewind/restore`
);
if (res.data.restored_count > 0) {
const msg = `已恢复 ${res.data.restored_count} 条被回退的消息`;
if (showAlert) {
showAlert(msg, '成功');
} else {
alert(msg);
}
loadSessionHistory(currentSessionId, true);
fetchSessions();
} else {
const msg = '没有可恢复的回退操作';
if (showAlert) {
showAlert(msg, '提示');
} else {
alert(msg);
}
}
} catch (e: any) {
const errMsg = `恢复失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
// 分叉当前会话
const handleBranch = async () => {
if (!currentSessionId) return;
const performBranch = async () => {
try {
const res = await axios.post<BranchResult>(
`/api/chat/sessions/${currentSessionId}/branch`
);
if (showAlert) {
showAlert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`, '成功');
} else {
alert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`);
}
// 刷新列表并选中新的分叉会话
await fetchSessions();
setCurrentSessionId(res.data.branch_session_id);
} catch (e: any) {
const errMsg = `分叉失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
const msg = '确定要将当前活跃的消息分叉到一个新会话吗?';
if (showConfirm) {
showConfirm(msg, performBranch, '确认分叉会话');
} else {
if (window.confirm(msg)) {
performBranch();
}
}
};
// 重试最后一轮
const handleRetry = async () => {
if (!currentSessionId || streaming) return;
const performRetry = async () => {
try {
const res = await axios.post<RetryResult>(
`/api/chat/sessions/${currentSessionId}/retry`
);
const questionText = res.data.retried_message;
if (!questionText) {
if (showAlert) {
showAlert('没有找到可重试的上一轮问题', '提示');
} else {
alert('没有找到可重试的上一轮问题');
}
return;
}
// 重新加载会话以移除已被硬删除的消息,然后自动触发发送
await loadSessionHistory(currentSessionId, true);
await fetchSessions();
// 如果原消息附带了图片,通过 path 复用已有文件,避免重新上传
if (res.data.image_path) {
const ext = res.data.image_path.split('.').pop() || 'png';
const mimeType = { jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', png: 'image/png' }[ext] || 'image/png';
handleSend(questionText, { path: res.data.image_path, mime_type: mimeType, name: res.data.image_path.split('/').pop() || 'image' });
return;
}
// 触发自动重发
handleSend(questionText);
} catch (e: any) {
const errMsg = `重试失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
const msg = '确定要重试最后一轮对话吗?原回答将被删除。';
if (showConfirm) {
showConfirm(msg, performRetry, '确认重试对话');
} else {
if (window.confirm(msg)) {
performRetry();
}
}
};
// 手动停止智能体执行
const handleStop = async () => {
if (!currentSessionId) return;
try {
await axios.post(`/api/chat/sessions/${currentSessionId}/stop`);
} catch (e) {
console.error('停止智能体执行失败:', e);
}
};
// 发送消息与流式响应处理
const handleSend = async (questionText: string, imageOverride?: { data?: string; path?: string; mime_type: string; name: string } | null) => {
if (!questionText.trim() || streaming) return;
if (!currentSessionId) {
skipNextHistoryLoadRef.current = true;
}
setInput('');
const image = imageOverride !== undefined ? imageOverride : pendingImage;
setPendingImage(null);
setStreaming(true);
setShouldAutoScroll(true);
const active: ActiveTurn = {
question: questionText,
imagePath: image
? (image.path ? `/api/files/${image.path}` : `data:${image.mime_type};base64,${image.data}`)
: undefined,
timeline: [],
finalAnswer: '',
};
setActiveTurn(active);
try {
const response = await fetch('/api/chat/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question: questionText,
session_id: currentSessionId,
mode: agentMode,
thinking,
...(image ? {
image: image.path
? { path: image.path, mime_type: image.mime_type }
: { data: image.data, mime_type: image.mime_type },
} : {}),
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('ReadableStream not supported');
}
const decoder = new TextDecoder('utf-8');
let buffer = '';
let activeSessionId = currentSessionId;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('data:')) {
const dataStr = trimmed.slice(5).trim();
if (!dataStr) continue;
try {
const event = JSON.parse(dataStr);
switch (event.type) {
case 'session':
if (!activeSessionId) {
activeSessionId = event.session_id;
setCurrentSessionId(event.session_id);
fetchSessions();
} else if (event.title) {
setSessions(prev => prev.map(s =>
s.session_id === event.session_id ? { ...s, title: event.title } : s
));
}
break;
case 'thought':
setActiveTurn(prev => {
if (!prev) return prev;
return { ...prev, timeline: routeThought(prev.timeline, event.step, event.content) };
});
break;
case 'tool_call':
setActiveTurn(prev => {
if (!prev) return prev;
return {
...prev,
timeline: routeToolCall(prev.timeline, event.step, event.id, event.name, event.arguments),
};
});
break;
case 'tool_result':
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
setActiveTurn(prev => {
if (!prev) return prev;
return {
...prev,
timeline: routeToolResult(prev.timeline, event.tool_call_id, event.name, event.output, event.is_error),
};
});
break;
case 'text_delta':
if (event.tool_call_id) {
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
}
setActiveTurn(prev => {
if (!prev) return prev;
if (event.tool_call_id) {
const hasToolCall = prev.timeline.some(
t => t.type === 'tool_call' && 'id' in t && t.id === event.tool_call_id,
);
if (!hasToolCall) return prev;
}
const { timeline, answerDelta } = routeTextDelta(
prev.timeline, event.content, event.tool_call_id,
);
return {
...prev,
timeline,
finalAnswer: prev.finalAnswer + answerDelta,
};
});
break;
case 'usage':
setActiveTurn(prev => {
if (!prev) return prev;
return { ...prev, usage: event };
});
break;
case 'error':
setActiveTurn(prev => {
if (!prev) return prev;
return { ...prev, error: event.message };
});
break;
}
} catch (err) {
console.error('解析 SSE 数据包失败:', err, trimmed);
}
}
}
}
setStreaming(false);
if (activeSessionId) {
loadSessionHistory(activeSessionId, true, () => {
setActiveTurn(null);
});
fetchSessions();
} else {
setActiveTurn(null);
}
} catch (e: any) {
console.error('智能体对话请求失败:', e);
setActiveTurn(prev => prev ? { ...prev, error: e.message || '网络连接错误,请稍后重试' } : null);
setStreaming(false);
}
};
const turns = groupMessagesIntoTurns(messages);
const toggleThought = (key: string) => setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
const toggleArgs = (tcId: string) => setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
const toggleResult = (tcId: string) => setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
const toggleSubAgent = (id: string) => setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
return {
sessions,
currentSessionId,
setCurrentSessionId,
messages,
activeTurn,
streaming,
input,
setInput,
agentMode,
setAgentMode,
agentModes,
thinking,
setThinking,
pendingImage,
setPendingImage,
loadingSessions,
loadingHistory,
sidebarCollapsed,
setSidebarCollapsed,
searchQuery,
setSearchQuery,
searchScopeOnlyCurrent,
setSearchScopeOnlyCurrent,
searchResults,
loadingSearch,
expandedThoughts,
expandedArgs,
expandedResults,
collapsedSubAgents,
showMetrics,
setShowMetrics,
showAuditLog,
setShowAuditLog,
hasPendingPermission,
setHasPendingPermission,
hasPendingQuestion,
setHasPendingQuestion,
fileInputRef,
chatEndRef,
scrollContainerRef,
handleScroll,
attachImage,
handlePaste,
fetchSessions,
loadSessionHistory,
handleNewSession,
handleDeleteSession,
handleRewind,
handleRestoreRewind,
handleBranch,
handleRetry,
handleStop,
handleSend,
turns,
toggleThought,
toggleArgs,
toggleResult,
toggleSubAgent,
};
}
// 辅助函数:将消息列表还原为以 TimelineItem[] 组织的 turns
function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
const turnsMap = new Map<number, ProcessedTurn>();
const nonSystemMessages = messages.filter(m => m.role !== 'system');
const leadMessages = nonSystemMessages.filter(m => !m.metadata?.is_subagent && !m.agent_name?.startsWith('sub_'));
const subMessages = nonSystemMessages.filter(m => m.metadata?.is_subagent || m.agent_name?.startsWith('sub_'));
const subByAgent = new Map<string, MessageRecord[]>();
for (const sm of subMessages) {
const key = sm.metadata?.agent || sm.agent_name || 'unknown';
if (!subByAgent.has(key)) subByAgent.set(key, []);
subByAgent.get(key)!.push(sm);
}
for (const msg of leadMessages) {
let turn = turnsMap.get(msg.turn_index);
if (!turn) {
turn = {
turn_index: msg.turn_index,
question: '',
timeline: [],
createdAt: msg.created_at,
};
turnsMap.set(msg.turn_index, turn);
}
if (msg.token_count > 0) {
if (!turn.usage) {
turn.usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
}
if (msg.role === 'assistant') {
turn.usage.completion_tokens += msg.token_count;
} else {
turn.usage.prompt_tokens += msg.token_count;
}
turn.usage.total_tokens = turn.usage.prompt_tokens + turn.usage.completion_tokens;
}
if (msg.role === 'user') {
turn.question = msg.content;
turn.questionMessageId = msg.id;
if (msg.metadata?.image_path) {
turn.imagePath = `/api/files/${msg.metadata.image_path}`;
}
} else if (msg.role === 'assistant') {
const stepNum = msg.step_index;
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
const hasThought = !!msg.thought;
const hasContent = !!msg.content;
if (hasToolCalls) {
if (hasThought) {
const alreadyExists = turn.timeline.some(
t => t.type === 'thought' && t.step === stepNum
);
if (!alreadyExists) {
turn.timeline.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
}
for (const tc of msg.tool_calls!) {
let parsedArgs = {};
try {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
} catch { /* ignore */ }
turn.timeline.push({
type: 'tool_call',
step: stepNum,
id: tc.id,
name: tc.function.name,
arguments: parsedArgs,
});
}
} else if (hasThought && hasContent) {
const alreadyExists = turn.timeline.some(
t => t.type === 'thought' && t.step === stepNum
);
if (!alreadyExists) {
turn.timeline.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
turn.timeline.push({ type: 'answer', content: msg.content! });
} else if (hasContent) {
turn.timeline.push({ type: 'answer', content: msg.content! });
}
} else if (msg.role === 'tool') {
for (const item of turn.timeline) {
if (item.type === 'tool_call' && item.id === msg.tool_call_id && !item.result) {
let isError = false;
if (msg.content.startsWith('错误:') || msg.content.includes('fail') || msg.content.startsWith('Error:')) {
isError = true;
}
item.result = { output: msg.content, isError };
break;
}
}
}
}
for (const [, turn] of turnsMap) {
for (let i = 0; i < turn.timeline.length; i++) {
const item = turn.timeline[i];
if (item.type !== 'tool_call' || (item.name !== 'subagent' && item.name !== 'delegate_research')) continue;
const delegateResult = item.result?.output || '';
const delegateTcId = item.id;
let subChildren: TimelineItem[] = [];
for (const [agentName, msgs] of subByAgent) {
if (msgs.length === 0) continue;
const filtered = msgs.filter(m => m.role !== 'system' && m.role !== 'user');
if (filtered.length === 0) continue;
const children: TimelineItem[] = [];
for (const msg of filtered) {
if (msg.role === 'assistant') {
const stepNum = msg.step_index;
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
const hasThought = !!msg.thought;
const hasContent = !!msg.content;
if (hasToolCalls) {
if (hasThought) {
const alreadyExists = children.some(
c => c.type === 'thought' && c.step === stepNum
);
if (!alreadyExists) {
children.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
}
for (const tc of msg.tool_calls!) {
let parsedArgs = {};
try {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
} catch { /* ignore */ }
children.push({
type: 'tool_call',
step: stepNum,
id: tc.id,
name: tc.function.name,
arguments: parsedArgs,
});
}
} else if (hasThought && hasContent) {
const alreadyExists = children.some(
c => c.type === 'thought' && c.step === stepNum
);
if (!alreadyExists) {
children.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
children.push({ type: 'answer', content: msg.content! });
} else if (hasContent) {
children.push({ type: 'answer', content: msg.content! });
}
} else if (msg.role === 'tool') {
for (const child of children) {
if (child.type === 'tool_call' && child.id === msg.tool_call_id && !child.result) {
let isError = false;
if (msg.content.startsWith('错误:') || msg.content.includes('fail') || msg.content.startsWith('Error:')) {
isError = true;
}
child.result = { output: msg.content, isError };
break;
}
}
}
}
subChildren = children;
subByAgent.delete(agentName);
break;
}
turn.timeline[i] = {
type: 'subagent_container',
id: delegateTcId,
status: 'complete',
children: subChildren,
summary: delegateResult,
};
}
}
const sortedTurns = Array.from(turnsMap.values()).sort((a, b) => a.turn_index - b.turn_index);
return sortedTurns;
}

View File

@ -0,0 +1,123 @@
// dashboard/src/hooks/useSearch.ts
import { useState } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
interface UseSearchProps {
showAlert: (message: string, title?: string) => void;
}
export function useSearch({ showAlert }: UseSearchProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [searchResults, setSearchResults] = useState<StandardPaper[]>([]);
const [searching, setSearching] = useState(false);
const [exportingList, setExportingList] = useState<string[]>([]);
const [bibtexContent, setBibtexContent] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [searchRows, setSearchRows] = useState(15);
const [searchStart, setSearchStart] = useState(0);
const [searchSort, setSearchSort] = useState('relevance');
const [searchCache, setSearchCache] = useState<Record<string, StandardPaper[]>>({});
const executeSearch = async (start: number, rows: number, sort: string) => {
if (!searchQuery.trim()) return;
// 构造缓存 Key
const cacheKey = `${searchQuery.trim()}_${searchSource}_${start}_${rows}_${sort}`;
if (searchCache[cacheKey]) {
setSearchResults(searchCache[cacheKey]);
return;
}
setSearching(true);
setBibtexContent(null);
try {
const res = await axios.get<StandardPaper[]>('/api/search', {
params: { q: searchQuery, source: searchSource, rows, start, sort }
});
setSearchResults(res.data);
// 写入缓存
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
} catch (e) {
console.error('检索文献失败', e);
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
} finally {
setSearching(false);
}
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
setSearchStart(0);
executeSearch(0, searchRows, searchSort);
};
const handlePageChange = (newStart: number) => {
setSearchStart(newStart);
executeSearch(newStart, searchRows, searchSort);
};
const handleSortChange = (newSort: string) => {
setSearchSort(newSort);
setSearchStart(0);
executeSearch(0, searchRows, newSort);
};
const handleRowsChange = (newRows: number) => {
setSearchRows(newRows);
setSearchStart(0);
executeSearch(0, newRows, searchSort);
};
const handleExportBibtex = async () => {
if (exportingList.length === 0) return;
setExporting(true);
try {
const res = await axios.post<{ bibtex: string }>('/api/export', { bibcodes: exportingList });
setBibtexContent(res.data.bibtex);
} catch (e) {
console.error('导出 BibTeX 失败', e);
showAlert('导出 BibTeX 失败,请检查 ADS Token。', '导出失败');
} finally {
setExporting(false);
}
};
const toggleExportItem = (bibcode: string) => {
setExportingList(prev =>
prev.includes(bibcode) ? prev.filter(b => b !== bibcode) : [...prev, bibcode]
);
};
return {
searchQuery,
setSearchQuery,
searchSource,
setSearchSource,
searchResults,
setSearchResults,
searching,
setSearching,
exportingList,
setExportingList,
bibtexContent,
setBibtexContent,
exporting,
setExporting,
searchRows,
setSearchRows,
searchStart,
setSearchStart,
searchSort,
setSearchSort,
searchCache,
executeSearch,
handleSearch,
handlePageChange,
handleSortChange,
handleRowsChange,
handleExportBibtex,
toggleExportItem,
};
}

View File

@ -0,0 +1,210 @@
// dashboard/src/hooks/useSyncScroll.ts
// 双语分栏对照阅读器的高精度双轨同步滚动引擎 (段落物理锚定与全局比例自适应回退)
import { useRef, useCallback } from 'react';
export function useSyncScroll(
syncScroll: boolean,
viewMode: 'bilingual' | 'english' | 'chinese',
showPdf: boolean,
hasMarkdown: boolean,
onScrollStart?: () => void
) {
const englishRef = useRef<HTMLDivElement>(null);
const chineseRef = useRef<HTMLDivElement>(null);
const scrollLock = useRef(false);
// 英文滚动同步到中文
const handleEnglishScroll = useCallback(() => {
if (!syncScroll || viewMode !== 'bilingual') return;
if (scrollLock.current) return;
if (onScrollStart) onScrollStart();
const eng = englishRef.current;
const chn = chineseRef.current;
if (!eng || !chn) return;
scrollLock.current = true;
// ── 判定是否执行全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
return;
}
// ── 执行高精度段落锚定与局部微调算法 ──
try {
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
if (engParas.length === 0) {
// 鲁棒性退后兜底:若没有获取到任何段落 DOM执行全局比例映射
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
} else {
// 1. 计算 15% 虚拟视窗分界线的高度位置
const threshold = eng.scrollTop + eng.clientHeight * 0.15;
// 2. 遍历源端段落,寻找当前穿过分界线的活动锚点段落
let anchorIndex = -1;
let anchorElement: HTMLElement | null = null;
for (let i = 0; i < engParas.length; i++) {
const el = engParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
anchorIndex = i;
anchorElement = el;
break;
}
}
// 3. 边界兜底:若没有完美契合的活动段落
if (anchorIndex === -1) {
if (threshold < (engParas[0] as HTMLElement).offsetTop) {
// 比第一段还靠上,锚定第 0 段
anchorIndex = 0;
anchorElement = engParas[0] as HTMLElement;
} else {
// 已经滚过了最后一段,锚定最后一段
anchorIndex = engParas.length - 1;
anchorElement = engParas[engParas.length - 1] as HTMLElement;
}
}
// 4. 计算该段落的局部滚出比例 (LocalRatio)
let localRatio = 0;
if (anchorElement) {
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
localRatio = (threshold - offsetTop) / offsetHeight;
localRatio = Math.max(0, Math.min(1, localRatio)); // 限制在安全浮点数 0~1
}
// 5. 目标端(中文)寻找同索引段落并进行物理锚定
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
const targetPara = chnParas[anchorIndex] as HTMLElement | undefined;
if (targetPara) {
const targetOffsetTop = targetPara.offsetTop;
const targetOffsetHeight = targetPara.offsetHeight;
// 目标 scrollTop = 目标段落顶部位置 - 目标容器 15% 分界线偏置 + 局部拉伸偏移
const targetScrollTop = targetOffsetTop - chn.clientHeight * 0.15 + localRatio * targetOffsetHeight;
const maxScroll = chn.scrollHeight - chn.clientHeight;
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
// 极端边界:若两侧段落数不一致导致越界,以全局比例兜底
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
}
}
} catch (err) {
console.error('段落对齐滚动计算失败:', err);
} finally {
requestAnimationFrame(() => {
scrollLock.current = false;
});
}
}, [syncScroll, viewMode, showPdf, hasMarkdown, onScrollStart]);
// 中文滚动同步到英文
const handleChineseScroll = useCallback(() => {
if (!syncScroll || viewMode !== 'bilingual') return;
if (scrollLock.current) return;
if (onScrollStart) onScrollStart();
const eng = englishRef.current;
const chn = chineseRef.current;
if (!eng || !chn) return;
scrollLock.current = true;
// ── 判定是否执行全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
return;
}
// ── 执行高精度段落锚定与局部微调算法 ──
try {
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
if (chnParas.length === 0) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
} else {
// 1. 计算 15% 虚拟视窗分界线的高度位置
const threshold = chn.scrollTop + chn.clientHeight * 0.15;
// 2. 遍历源端段落,寻找当前活动锚点段落
let anchorIndex = -1;
let anchorElement: HTMLElement | null = null;
for (let i = 0; i < chnParas.length; i++) {
const el = chnParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
anchorIndex = i;
anchorElement = el;
break;
}
}
// 3. 边界兜底
if (anchorIndex === -1) {
if (threshold < (chnParas[0] as HTMLElement).offsetTop) {
anchorIndex = 0;
anchorElement = chnParas[0] as HTMLElement;
} else {
anchorIndex = chnParas.length - 1;
anchorElement = chnParas[chnParas.length - 1] as HTMLElement;
}
}
// 4. 计算该段落的局部滚出比例 (LocalRatio)
let localRatio = 0;
if (anchorElement) {
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
localRatio = (threshold - offsetTop) / offsetHeight;
localRatio = Math.max(0, Math.min(1, localRatio));
}
// 5. 目标端(英文)寻找同索引段落并进行物理锚定
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
const targetPara = engParas[anchorIndex] as HTMLElement | undefined;
if (targetPara) {
const targetOffsetTop = targetPara.offsetTop;
const targetOffsetHeight = targetPara.offsetHeight;
const targetScrollTop = targetOffsetTop - eng.clientHeight * 0.15 + localRatio * targetOffsetHeight;
const maxScroll = eng.scrollHeight - eng.clientHeight;
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
}
}
} catch (err) {
console.error('段落对齐滚动计算失败:', err);
} finally {
requestAnimationFrame(() => {
scrollLock.current = false;
});
}
}, [syncScroll, viewMode, showPdf, hasMarkdown, onScrollStart]);
return {
englishRef,
chineseRef,
handleEnglishScroll,
handleChineseScroll,
};
}

View File

@ -0,0 +1,375 @@
// dashboard/src/hooks/useSyncState.ts
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { SavedSyncQuery } from '../types';
export interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
export interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function useSyncState() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [limit, setLimit] = useState<number>(200);
const [estimating, setEstimating] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [status, setStatus] = useState<HarvestStatus>({
active: false,
query: '',
source: '',
synced: 0,
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
const [skipFailed, setSkipFailed] = useState<boolean>(false);
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
// 获取历史检索配置
const fetchSyncQueries = async () => {
try {
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
setSyncQueries(res.data);
} catch (e) {
console.error('获取检索配置列表失败', e);
}
};
const handleDeleteQuery = async (id: number) => {
try {
await axios.delete(`/api/sync/queries/${id}`);
fetchSyncQueries();
} catch (e) {
console.error('删除配置失败', e);
}
};
const handleReuseQuery = (sq: SavedSyncQuery) => {
setQuery(sq.query);
setSource(sq.source as any);
setLimit(sq.limit_count);
};
const handleQuickSync = async (sq: SavedSyncQuery) => {
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: sq.query,
source: sq.source as any,
synced: 0,
total: sq.limit_count,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
setStatus(res.data);
if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
fetchSyncQueries();
}
}
} catch (e) {
console.error('获取同步状态失败', e);
}
};
// 开始轮询元数据同步状态
const startPolling = () => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(fetchStatus, 1000);
};
useEffect(() => {
fetchStatus();
fetchSyncQueries();
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/batch/asset/run', {
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopBatch = async () => {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchBatchStatus();
return () => {
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部
useEffect(() => {
const container = logsContainerRef.current;
if (container) {
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [batchStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setEstimating(true);
setEstimatedCount(null);
try {
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setStatus(prev => ({
...prev,
active: true,
query: query.trim(),
source,
synced: 0,
total: 0,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
return {
query,
setQuery,
source,
setSource,
limit,
setLimit,
estimating,
estimatedCount,
status,
errorMsg,
setErrorMsg,
syncQueries,
targetPhase,
setTargetPhase,
batchLimitCount,
setBatchLimitCount,
sortOrder,
setSortOrder,
skipCompleted,
setSkipCompleted,
skipFailed,
setSkipFailed,
skipPrecedingFailed,
setSkipPrecedingFailed,
skipPrecedingUncompleted,
setSkipPrecedingUncompleted,
batchStatus,
batchError,
setBatchError,
showBuilder,
setShowBuilder,
rules,
logsContainerRef,
handleAddRule,
handleRemoveRule,
handleRuleChange,
handleDeleteQuery,
handleReuseQuery,
handleQuickSync,
handleEstimate,
handleStartHarvest,
handleStartBatch,
handleStopBatch,
};
}

View File

@ -1,8 +1,8 @@
// dashboard/src/features/citation/CitationPanel.tsx
import { useState } from 'react';
import { Loader, GitFork, RotateCcw, ChevronDown, History, FileText } from 'lucide-react';
import { CitationGalaxyCanvas } from '../../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../../types';
import { CitationGalaxyCanvas } from '../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../types';
interface CitationPanelProps {
selectedPaper: StandardPaper | null;

View File

@ -1,9 +1,9 @@
// dashboard/src/features/library/LibraryPanel.tsx
import { useState, useCallback } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../search/SearchPanel';
import { CustomSelect } from '../../components/CustomSelect';
import type { StandardPaper } from '../types';
import { getDoctypeBadge } from '../utils/paper';
import { CustomSelect } from '../components/CustomSelect';
interface LibraryPanelProps {
library: StandardPaper[];

View File

@ -0,0 +1,174 @@
// dashboard/src/features/reader/ReaderPanel.tsx
import { useState } from 'react';
import type { StandardPaper, NoteRecord } from '../types';
import { useReaderState } from '../hooks/useReaderState';
import { ReaderToolbar } from '../components/reader/ReaderToolbar';
import { BilingualViewer } from '../components/reader/BilingualViewer';
import { ReaderNotesSidebar } from '../components/reader/ReaderNotesSidebar';
interface ReaderPanelProps {
selectedPaper: StandardPaper;
library: StandardPaper[];
recentlySelected: StandardPaper[];
onSwitchPaper: (paper: StandardPaper) => void;
parsing: boolean;
handleParse: (bibcode: string, force?: boolean) => void;
translating: boolean;
handleTranslate: (bibcode: string, force?: boolean) => void;
vectorizing: boolean;
handleVectorize: (bibcode: string) => void;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notes: NoteRecord[];
englishText: string;
chineseText: string;
handleTextSelection: (paragraphIdx: number) => void;
selectedParagraphIdx: number | null;
setSelectedParagraphIdx: (idx: number | null) => void;
selectedText: string;
setSelectedText: (text: string) => void;
newNoteColor: string;
setNewNoteColor: (color: string) => void;
newNoteText: string;
setNewNoteText: (text: string) => void;
handleCreateNote: () => void;
handleDeleteNote: (id: number) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
}
export function ReaderPanel(props: ReaderPanelProps) {
const {
selectedPaper,
library,
recentlySelected,
onSwitchPaper,
parsing,
handleParse,
translating,
handleTranslate,
vectorizing,
handleVectorize,
showNotesPanel,
setShowNotesPanel,
notes,
englishText,
chineseText,
handleTextSelection,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
newNoteColor,
setNewNoteColor,
newNoteText,
setNewNoteText,
handleCreateNote,
handleDeleteNote,
showConfirm,
onJumpToSource,
} = props;
// 局部管理阅读视角模式(原文/中文/对照)
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return 'english';
}
return 'bilingual';
});
// 调用封装后的阅读器逻辑状态 Hook
const state = useReaderState({
selectedPaper,
englishText,
viewMode,
setViewMode,
});
return (
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
{/* 顶部控制栏 */}
<ReaderToolbar
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={onSwitchPaper}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notesCount={notes.length}
showConfirm={showConfirm}
englishText={englishText}
chineseText={chineseText}
showSwitchMenu={state.showSwitchMenu}
setShowSwitchMenu={state.setShowSwitchMenu}
viewMode={viewMode}
setViewMode={setViewMode}
syncScroll={state.syncScroll}
setSyncScroll={state.setSyncScroll}
showPdf={state.showPdf}
/>
{/* 并排对比视窗与侧边栏 */}
<BilingualViewer
selectedPaper={selectedPaper}
parsing={parsing}
translating={translating}
englishText={englishText}
chineseText={chineseText}
notes={notes}
handleTextSelection={handleTextSelection}
showNotesPanel={showNotesPanel}
viewMode={viewMode}
showPdf={state.showPdf}
setShowPdf={state.setShowPdf}
englishRef={state.englishRef}
chineseRef={state.chineseRef}
handleEnglishScroll={state.handleEnglishScroll}
handleChineseScroll={state.handleChineseScroll}
handleMouseOver={state.handleMouseOver}
clearHoverTimeout={state.clearHoverTimeout}
handleContainerMouseLeave={state.handleContainerMouseLeave}
handleMouseLeaveTarget={state.handleMouseLeaveTarget}
highlightCandidates={state.highlightCandidates}
hoveredTarget={state.hoveredTarget}
hoverCardPos={state.hoverCardPos}
>
{/* 学术助手侧边栏 */}
<ReaderNotesSidebar
selectedPaper={selectedPaper}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notes={notes}
selectedParagraphIdx={selectedParagraphIdx}
setSelectedParagraphIdx={setSelectedParagraphIdx}
selectedText={selectedText}
setSelectedText={setSelectedText}
newNoteColor={newNoteColor}
setNewNoteColor={setNewNoteColor}
newNoteText={newNoteText}
setNewNoteText={setNewNoteText}
handleCreateNote={handleCreateNote}
handleDeleteNote={handleDeleteNote}
onJumpToSource={onJumpToSource}
sidebarTab={state.sidebarTab}
setSidebarTab={state.setSidebarTab}
targets={state.targets}
loadingTargets={state.loadingTargets}
identifyingTargets={state.identifyingTargets}
associatingTarget={state.associatingTarget}
manualTargetName={state.manualTargetName}
setManualTargetName={state.setManualTargetName}
handleIdentifyTargets={state.handleIdentifyTargets}
handleAssociateTarget={state.handleAssociateTarget}
handleJumpToTarget={state.handleJumpToTarget}
/>
</BilingualViewer>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { useResearchAgent } from '../hooks/useResearchAgent';
import { AgentSessionSidebar } from '../components/agent/AgentSessionSidebar';
import { AgentMessageList } from '../components/agent/AgentMessageList';
import { AgentInputArea } from '../components/agent/AgentInputArea';
interface ResearchAgentPanelProps {
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
showAlert?: (message: string, title?: string) => void;
}
export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPanelProps) {
const state = useResearchAgent({ showConfirm, showAlert });
return (
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-2xl border border-slate-200 h-[calc(100vh-130px)] shadow-xs">
{/* 左侧会话列表侧栏 */}
<AgentSessionSidebar
sessions={state.sessions}
currentSessionId={state.currentSessionId}
setCurrentSessionId={state.setCurrentSessionId}
loadingSessions={state.loadingSessions}
searchQuery={state.searchQuery}
setSearchQuery={state.setSearchQuery}
searchScopeOnlyCurrent={state.searchScopeOnlyCurrent}
setSearchScopeOnlyCurrent={state.setSearchScopeOnlyCurrent}
searchResults={state.searchResults}
loadingSearch={state.loadingSearch}
collapsed={state.sidebarCollapsed}
onToggleCollapse={state.setSidebarCollapsed}
onNewSession={state.handleNewSession}
onDeleteSession={state.handleDeleteSession}
/>
{/* 右侧会话对话展示及输入面板 */}
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
<AgentMessageList
turns={state.turns}
activeTurn={state.activeTurn}
streaming={state.streaming}
currentSessionId={state.currentSessionId}
expandedThoughts={state.expandedThoughts}
expandedArgs={state.expandedArgs}
expandedResults={state.expandedResults}
collapsedSubAgents={state.collapsedSubAgents}
toggleThought={state.toggleThought}
toggleArgs={state.toggleArgs}
toggleResult={state.toggleResult}
toggleSubAgent={state.toggleSubAgent}
handleRewind={state.handleRewind}
handleRestoreRewind={state.handleRestoreRewind}
handleRetry={state.handleRetry}
handleBranch={state.handleBranch}
handleSend={state.handleSend}
scrollContainerRef={state.scrollContainerRef}
chatEndRef={state.chatEndRef}
handleScroll={state.handleScroll}
showMetrics={state.showMetrics}
setShowMetrics={state.setShowMetrics}
showAuditLog={state.showAuditLog}
setShowAuditLog={state.setShowAuditLog}
hasPendingPermission={state.hasPendingPermission}
setHasPendingPermission={state.setHasPendingPermission}
hasPendingQuestion={state.hasPendingQuestion}
setHasPendingQuestion={state.setHasPendingQuestion}
showAlert={showAlert}
sidebarCollapsed={state.sidebarCollapsed}
setSidebarCollapsed={state.setSidebarCollapsed}
sessions={state.sessions}
handleDeleteSession={state.handleDeleteSession}
loadSessionHistory={state.loadSessionHistory}
/>
<AgentInputArea
input={state.input}
setInput={state.setInput}
streaming={state.streaming}
thinking={state.thinking}
setThinking={state.setThinking}
agentMode={state.agentMode}
setAgentMode={state.setAgentMode}
agentModes={state.agentModes}
pendingImage={state.pendingImage}
setPendingImage={state.setPendingImage}
onSend={state.handleSend}
onStop={state.handleStop}
fileInputRef={state.fileInputRef}
attachImage={state.attachImage}
handlePaste={state.handlePaste}
/>
</div>
</div>
);
}

View File

@ -1,40 +1,10 @@
// dashboard/src/features/search/SearchPanel.tsx
import React from 'react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal, AlertTriangle, Lightbulb } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
import type { StandardPaper } from '../types';
import { CustomSelect } from '../components/CustomSelect';
export const getDoctypeBadge = (doctype: string) => {
const typeMap: Record<string, { label: string; style: string }> = {
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proceedings: { label: '会议集', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proposal: { label: '观测提案', style: 'bg-rose-50 text-rose-700 border-rose-200' },
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
dataset: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
software: { label: '软件代码', style: 'bg-teal-50 text-teal-700 border-teal-200' },
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
circular: { label: '天文电报', style: 'bg-orange-50 text-orange-700 border-orange-200' },
inbook: { label: '图书章节', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
erratum: { label: '勘误说明', style: 'bg-red-50 text-red-700 border-red-200' },
misc: { label: '其他文献', style: 'bg-slate-50 text-slate-700 border-slate-200' },
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-200' },
};
const val = doctype ? doctype.toLowerCase() : 'article';
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}>
{match.label}
</span>
);
};
import { getDoctypeBadge } from '../utils/paper';
interface SearchPanelProps {
searchQuery: string;

View File

@ -0,0 +1,137 @@
// dashboard/src/features/sync/SyncPanel.tsx
import { AlertTriangle, SlidersHorizontal } from 'lucide-react';
import { useSyncState } from '../hooks/useSyncState';
import { MetadataSyncCard } from '../components/sync/MetadataSyncCard';
import { BatchPipelineCard } from '../components/sync/BatchPipelineCard';
import { BookmarkletToolCard } from '../components/sync/BookmarkletToolCard';
export function SyncPanel() {
const state = useSyncState();
return (
<div className="space-y-6 w-full max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3 select-none">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs">
NASA ADS arXiv 线
</p>
</div>
{/* 全局错误显示 */}
{state.errorMsg && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{state.errorMsg}</div>
</div>
)}
{/* 1. 元数据同步控制卡片 */}
<MetadataSyncCard
query={state.query}
setQuery={state.setQuery}
source={state.source}
setSource={state.setSource}
limit={state.limit}
setLimit={state.setLimit}
estimating={state.estimating}
estimatedCount={state.estimatedCount}
status={state.status}
showBuilder={state.showBuilder}
setShowBuilder={state.setShowBuilder}
rules={state.rules}
handleAddRule={state.handleAddRule}
handleRemoveRule={state.handleRemoveRule}
handleRuleChange={state.handleRuleChange}
handleEstimate={state.handleEstimate}
handleStartHarvest={state.handleStartHarvest}
/>
{/* 2. 馆藏批量流水线学术任务卡片 */}
<BatchPipelineCard
targetPhase={state.targetPhase}
setTargetPhase={state.setTargetPhase}
batchLimitCount={state.batchLimitCount}
setBatchLimitCount={state.setBatchLimitCount}
sortOrder={state.sortOrder}
setSortOrder={state.setSortOrder}
skipCompleted={state.skipCompleted}
setSkipCompleted={state.setSkipCompleted}
skipFailed={state.skipFailed}
setSkipFailed={state.setSkipFailed}
skipPrecedingFailed={state.skipPrecedingFailed}
setSkipPrecedingFailed={state.setSkipPrecedingFailed}
skipPrecedingUncompleted={state.skipPrecedingUncompleted}
setSkipPrecedingUncompleted={state.setSkipPrecedingUncompleted}
batchStatus={state.batchStatus}
batchError={state.batchError}
logsContainerRef={state.logsContainerRef}
handleStartBatch={state.handleStartBatch}
handleStopBatch={state.handleStopBatch}
/>
{/* 3. 常用批量同步检索配置 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-sky-600" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-0.5">
</p>
</div>
{state.syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-405 italic select-none">
</div>
) : (
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
{state.syncQueries.map(sq => (
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
<div className="space-y-1">
<div className="font-bold text-slate-800 break-all select-all font-mono">
{sq.query}
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
<span>: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
<span>: <strong className="text-slate-700">{sq.limit_count}</strong></span>
<span>: <strong className="text-slate-700">{sq.last_run}</strong></span>
</div>
</div>
<div className="flex gap-2 shrink-0 self-end sm:self-center select-none">
<button
type="button"
onClick={() => state.handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
>
</button>
<button
type="button"
disabled={state.status.active}
onClick={() => state.handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => state.handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-755 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 4. 浏览器直推书签工具 */}
<BookmarkletToolCard />
</div>
);
}

View File

@ -0,0 +1,131 @@
// dashboard/src/utils/celestial.ts
// 天体物理文本高亮与候选词生成通用工具函数 (Pure Functions)
/**
* Unicode
*
* Markdown \u2009 () \u00a0 ()
* 2026-06-25 \u0020
*
*
* - (true) Unicode
* - (false)
* - false
*/
export const ENABLE_LEGACY_UNICODE_SPACE_COMPAT = true;
export interface TargetInfo {
target_name: string;
ra: string | null;
dec: string | null;
parallax: number | null;
parallax_err: number | null;
spectral_type: string | null;
v_magnitude: number | null;
otype: string | null;
oname: string | null;
pm_ra: number | null;
pm_de: number | null;
radial_velocity: number | null;
photometry: Record<string, number> | null;
aliases: string[];
}
export interface HighlightCandidate {
name: string;
preferredName: string;
}
// 生成天体的匹配词条列表
export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidate[] {
const candidates: HighlightCandidate[] = [];
const seen = new Set<string>();
const addCandidate = (name: string, preferredName: string) => {
const trimmed = name.trim();
if (!trimmed) return;
// 过滤无效或太短的名称以防止在正文中误伤普通单词
if (trimmed.length < 3) return;
if (/^\d+$/.test(trimmed)) return; // 纯数字
if (/^[a-zA-Z\s]+$/.test(trimmed) && trimmed.length <= 4) return; // 纯字母且长度小于等于4
const lower = trimmed.toLowerCase();
if (seen.has(lower)) return;
seen.add(lower);
candidates.push({ name: trimmed, preferredName });
// 自动为带有空格的常见星表前缀添加无空格变体
const spaceRegex = /^(NGC|IC|M|HD|HIP|Gaia|WD|GD|TYC|KIC|TIC|PSR)\s+(\d+)$/i;
const match = trimmed.match(spaceRegex);
if (match) {
const variant = `${match[1]}${match[2]}`;
const variantLower = variant.toLowerCase();
if (!seen.has(variantLower)) {
seen.add(variantLower);
candidates.push({ name: variant, preferredName });
}
}
// 自动为无空格的常见星表前缀添加有空格变体
const noSpaceRegex = /^(NGC|IC|M|HD|HIP|Gaia|WD|GD|TYC|KIC|TIC|PSR)(\d+)$/i;
const matchNoSpace = trimmed.match(noSpaceRegex);
if (matchNoSpace) {
const variant = `${matchNoSpace[1]} ${matchNoSpace[2]}`;
const variantLower = variant.toLowerCase();
if (!seen.has(variantLower)) {
seen.add(variantLower);
candidates.push({ name: variant, preferredName });
}
}
};
for (const t of targets) {
// 1. 首选名称
addCandidate(t.target_name, t.target_name);
// 2. 别名
if (t.aliases) {
for (const alias of t.aliases) {
addCandidate(alias, t.target_name);
}
}
}
// 按长度降序排列,保证最长匹配优先
return candidates.sort((a, b) => b.name.length - a.name.length);
}
// 辅助函数:在 Markdown 文本中安全高亮天体名称,跳过 HTML 标签、LaTeX公式和 Markdown 链接
export function highlightTargetsInMarkdown(text: string, candidates: HighlightCandidate[]): string {
if (!candidates || candidates.length === 0) return text;
let result = text;
for (const cand of candidates) {
const escaped = cand.name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
// 根据兼容开关决定是否启用宽容的 Unicode 空格正则通配
const regexStr = ENABLE_LEGACY_UNICODE_SPACE_COMPAT
? escaped.replace(/\s+/g, '[\\s\\u00a0\\u2000-\\u200a\\u202f\\u205f\\u3000]+')
: escaped;
const regex = new RegExp(`\\b(${regexStr})\\b`, 'gi');
// 按 HTML 标签、LaTeX 行内/块级公式、Markdown 链接进行切片
const parts = result.split(/(<[^>]+>|\$\$[\s\S]*?\ $\$|\$[\s\S]*?\$|\[[^\]]*\]\([^\)]*\))/g);
result = parts.map((part) => {
// 若是标签、公式或 Markdown 链接,则原样返回
if (
part.startsWith('<') ||
part.startsWith('$') ||
(part.startsWith('[') && part.includes(']('))
) {
return part;
}
// plain text 部分执行天体正则高亮替换
return part.replace(regex, `[$1](#celestial-${encodeURIComponent(cand.preferredName)})`);
}).join('');
}
return result;
}

View File

@ -0,0 +1,152 @@
// dashboard/src/utils/paper.tsx
// 文献相关的通用辅助和视图徽章渲染函数
// 根据文献类型 (doctype) 渲染统一的 Astro 样式卡片分类徽章 (Pure View Helper)
export const getDoctypeBadge = (doctype: string) => {
const typeMap: Record<string, { label: string; style: string }> = {
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proceedings: { label: '会议集', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proposal: { label: '观测提案', style: 'bg-rose-50 text-rose-700 border-rose-200' },
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
dataset: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
software: { label: '软件代码', style: 'bg-teal-50 text-teal-700 border-teal-200' },
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
circular: { label: '天文电报', style: 'bg-orange-50 text-orange-700 border-orange-200' },
inbook: { label: '图书章节', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
erratum: { label: '勘误说明', style: 'bg-red-50 text-red-700 border-red-200' },
misc: { label: '其他文献', style: 'bg-slate-50 text-slate-700 border-slate-200' },
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-200' },
};
const val = doctype ? doctype.toLowerCase() : 'article';
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}>
{match.label}
</span>
);
};
export interface PaperMetadata {
title?: string;
author?: string[];
publisher?: string;
source?: string;
date?: string;
tags?: string;
}
/**
* Markdown YAML Front Matter
* YAML Markdown
*/
export function parseMarkdownFrontMatter(markdown: string): {
metadata: PaperMetadata | null;
pureMarkdown: string;
} {
if (!markdown) {
return { metadata: null, pureMarkdown: '' };
}
// 匹配文首被双 --- 包裹的 YAML 元数据区块
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) {
return { metadata: null, pureMarkdown: markdown };
}
const yamlText = match[1];
const pureMarkdown = match[2];
const metadata: PaperMetadata = {};
const lines = yamlText.split('\n');
for (const line of lines) {
// 寻找第一个英文冒号或中文冒号作为键值分隔符
let sepIdx = line.indexOf(':');
if (sepIdx === -1) {
sepIdx = line.indexOf('');
}
if (sepIdx !== -1) {
let rawKey = line.slice(0, sepIdx).trim();
let val = line.slice(sepIdx + 1).trim();
// 清理 key 中的 Markdown 粗体/斜体等标记,如 **, *, __, _
rawKey = rawKey.replace(/[\*_]/g, '').trim().toLowerCase();
// 清理 val 首尾的 Markdown 粗体/斜体标记(大模型翻译时常在值末尾加加粗符号)
val = val.replace(/^[\*_]+|[\*_]+$/g, '').trim();
// 去除包裹的字符串双引号或单引号
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1);
} else if (val.startsWith("'") && val.endsWith("'")) {
val = val.slice(1, -1);
}
// 将常见的中文 Key 映射为标准的英文元数据字段键
let key = rawKey;
if (rawKey === '标题' || rawKey === 'title') {
key = 'title';
} else if (rawKey === '作者' || rawKey === 'author' || rawKey === 'authors') {
key = 'author';
} else if (rawKey === '出版社' || rawKey === '出版商' || rawKey === 'publisher') {
key = 'publisher';
} else if (rawKey === '来源' || rawKey === 'source' || rawKey === 'url') {
key = 'source';
} else if (rawKey === '日期' || rawKey === '年份' || rawKey === 'date' || rawKey === 'year') {
key = 'date';
} else if (rawKey === '标签' || rawKey === 'tags') {
key = 'tags';
}
if (key === 'title') {
metadata.title = val;
} else if (key === 'publisher') {
metadata.publisher = val;
} else if (key === 'source') {
metadata.source = val;
} else if (key === 'date') {
metadata.date = val;
} else if (key === 'tags') {
metadata.tags = val;
} else if (key === 'author') {
// 解析 JSON 格式数组: ["U. Heber", "S. Geier", ...] 或中文翻译保留的 [Heber, Ulrich]
if (val.startsWith('[') && val.endsWith(']')) {
const authors: string[] = [];
const quoteRegex = /"([^"]+)"|'([^']+)'/g;
let m;
let hasQuotes = false;
while ((m = quoteRegex.exec(val)) !== null) {
authors.push(m[1] || m[2]);
hasQuotes = true;
}
if (!hasQuotes) {
// 如果没有引号,如 [Heber, Ulrich],直接去掉括号按逗号切分
const inside = val.slice(1, -1).trim();
metadata.author = inside.split(/[,]/).map(a => a.trim()).filter(Boolean);
} else {
metadata.author = authors;
}
} else {
// 兼容中文逗号和英文逗号的分隔
metadata.author = val.split(/[,]/).map(a => a.trim()).filter(Boolean);
}
}
}
}
// 只要解析出任一有效元数据字段,就认为解析成功;否则返回空
const hasKeys = Object.keys(metadata).length > 0;
return {
metadata: hasKeys ? metadata : null,
pureMarkdown
};
}

View File

@ -1,580 +0,0 @@
# 前端开发规范\-React篇
React 开发实践指南
V1\.0 \| 2026年5月 \
# 一、概述与总则
本文档旨在统一前端开发团队的技术实践标准,确保代码质量、可维护性和团队协作效率。规范覆盖 React 技术栈的核心开发场景,同时涵盖 HTML/CSS、JavaScript/TypeScript 等基础层面的通用准则。
## 1\.1 适用范围
本规范适用于所有使用 React 技术栈的前端项目,包括但不限于:
1. 使用 React 18\+ 的 Web 应用项目
2. 基于 Next\.js、Remix 等元框架的服务端渲染项目
3. 使用 React Native 的移动端跨平台项目(部分适用)
## 1\.2 规范层级
规范条目按强制程度分为三个层级,开发者应根据项目实际情况合理遵循:
|**层级**|**标识**|**说明**|
|---|---|---|
|必须Must|\[M\]|所有项目必须严格遵守Code Review 中必检项|
|推荐Should|\[S\]|强烈建议遵循,特殊场景经评估后可调整|
|可选May|\[O\]|根据项目实际情况选择性采纳|
## 1\.3 技术栈版本要求
新项目应优先采用以下技术栈版本,已有项目应在迭代周期内逐步升级:
|**技术项**|**推荐版本**|**说明**|
|---|---|---|
|React|18\.x / 19\.x|使用最新稳定版|
|TypeScript|5\.5\+|strict 模式启用|
|Vite|6\.x|构建工具首选|
|Next\.js|15\.x|SSR/SSG 场景|
|Tailwind CSS|4\.x|原子化 CSS 方案|
|ESLint|9\.x|Flat Config 格式|
# 二、React 开发规范
React 是本规范的核心关注领域。本章从组件设计、Hooks 使用、状态管理、TypeScript 类型约束和性能优化五个维度,系统性地定义 React 开发的最佳实践。
## 2\.1 组件设计规范
### 2\.1\.1 组件分类与组织
React 组件应按职责明确划分为以下类别,并在项目目录中保持清晰的组织结构:
|**组件类型**|**存放路径**|**职责说明**|
|---|---|---|
|Page 组件|app/ 或 pages/|路由级别的页面组件,负责数据获取和页面级布局|
|Layout 组件|components/layout/|页面布局框架,如 Header、Sidebar、Footer|
|UI 组件|components/ui/|基础 UI 元素Button、Input、Modal 等纯展示组件|
|Feature 组件|features/\*/components/|业务功能组件,与特定功能域紧耦合|
|HOC / 工具|components/hoc/|高阶组件和渲染工具render props|
### 2\.1\.2 函数组件优先
自 React 16\.8 引入 Hooks 以来,函数组件已成为官方推荐的标准写法。所有新开发组件必须使用函数组件,类组件仅在维护遗留代码时允许存在。
```TypeScript
// 推荐:函数组件 + Hooks
import { useState, useCallback } from 'react';
interface UserCardProps {
user: User;
onSelect: (id: string) => void;
}
export function UserCard({ user, onSelect }: UserCardProps) {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => {
onSelect(user.id);
setExpanded(prev => !prev);
}, [onSelect, user.id]);
return (
<Card onClick={handleClick}>
<Avatar src={user.avatar} />
<UserName>{user.name}</UserName>
{expanded && <UserDetail user={user} />}
</Card>
);
}
```
### 2\.1\.3 Props 设计原则
组件的 Props 接口设计直接影响组件的可复用性和可维护性。遵循以下原则:
4. 单一职责:每个组件只接收其渲染所需的最小数据集合,避免传递冗余数据
5. 显式接口:使用 TypeScript interface 定义 Props禁止隐式 any 类型
6. 默认值策略:对可选 Props 提供合理的默认值,或使用解构赋值简化处理
7. 事件命名:自定义事件处理器以 on 为前缀(如 onSelect、onValueChange遵循 React 原生事件命名惯例
8. 避免过度透传:不要简单地将父组件的 Props 全部展开传递给子组件,应显式声明所需属性
### 2\.1\.4 组件文件结构
每个组件应按以下结构组织,确保关注点分离和可测试性:
```TypeScript
// components/UserCard/index.tsx
export { UserCard } from './UserCard';
export type { UserCardProps } from './types';
// components/UserCard/UserCard.tsx
import { useState } from 'react';
import type { UserCardProps } from './types';
import { useUserCard } from './useUserCard';
import * as S from './styles';
export function UserCard({ user, onSelect }: UserCardProps) {
const { expanded, handleClick } = useUserCard(user, onSelect);
return (
<S.Card onClick={handleClick}>...</S.Card>
);
}
// components/UserCard/types.ts
export interface UserCardProps {
user: User;
onSelect: (id: string) => void;
}
// components/UserCard/useUserCard.ts
export function useUserCard(user: User, onSelect: (id: string) => void) {
// 业务逻辑抽离到自定义 Hook
}
// components/UserCard/styles.ts (styled-components / CSS Modules)
```
## 2\.2 Hooks 使用规范
### 2\.2\.1 Hooks 基础规则
Hooks 是 React 16\.8 引入的革命性特性,必须严格遵循以下使用规则,否则可能导致不可预期的行为:
9. 只在最顶层调用 Hooks不要在循环、条件判断或嵌套函数中调用 Hooks
10. 只在 React 函数中调用 Hooks在函数组件或自定义 Hook 中调用,不要在普通 JavaScript 函数中调用
11. 以 use 开头命名:自定义 Hook 必须以 use 开头命名,以便 ESLint 插件识别
12. 依赖数组诚实原则useEffect、useMemo、useCallback 的依赖数组必须完整列出所有依赖项
### 2\.2\.2 useEffect 最佳实践
useEffect 是最常用的 Hook 之一,也是最容易滥用的。遵循以下实践:
```JavaScript
// 推荐:单一职责的 Effect
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal })
.then(setUser)
.catch(setError);
return () => controller.abort();
}, [userId]); // 依赖数组必须完整
// 推荐:逻辑拆分到独立 Effect
useEffect(() => {
// 数据获取逻辑
}, [params]);
useEffect(() => {
// DOM 操作或订阅逻辑
return () => { /* 清理逻辑 */ };
}, []);
// 禁止:缺失依赖项
useEffect(() => {
fetchData(page); // page 未在依赖数组中!
}, []); // eslint-disable-line 是临时方案,应尽快修复
```
### 2\.2\.3 useMemo 与 useCallback
性能优化 Hooks 应在有明确性能问题时使用,避免过早优化。遵循以下准则:
13. useMemo用于缓存昂贵的计算结果仅在计算成本显著高于缓存开销时使用
14. useCallback用于缓存事件处理函数主要配合 React\.memo 使用,避免子组件不必要的重渲染
15. 避免滥用:简单的计算和事件处理不需要 memoizationReact 的渲染性能通常优于预期
16. 依赖数组完整性:与 useEffect 同样,必须确保依赖数组的完整性
```JavaScript
// 推荐:复杂数据转换使用 useMemo
const filteredUsers = useMemo(() => {
return users
.filter(u => u.active)
.sort((a, b) => b.score - a.score)
.slice(0, 100);
}, [users]);
// 推荐:配合 React.memo 使用 useCallback
const handleSubmit = useCallback((values: FormData) => {
api.submit(values).then(onSuccess);
}, [onSuccess]);
// 禁止:对简单值使用 useMemo
const fullName = useMemo(
() => `$${firstName} $${lastName}`,
[firstName, lastName] // 字符串拼接成本极低,无需缓存
);
```
## 2\.3 状态管理规范
### 2\.3\.1 状态管理策略
React 应用的状态管理应按状态的作用域和复杂度选择适当的方案,避免过度工程化:
|**状态类型**|**管理方案**|**适用场景**|
|---|---|---|
|本地 UI 状态|useState|组件内部的临时状态,如表单输入、展开/收起|
|派生状态|useMemo / 计算|可从已有状态计算得出的值|
|共享状态|Context / Props|跨 2\-3 层组件传递的状态|
|全局状态|Zustand / Jotai|应用级共享状态,如用户信息、主题设置|
|服务端状态|TanStack Query|服务器数据缓存和同步|
|表单状态|React Hook Form|复杂表单的状态和验证管理|
### 2\.3\.2 Context 使用规范
React Context 适用于跨组件层级的数据传递,但不当使用会导致性能问题:
17. 拆分 Context将高频变化和低频变化的状态拆分到独立的 Context避免不必要的重渲染
18. 避免过度使用:仅在真正需要跨多级组件传递数据时使用,简单的父子组件通信仍应通过 Props
19. 结合 useReducer对于复杂状态逻辑Context 配合 useReducer 可以实现轻量级的 Redux\-like 方案
```TypeScript
// 推荐:拆分 Context 避免重渲染
const ThemeContext = createContext<Theme>('light');
const UserContext = createContext<User | null>(null);
// ThemeProvider 更新时,只消费 ThemeContext 的组件重渲染
// UserProvider 更新时,只消费 UserContext 的组件重渲染
// 推荐Context + useReducer 组合
type Action = { type: 'increment' } | { type: 'decrement' };
const CounterContext = createContext<{
state: number;
dispatch: React.Dispatch<Action>;
} | null>(null);
```
### 2\.3\.3 外部状态管理Zustand/Jotai
对于中大型企业级应用,推荐使用轻量级的原子化状态管理方案,如 Zustand 或 Jotai
20. Zustand适合模块化的 Store 架构API 极简,无 Provider 包裹问题
21. Jotai适合原子化的细粒度状态管理状态依赖自动追踪
22. 避免 Redux 过度使用:仅在需要 Redux DevTools 时间旅行调试、复杂中间件链时考虑 Redux Toolkit
## 2\.4 TypeScript 类型规范
### 2\.4\.1 组件 Props 类型
所有组件 Props 必须使用 TypeScript 接口显式定义,禁止使用 any 类型绕过类型检查:
```TypeScript
// 推荐:显式 Props 接口
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
onClick,
children,
}: ButtonProps) {
// 实现...
}
// 禁止:隐式 any 或缺少类型
// function Button(props) { // 错误props 为 any
// return <button>{props.label}</button>;
// }
```
### 2\.4\.2 泛型组件
对于数据展示类组件,使用泛型实现类型安全的通用组件:
```TypeScript
// 推荐:泛型表格组件
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
keyExtractor: (item: T) => string;
onRowClick?: (item: T) => void;
}
export function DataTable<T>({
data, columns, keyExtractor, onRowClick,
}: DataTableProps<T>) {
return (
<table>
<tbody>
{data.map(item => (
<tr key={keyExtractor(item)}
onClick={() => onRowClick?.(item)}>
{columns.map(col => (
<td key={col.key}>{col.render(item)}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
```
### 2\.4\.3 事件类型
React 事件处理函数应使用 React 提供的泛型事件类型,而非原生 DOM 事件类型:
|**事件类型**|**React 类型**|
|---|---|
|点击事件|React\.MouseEvent\<HTMLButtonElement\>|
|输入事件|React\.ChangeEvent\<HTMLInputElement\>|
|表单提交|React\.FormEvent\<HTMLFormElement\>|
|键盘事件|React\.KeyboardEvent\<HTMLInputElement\>|
|拖拽事件|React\.DragEvent\<HTMLDivElement\>|
|触摸事件|React\.TouchEvent\<HTMLDivElement\>|
|通用事件|React\.SyntheticEvent|
## 2\.5 性能优化规范
### 2\.5\.1 渲染优化
React 的渲染优化应从以下维度系统化地进行:
23. React\.memo对纯展示组件使用 React\.memo 进行浅比较优化,避免不必要的重渲染
24. useMemo / useCallback对昂贵的计算和传递给子组件的回调进行缓存
25. 虚拟列表:长列表使用 react\-window 或 react\-virtualized 实现虚拟滚动
26. 代码分割:使用 React\.lazy \+ Suspense 实现路由级别和组件级别的懒加载
```JavaScript
// 推荐React.memo + 自定义比较函数
export const UserList = React.memo(function UserList({
users,
onSelect,
}: UserListProps) {
return (
<ul>
{users.map(user => (
<UserItem key={user.id} user={user} onSelect={onSelect} />
))}
</ul>
);
}, (prev, next) => prev.users === next.users);
// 推荐React.lazy 代码分割
const Dashboard = React.lazy(() => import('./Dashboard'));
const Settings = React.lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
```
### 2\.5\.2 状态更新优化
状态更新方式直接影响渲染性能,应遵循以下最佳实践:
27. 批量更新React 18 自动批处理所有状态更新,无需手动合并
28. 不可变数据:始终使用不可变更新模式,配合 useMemo/React\.memo 进行引用比较
29. 状态拆分:将独立变化的状态拆分为多个 useState避免不必要的联合更新
30. 派生状态:优先使用 useMemo 计算派生状态,避免在状态中存储可计算的值
# 三、HTML/CSS 规范
HTML 和 CSS 是前端开发的基础,良好的标记和样式实践是构建可维护应用的前提。
## 3\.1 HTML 语义化
语义化的 HTML 不仅有利于可访问性A11y也有助于 SEO 和代码的可读性:
31. 使用恰当的语义化标签header、nav、main、article、section、aside、footer
32. 表单元素必须关联 label使用 aria\-label 或 aria\-labelledby 补充描述
33. 图片必须提供有意义的 alt 文本,装饰性图片使用 alt=""
34. 遵循标题层级顺序h1 → h2 → h3不要跳级使用
## 3\.2 CSS 架构
推荐采用CSS Modules避免全局命名空间污染
```JavaScript
// 推荐CSS Modules
import styles from './Button.module.css';
export function Button({ children }) {
return <button className={styles.button}>{children}</button>;
}
/* Button.module.css */
.button {
padding: 8px 16px;
border-radius: 4px;
background: var(--color-primary);
color: white;
}
.button:hover {
background: var(--color-primary-dark);
}
```
## 3\.3 响应式设计
所有界面必须适配至少三种断点,采用移动优先的设计策略:
|**断点名**|**尺寸范围**|**适配策略**|
|---|---|---|
|Mobile|\< 768px|单列布局、触摸友好的交互、简化导航|
|Tablet|768px \- 1024px|双列布局、侧边栏可收起、适配触控|
|Desktop|\> 1024px|完整多列布局、 hover 交互、固定侧边栏|
# 四、JavaScript/TypeScript 通用规范
除 React 特定规范外,团队应遵循以下 JavaScript/TypeScript 通用编码规范。
## 4\.1 命名规范
35. 文件名PascalCase 用于组件文件UserCard\.tsxcamelCase 用于工具文件formatDate\.ts
36. 组件名PascalCase与文件名保持一致
37. Hook 名:以 use 开头,后跟 PascalCaseuseUserData
38. 常量UPPER\_SNAKE\_CASEMAX\_RETRY\_COUNT
39. 布尔变量:使用 is、has、should 等前缀isLoading、hasError
## 4\.2 代码风格
统一使用 ESLint \+ Prettier 进行代码格式化和质量检查,配置文件纳入版本控制:
```Java
// .eslintrc.cjs
module.exports = {
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'warn',
'react-hooks/exhaustive-deps': 'error',
},
};
// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all"
}
```
## 4\.3 类型安全
40. strict 模式TypeScript 配置必须启用 strict: true
41. 禁止 any原则上禁止使用 any 类型,必要时应使用 unknown 并配合类型收窄
42. 返回值类型:公共函数应显式声明返回值类型,利用类型推断的边界情况除外
43. 类型导出:组件 Props 接口应随组件一起导出,便于复用
# 五、工程化与项目结构
良好的项目结构和工程化配置是团队协作的基石。
## 5\.1 目录结构
推荐采用以下目录组织方式Feature\-based 结构优先:
```Python
src/
├── app/ # 路由页面Next.js / React Router
│ ├── layout.tsx
│ ├── page.tsx
│ └── dashboard/
│ └── page.tsx
├── components/ # 共享组件
│ ├── ui/ # 基础 UI 组件Button, Input, Modal
│ └── layout/ # 布局组件Header, Sidebar, Footer
├── features/ # 功能模块
│ └── auth/ # 认证功能
│ ├── api/ # API 请求
│ ├── components/ # 功能组件
│ ├── hooks/ # 功能 Hooks
│ ├── stores/ # 状态管理
│ └── types.ts # 功能类型
├── hooks/ # 全局共享 Hooks
├── lib/ # 工具库和配置
│ ├── api.ts # Axios 实例配置
│ └── utils.ts # 通用工具函数
├── types/ # 全局类型定义
└── styles/ # 全局样式和主题配置
```
## 5\.2 开发工作流
44. Git 分支策略:采用 Git Flow 或 Trunk\-based 开发,功能分支命名格式 feature/描述 或 fix/描述
45. 代码审查:所有代码变更必须通过 Pull Request 审查,至少 1 人 approving 后方可合并
46. 提交规范:遵循 Conventional Commits 规范feat:、fix:、docs:、refactor:、test: 等前缀)
47. CI/CD集成自动化测试、代码质量检查ESLint、TypeScript 编译检查)到 CI 流水线
# 六、性能优化与最佳实践
性能优化是前端开发的重要环节,应贯穿整个开发周期。
## 6\.1 加载性能
48. 资源压缩:启用 Gzip/Brotli 压缩,图片使用 WebP/AVIF 格式
49. 懒加载:路由、图片、非首屏组件均使用懒加载策略
50. 预加载:对关键资源使用 rel=preload对后续路由使用 rel=prefetch
51. Bundle 分析:定期使用 @next/bundle\-analyzer 或 webpack\-bundle\-analyzer 分析打包体积
## 6\.2 运行时性能
52. 避免频繁的状态更新使用防抖debounce和节流throttle控制高频事件
53. Web Workers将复杂计算 offload 到 Web Worker避免阻塞主线程
54. 内存管理:及时清理定时器、事件监听器和订阅,防止内存泄漏
55. 虚拟化:长列表使用虚拟滚动,大数据集使用分页或虚拟表格

View File

@ -43,14 +43,18 @@ AstroResearch 是一个集成了天文学文献检索、多通道下载(含防
### 3.5 前端核心组件 (`dashboard/src/`)
| 组件文件 | 职责 |
| 目录/组件文件 | 职责 |
|:---|:---|
| **[App.tsx](../dashboard/src/App.tsx)** | 全局状态管理Tab 持久化、手动上传处理、无资源标记、活跃文献追踪、详情弹窗(含错误诊断和上传区)。 |
| **[components/CustomSelect.tsx](../dashboard/src/components/CustomSelect.tsx)** | 可复用下拉选择组件:统一视觉风格、点击外部关闭、选中高亮。 |
| **[components/CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx)** | 基于 HTML5 Canvas 的自研力导向引文星系图谱引擎:节点排斥力、中心引力、拖拽阻尼、双击多层级衍生。 |
| **[features/library/LibraryPanel.tsx](../dashboard/src/features/library/LibraryPanel.tsx)** | 馆藏管理面板:同步反馈、下载失败/无资源状态筛选、文献类型筛选13 种)、状态优先排序。 |
| **[features/search/SearchPanel.tsx](../dashboard/src/features/search/SearchPanel.tsx)** | 跨源检索面板高级组合条件、排序分页、下载失败状态提示、文献类型徽章16 种)。 |
| **[features/sync/SyncPanel.tsx](../dashboard/src/features/sync/SyncPanel.tsx)** | 批量同步控制台:乐观 UI 更新、容器内日志自动滚动。 |
| **[App.tsx](../dashboard/src/App.tsx)** | 全局骨架与胶水层鉴权外壳、Tab 持久化、挂载全局弹窗,调度各页面跨组件跳转逻辑。 |
| **[pages/SearchPanel.tsx](../dashboard/src/pages/SearchPanel.tsx)** | 统一跨源检索页面:支持高级检索构造、分页排序以及下载错误状态提示与文献类型徽章显示。 |
| **[pages/LibraryPanel.tsx](../dashboard/src/pages/LibraryPanel.tsx)** | 馆藏管理页面:展现本地馆藏列表、最近阅读、同步状态、支持下载失败和“无资源”状态筛选。 |
| **[pages/ReaderPanel.tsx](../dashboard/src/pages/ReaderPanel.tsx)** | 对照阅读器视图:以 children 组合形式装配 `BilingualViewer``ReaderNotesSidebar`。 |
| **[pages/CitationPanel.tsx](../dashboard/src/pages/CitationPanel.tsx)** | 引文星系图谱视图:装配自研 Canvas 引文拓扑力导图。 |
| **[pages/SyncPanel.tsx](../dashboard/src/pages/SyncPanel.tsx)** | 批量同步控制页面:装配元数据同步面板、流水线批量任务日志流虚拟终端。 |
| **[pages/ResearchAgentPanel.tsx](../dashboard/src/pages/ResearchAgentPanel.tsx)** | 智能科研助理页面:装配 SSE 研讨消息列表、多模式选择、思维链展示与会话侧栏。 |
| **[components/](../dashboard/src/components/)** | 通用与业务子组件:如 Canvas 引擎 ([CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx))、下拉选择 ([CustomSelect.tsx](../dashboard/src/components/CustomSelect.tsx))、学术助手侧栏、智能体对话等。 |
| **[hooks/](../dashboard/src/hooks/)** | 全局与特定页面业务逻辑状态 Hook 库(如 `useLibrary`, `useSearch`, `useReaderState`, `useSyncState`, `useResearchAgent` 等),实现数据逻辑与 UI 渲染彻底解耦。 |
| **[types/index.ts](../dashboard/src/types/index.ts)** | 全局 TypeScript 静态类型定义中心。 |
---

View File

@ -50,7 +50,34 @@ erDiagram
integer limit_count "拉取上限"
datetime last_run "最近运行时间"
datetime created_at "创建时间"
UNIQUE_query_source_limit "唯一去重约束"
}
PAPER_CHUNKS_CONTENT {
integer rowid PK
text bibcode FK
integer paragraph_index
text content
text headings "章节路径"
integer section_index "章节编号"
}
PAPER_TARGETS {
integer id PK
text bibcode FK
text target_name "标准化天体名称"
text ra "赤经"
text dec "赤纬"
real parallax "视差 mas"
text spectral_type "光谱分类"
real v_magnitude "视星等"
text aliases "JSON Array"
text otype "天体类型"
text oname "SIMBAD 主名称"
real pm_ra "自行 RA"
real pm_de "自行 Dec"
real radial_velocity "视向速度"
real parallax_err "视差误差"
text photometry "测光数据 JSON"
}
AGENT_SESSIONS {
@ -58,7 +85,31 @@ erDiagram
text title
text model
integer turn_count
text last_error
text summary
text metadata
datetime deleted_at
integer rewind_count "回退计数"
text parent_session_id FK "分支来源"
text branch_metadata "分支元数据"
text mode "模式标识符"
}
AGENT_MESSAGES {
integer id PK
text session_id FK
integer turn_index
integer step_index
text role "system/user/assistant/tool"
text content
text thought "思考链"
text tool_calls "工具调用 JSON"
text tool_call_id "工具调用 ID"
integer token_count
text metadata
text raw_json
text agent_name "消息归属"
integer active "软删除标记"
}
AGENT_TASKS {
@ -66,7 +117,7 @@ erDiagram
text session_id FK
text task_id
text content
text status
text status "pending/in_progress/completed"
text blocked_by "JSON Array — DAG 依赖"
text owner "分配目标 agent 名称"
}
@ -92,9 +143,13 @@ erDiagram
PAPERS ||--o{ NOTES : "has"
PAPERS ||--o{ CITATIONS_REFERENCES : "cites / cited_by"
PAPERS ||--o{ PAPER_CHUNKS_CONTENT : "chunked into"
PAPERS ||--o{ PAPER_TARGETS : "contains"
AGENT_SESSIONS ||--o{ AGENT_MESSAGES : "contains"
AGENT_SESSIONS ||--o{ AGENT_TASKS : "owns"
AGENT_SESSIONS ||--o{ AGENT_AUDIT_LOG : "records"
AGENT_SESSIONS ||--o{ AGENT_TEAM_MEMBERS : "members"
AGENT_SESSIONS ||--o| AGENT_SESSIONS : "branches from"
```
---
@ -127,44 +182,133 @@ erDiagram
存储用户保存的批量同步检索条件,支持快速重新同步。
- **唯一约束**`UNIQUE(query, source, limit_count)` 确保相同条件的检索不会重复保存。
### 2.5 agent_sessions 表 (Agent 会话)
存储 ReAct 智能体对话会话的元数据,支持软删除 (`deleted_at`)。
- 每条会话关联 `agent_messages` 表存储对话历史与工具调用记录。
- **索引**`idx_agent_sessions_created_at` (ORDER BY updated_at DESC)。
### 2.5 paper_chunks_content 表 (RAG 文本切片)
存储文献 Markdown 段落的文本切片,用于向量嵌入和混合检索。
- `rowid`:自增主键,与 `vec_paper_chunks` 虚拟表的 rowid 保持 1:1 对齐。
- `headings`:章节路径,如 `"Introduction > Background"`,用于结构化检索。
- `section_index`:章节内的段落编号。
- **外键**`bibcode` 级联删除 (`ON DELETE CASCADE`)。
- **关联 FTS5**`paper_chunks_fts` 虚拟表通过触发器自动同步 `content` 字段。
### 2.6 agent_tasks 表 (Agent 任务追踪)
### 2.6 paper_targets 表 (天体目标缓存)
存储从文献中提取并通过 CDS Sesame 解析的天体物理参数。
- `target_name`:标准化天体名称(如 `GD 358`)。
- `ra` / `dec`:赤道坐标(赤经/赤纬)。
- `parallax`视差mas毫角秒
- `spectral_type`:光谱分类(如 `DA1`)。
- `otype` / `oname`Sesame v4 返回的天体类型和 SIMBAD 官方主名称。
- `pm_ra` / `pm_de`自行分量mas/yr
- `radial_velocity`:视向速度。
- `photometry`多波段测光数据JSON 文本)。
- **唯一约束**`UNIQUE(bibcode, target_name)`。
- **索引**`idx_paper_targets_name`。
### 2.7 agent_sessions 表 (Agent 会话)
存储 ReAct 智能体对话会话的元数据。
- `deleted_at`软删除时间戳NULL 表示活跃会话)。
- `rewind_count`会话回退undo次数用于追踪重做历史。
- `parent_session_id`:分支来源会话 ID支持会话分叉branch
- `branch_metadata`分支元数据JSON 文本)。
- `mode`:会话模式标识符(`default`、`deep-research`、`literature-reader`),控制 Agent 的行为配置。
- **索引**`idx_agent_sessions_updated`WHERE deleted_at IS NULL仅索引活跃会话
### 2.8 agent_messages 表 (Agent 消息)
存储 Agent 会话的完整对话历史,包括思考链和工具调用。
- `role``system` / `user` / `assistant` / `tool`
- `thought`LLM 思考链内容CoT
- `tool_calls`:工具调用 JSON函数名 + 参数)。
- `tool_call_id`:工具调用唯一标识(用于 SSE 流中 tool_call ↔ tool_result 配对)。
- `agent_name`:消息归属(`lead` / 子代理名 / teammate 名),支持身份隔离。
- `active`:软删除标记(`0` = 已回退隐藏,`1` = 正常可见),用于 session rewind。
- **索引**`idx_agent_messages_session`、`idx_agent_messages_turn`、`idx_agent_messages_agent`、`idx_agent_messages_active`。
- **关联 FTS5**`agent_messages_fts` 虚拟表通过触发器自动同步 `content`、`thought`、`tool_calls` 字段。
### 2.9 agent_tasks 表 (Agent 任务追踪)
持久化智能体的结构化待办任务,支持 DAG 依赖模式。
- `blocked_by`JSON 数组,前置任务 ID 列表。
- `status` 生命周期:`pending` → `in_progress``completed`
- `owner`:分配目标 agent 名称(多 Agent 团队协作路由)。
- **索引**`idx_agent_tasks_session`、`idx_agent_tasks_status`、`idx_agent_tasks_session_task` (UNIQUE)。
### 2.7 agent_audit_log 表 (Agent 工具审计)
### 2.10 agent_audit_log 表 (Agent 工具审计)
记录所有工具调用的审计信息:工具名称、执行状态、耗时 (ms)、输出预览。
- `status``OK` / `FAIL` / `SESSION_STOP`
- `agent_name`:区分 lead/子代理/teammate 的调用来源。
- **用途**`GET /api/chat/metrics` 聚合指标、会话审计回溯。
### 2.8 agent_team_members 表 (多 Agent 团队)
### 2.11 agent_team_members 表 (多 Agent 团队)
管理多智能体团队中每个成员的生命周期。
- `status``spawning` → `active``idle``shutdown`
- `agent_role`:区分 team lead / teammate 等角色。
- **唯一约束**`UNIQUE(session_id, agent_name)`。
### 2.12 全文搜索 (FTS5)
系统在以下实体表上建立了 FTS5 全文索引,通过触发器自动保持同步:
| FTS5 虚拟表 | 实体表 | 索引字段 | 用途 |
|:---|:---|:---|:---|
| `papers_fts` | `papers` | title, authors, keywords, abstract, pub | 本地文献 BM25 全文检索 |
| `paper_chunks_fts` | `paper_chunks_content` | content | 段落级稀疏检索,配合 `vec_paper_chunks` 向量做混合搜索 |
| `agent_sessions_fts` | `agent_sessions` | session_id, title, summary, metadata | 会话历史搜索 |
| `agent_messages_fts` | `agent_messages` | session_id, role, content, thought, tool_calls | 对话内容搜索 |
每个 FTS5 虚拟表通过 `content='<实体表>'` 声明为外部内容表,并配套 `_insert`、`_update`、`_delete` 三个触发器保持数据同步。
---
## 3. 数据库迁移说明
迁移脚本存放在 `migrations/` 下,服务启动时(`src/main.rs`)会自动调用 `sqlx::migrate!().run(&pool).await` 自动部署:
### 3.1 迁移机制
迁移脚本存放在 `migrations/` 目录下,使用 `sqlx`**change-based 迁移** 模型:
- **编译期嵌入**`sqlx::migrate!("./migrations")` 宏在编译时将 `.sql` 文件嵌入二进制
- **启动时自动执行**`src/main.rs`、`src/bin/cli.rs` 以及所有测试的 in-memory 数据库在启动时调用 `.run(&pool)`,对比 `_sqlx_migrations` 追踪表,按版本号顺序执行未应用的迁移
- **版本号规则**:文件名格式 `YYYYMMDDHHMMSS_description.sql`,时间戳前缀保证全局唯一排序
- **幂等性**:所有 DDL 必须使用 `IF NOT EXISTS` / `IF EXISTS`,确保可重复执行
- **checksum 校验**sqlx 对每个迁移文件计算 SHA-256 校验和并存入 `_sqlx_migrations`;若文件内容在已应用后被修改,启动时将拒绝执行(防止 schema 漂移)
### 3.2 当前迁移
| 迁移文件 | 说明 |
|:---|:---|
| `20260608000000_init.sql` | 初始化 `papers``citations_references` 结构。 |
| `20260608000001_notes.sql` | 添加 `notes` 笔记高亮表,并为关联建立级联删除。 |
| `20260608000002_add_doctype.sql` | 为 `papers` 表新增 `doctype` 文献类型字段。 |
| `20260608000003_sync_features.sql` | 添加 `sync_queries` 同步检索条件表,支持唯一去重。 |
| `20260616000000_agent_tasks.sql` | 智能体任务持久化表,支持 DAG 依赖与状态生命周期。 |
| `20260617000000_agent_audit_log.sql` | 智能体工具调用审计日志表。 |
| `20260618000000_agent_identity.sql` | Agent 身份隔离:消息/审计归属、`agent_team_members` 团队注册表。 |
| `00000000000000_init.sql` | **完整初始化**包含所有表、索引、FTS5 虚拟表及同步触发器,详见第 2 节各表结构。 |
> **注意**`vec_paper_chunks` 向量虚拟表由 `src/main.rs` 在迁移之后动态创建,维度由 `EMBEDDING_DIM` 环境变量控制(默认 1536不在迁移文件中管理。
### 3.3 新增迁移
开发新功能需要变更数据库 schema 时:
1. 在 `migrations/` 下新建文件,命名 `YYYYMMDDHHMMSS_<简短描述>.sql`(取当前时间)
2. 编写增量 SQL`ALTER TABLE ADD COLUMN`、`CREATE TABLE IF NOT EXISTS` 等)
3. 运行 `cargo build && cargo test` 验证
4. 更新本文档的 3.2 节和对应的表结构说明
**SQLite 约束**SQLite 不支持事务性 DDL 的全部语义,且 `ALTER TABLE` 能力有限(不支持 `DROP COLUMN`、`RENAME COLUMN` 等旧版本的常见操作)。新增字段时务必使用 `DEFAULT` 值以保证向后兼容。
### 3.4 迁移 Squash
开发期产生的增量迁移应在 **首次正式发布前** squash 为单一 `init.sql`避免迁移文件线性膨胀。Squash 流程:
1. **导出完整 schema**`sqlite3 library/astro_research.db .schema`
2. **移除以下内容**
- `_sqlx_migrations`sqlx 自动管理)
- `sqlite_sequence`SQLite 内部表)
- `vec_paper_chunks` 及其关联内部表(由 main.rs 运行时创建)
- 所有 FTS5 内部表(`_data`、`_idx`、`_docsize`、`_config`,由 FTS5 自动管理)
3. **写入 `migrations/00000000000000_init.sql`**:确保所有 DDL 带 `IF NOT EXISTS` / `IF EXISTS`
4. **删除所有旧迁移文件**
5. **重建二进制**`cargo build`(新迁移文件需嵌入)
6. **更新已有数据库**(保留数据):
```sql
DELETE FROM _sqlx_migrations;
-- 重启应用后 sqlx 自动应用新的 init 迁移(所有 IF NOT EXISTS 均为 no-op
```
7. **验证**`cargo test --lib && cargo run`
> 本项目在 2026-06-25 执行了一次 squash将 18 个开发期增量迁移合并为 1 个 `00000000000000_init.sql`,同时保留了已有的 1341 条文献记录和 6477 条引用关系。
---

View File

@ -0,0 +1,356 @@
-- AstroResearch 完整数据库初始化迁移
-- 合并自 18 个开发期增量迁移文件,版本: 2026-06-25
--
-- 领域划分:
-- 1. 文献与引用
-- 2. 笔记与高亮
-- 3. 批量同步
-- 4. RAG 切片与天体目标
-- 5. Agent 会话与消息
-- 6. Agent 任务看板
-- 7. Agent 审计日志
-- 8. Agent 团队协作
-- 9. 全文搜索 (FTS5)
-- ============================================================
-- 1. 文献与引用
-- ============================================================
CREATE TABLE IF NOT EXISTS papers (
bibcode TEXT PRIMARY KEY,
title TEXT NOT NULL,
authors TEXT, -- JSON array of author names
year TEXT,
pub TEXT, -- journal / publisher
keywords TEXT, -- JSON array of keywords
abstract TEXT,
doi TEXT,
arxiv_id TEXT,
citation_count INTEGER DEFAULT 0,
reference_count INTEGER DEFAULT 0,
pdf_path TEXT,
html_path TEXT,
markdown_path TEXT,
translation_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
doctype TEXT
);
CREATE INDEX IF NOT EXISTS idx_papers_doi ON papers(doi);
CREATE INDEX IF NOT EXISTS idx_papers_arxiv_id ON papers(arxiv_id);
CREATE TABLE IF NOT EXISTS citations_references (
source_bibcode TEXT NOT NULL,
target_bibcode TEXT NOT NULL,
PRIMARY KEY (source_bibcode, target_bibcode)
);
CREATE INDEX IF NOT EXISTS idx_citations_ref_source ON citations_references(source_bibcode);
CREATE INDEX IF NOT EXISTS idx_citations_ref_target ON citations_references(target_bibcode);
-- ============================================================
-- 2. 笔记与高亮
-- ============================================================
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bibcode TEXT NOT NULL,
paragraph_index INTEGER NOT NULL,
note_text TEXT NOT NULL DEFAULT '',
highlight_color TEXT NOT NULL DEFAULT 'yellow',
selected_text TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bibcode) REFERENCES papers(bibcode) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notes_bibcode ON notes(bibcode);
-- ============================================================
-- 3. 批量同步
-- ============================================================
CREATE TABLE IF NOT EXISTS sync_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
source TEXT NOT NULL,
limit_count INTEGER NOT NULL,
last_run DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(query, source, limit_count)
);
-- ============================================================
-- 4. RAG 切片与天体目标
-- ============================================================
CREATE TABLE IF NOT EXISTS paper_chunks_content (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
bibcode TEXT,
paragraph_index INTEGER,
content TEXT,
headings TEXT DEFAULT 'Document',
section_index INTEGER DEFAULT 0,
FOREIGN KEY(bibcode) REFERENCES papers(bibcode) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS paper_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bibcode TEXT,
target_name TEXT,
ra TEXT,
dec TEXT,
parallax REAL,
spectral_type TEXT,
v_magnitude REAL,
aliases TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
otype TEXT,
oname TEXT,
pm_ra REAL,
pm_de REAL,
radial_velocity REAL,
parallax_err REAL,
photometry TEXT,
FOREIGN KEY(bibcode) REFERENCES papers(bibcode) ON DELETE CASCADE,
UNIQUE(bibcode, target_name)
);
CREATE INDEX IF NOT EXISTS idx_paper_targets_name ON paper_targets(target_name);
-- ============================================================
-- 5. Agent 会话与消息
-- ============================================================
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
turn_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
summary TEXT,
metadata TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
rewind_count INTEGER NOT NULL DEFAULT 0,
parent_session_id TEXT REFERENCES agent_sessions(session_id),
branch_metadata TEXT,
mode TEXT NOT NULL DEFAULT 'default'
);
CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated
ON agent_sessions(updated_at) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
turn_index INTEGER NOT NULL DEFAULT 0,
step_index INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL CHECK(role IN ('system', 'user', 'assistant', 'tool')),
content TEXT NOT NULL DEFAULT '',
thought TEXT,
tool_calls TEXT,
tool_call_id TEXT,
token_count INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
raw_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
agent_name TEXT NOT NULL DEFAULT 'lead',
active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id);
CREATE INDEX IF NOT EXISTS idx_agent_messages_turn ON agent_messages(session_id, turn_index);
CREATE INDEX IF NOT EXISTS idx_agent_messages_agent ON agent_messages(session_id, agent_name);
CREATE INDEX IF NOT EXISTS idx_agent_messages_active ON agent_messages(session_id, active);
-- ============================================================
-- 6. Agent 任务看板
-- ============================================================
CREATE TABLE IF NOT EXISTS agent_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'in_progress', 'completed')),
blocked_by TEXT NOT NULL DEFAULT '[]',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
owner TEXT NOT NULL DEFAULT '',
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_tasks_session ON agent_tasks(session_id);
CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(session_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_tasks_session_task ON agent_tasks(session_id, task_id);
-- ============================================================
-- 7. Agent 审计日志
-- ============================================================
CREATE TABLE IF NOT EXISTS agent_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
step INTEGER NOT NULL DEFAULT 0,
tool_name TEXT,
status TEXT NOT NULL CHECK(status IN ('OK', 'FAIL', 'SESSION_STOP')),
elapsed_ms INTEGER NOT NULL DEFAULT 0,
output_preview TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
agent_name TEXT NOT NULL DEFAULT 'lead',
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_audit_log_session ON agent_audit_log(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON agent_audit_log(created_at);
-- ============================================================
-- 8. Agent 团队协作
-- ============================================================
CREATE TABLE IF NOT EXISTS agent_team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
agent_role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('spawning', 'active', 'idle', 'shutdown')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE,
UNIQUE(session_id, agent_name)
);
CREATE INDEX IF NOT EXISTS idx_team_members_session ON agent_team_members(session_id);
CREATE INDEX IF NOT EXISTS idx_team_members_status ON agent_team_members(session_id, status);
-- ============================================================
-- 9. 全文搜索 (FTS5)
-- ============================================================
-- 文献全文搜索
CREATE VIRTUAL TABLE IF NOT EXISTS papers_fts USING fts5(
title,
authors,
keywords,
abstract,
pub,
content='papers',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS papers_fts_insert AFTER INSERT ON papers
BEGIN
INSERT INTO papers_fts(rowid, title, authors, keywords, abstract, pub)
VALUES (NEW.rowid, NEW.title, NEW.authors, NEW.keywords, NEW.abstract, NEW.pub);
END;
CREATE TRIGGER IF NOT EXISTS papers_fts_update AFTER UPDATE ON papers
BEGIN
UPDATE papers_fts SET
title = NEW.title,
authors = NEW.authors,
keywords = NEW.keywords,
abstract = NEW.abstract,
pub = NEW.pub
WHERE rowid = OLD.rowid;
END;
CREATE TRIGGER IF NOT EXISTS papers_fts_delete AFTER DELETE ON papers
BEGIN
DELETE FROM papers_fts WHERE rowid = OLD.rowid;
END;
-- Agent 会话全文搜索
CREATE VIRTUAL TABLE IF NOT EXISTS agent_sessions_fts USING fts5(
session_id,
title,
summary,
metadata,
content='agent_sessions',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions
BEGIN
INSERT INTO agent_sessions_fts(rowid, session_id, title, summary, metadata)
VALUES (NEW.rowid, NEW.session_id, NEW.title, NEW.summary, NEW.metadata);
END;
CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON agent_sessions
BEGIN
UPDATE agent_sessions_fts SET
session_id = NEW.session_id,
title = NEW.title,
summary = NEW.summary,
metadata = NEW.metadata
WHERE rowid = OLD.rowid;
END;
CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON agent_sessions
BEGIN
DELETE FROM agent_sessions_fts WHERE rowid = OLD.rowid;
END;
-- Agent 消息全文搜索
CREATE VIRTUAL TABLE IF NOT EXISTS agent_messages_fts USING fts5(
session_id,
role,
content,
thought,
tool_calls,
content='agent_messages',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages
BEGIN
INSERT INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls)
VALUES (NEW.rowid, NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON agent_messages
BEGIN
UPDATE agent_messages_fts SET
session_id = NEW.session_id,
role = NEW.role,
content = NEW.content,
thought = NEW.thought,
tool_calls = NEW.tool_calls
WHERE rowid = OLD.rowid;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON agent_messages
BEGIN
DELETE FROM agent_messages_fts WHERE rowid = OLD.rowid;
END;
-- 段落切片全文搜索(配合 vec_paper_chunks 向量检索做混合搜索)
CREATE VIRTUAL TABLE IF NOT EXISTS paper_chunks_fts USING fts5(
content,
content='paper_chunks_content',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS chunks_fts_insert AFTER INSERT ON paper_chunks_content
BEGIN
INSERT INTO paper_chunks_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
END;
CREATE TRIGGER IF NOT EXISTS chunks_fts_update AFTER UPDATE ON paper_chunks_content
BEGIN
UPDATE paper_chunks_fts SET content = NEW.content WHERE rowid = OLD.rowid;
END;
CREATE TRIGGER IF NOT EXISTS chunks_fts_delete AFTER DELETE ON paper_chunks_content
BEGIN
DELETE FROM paper_chunks_fts WHERE rowid = OLD.rowid;
END;
-- ============================================================
-- 注意: vec_paper_chunks 向量虚拟表由 main.rs 在运行时动态创建,
-- 维度由 EMBEDDING_DIM 环境变量控制 (默认 1536)。
-- ============================================================

View File

@ -1,32 +0,0 @@
-- migrations/20260608000000_init.sql
CREATE TABLE IF NOT EXISTS papers (
bibcode TEXT PRIMARY KEY,
title TEXT NOT NULL,
authors TEXT, -- JSON array of author names
year TEXT,
pub TEXT, -- journal / publisher
keywords TEXT, -- JSON array of keywords
abstract TEXT,
doi TEXT,
arxiv_id TEXT,
citation_count INTEGER DEFAULT 0,
reference_count INTEGER DEFAULT 0,
pdf_path TEXT,
html_path TEXT,
markdown_path TEXT,
translation_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS citations_references (
source_bibcode TEXT NOT NULL,
target_bibcode TEXT NOT NULL,
PRIMARY KEY (source_bibcode, target_bibcode)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_papers_doi ON papers(doi);
CREATE INDEX IF NOT EXISTS idx_papers_arxiv_id ON papers(arxiv_id);
CREATE INDEX IF NOT EXISTS idx_citations_ref_source ON citations_references(source_bibcode);
CREATE INDEX IF NOT EXISTS idx_citations_ref_target ON citations_references(target_bibcode);

View File

@ -1,15 +0,0 @@
-- migrations/20260608000001_notes.sql
-- 笔记/高亮表:每条记录对应一篇文献中某个段落的标注笔记
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bibcode TEXT NOT NULL,
paragraph_index INTEGER NOT NULL, -- 在文章 Markdown 段落中的序号(从 0 开始)
note_text TEXT NOT NULL DEFAULT '',
highlight_color TEXT NOT NULL DEFAULT 'yellow', -- 'yellow' | 'green' | 'blue' | 'pink'
selected_text TEXT NOT NULL DEFAULT '', -- 被高亮选中的原始文本片段
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bibcode) REFERENCES papers(bibcode) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notes_bibcode ON notes(bibcode);

View File

@ -1,3 +0,0 @@
-- migrations/20260608000002_add_doctype.sql
ALTER TABLE papers ADD COLUMN doctype TEXT;

View File

@ -1,12 +0,0 @@
-- migrations/20260608000003_sync_features.sql
-- 批量同步检索配置表 (支持唯一去重机制)
CREATE TABLE IF NOT EXISTS sync_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
source TEXT NOT NULL,
limit_count INTEGER NOT NULL,
last_run DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(query, source, limit_count) -- 去重约束
);

View File

@ -1,29 +0,0 @@
-- migrations/20260613000000_paper_chunks_metadata.sql
-- RAG 文本切片内容表与天体目标缓存表
-- 存储文献 Markdown 段落切片的元数据与文本内容
-- rowid 与 vec_paper_chunks 虚拟表的 rowid 保持 1:1 对齐
CREATE TABLE IF NOT EXISTS paper_chunks_content (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
bibcode TEXT,
paragraph_index INTEGER,
content TEXT,
FOREIGN KEY(bibcode) REFERENCES papers(bibcode) ON DELETE CASCADE
);
-- 天体目标信息缓存表,从 SIMBAD/Sesame 解析后本地持久化
CREATE TABLE IF NOT EXISTS paper_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bibcode TEXT,
target_name TEXT, -- 标准化天体名称 (如 GD 358)
ra TEXT, -- 赤道坐标:赤经 (Right Ascension)
dec TEXT, -- 赤道坐标:赤纬 (Declination)
parallax REAL, -- 视差 (mas毫角秒)
spectral_type TEXT, -- 光谱分类 (如 DA1)
v_magnitude REAL, -- 视星等
aliases TEXT, -- 天体别名 (JSON 数组文本)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(bibcode) REFERENCES papers(bibcode) ON DELETE CASCADE,
UNIQUE(bibcode, target_name)
);
CREATE INDEX IF NOT EXISTS idx_paper_targets_name ON paper_targets(target_name);

View File

@ -1,36 +0,0 @@
-- 科研智能体会话与消息持久化表
-- 支持 ReAct 框架的多轮对话、工具调用与思考链存储
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
turn_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
summary TEXT,
metadata TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
turn_index INTEGER NOT NULL DEFAULT 0,
step_index INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL CHECK(role IN ('system', 'user', 'assistant', 'tool')),
content TEXT NOT NULL DEFAULT '',
thought TEXT,
tool_calls TEXT,
tool_call_id TEXT,
token_count INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
raw_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id);
CREATE INDEX IF NOT EXISTS idx_agent_messages_turn ON agent_messages(session_id, turn_index);
CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated ON agent_sessions(updated_at) WHERE deleted_at IS NULL;

View File

@ -1,31 +0,0 @@
-- 智能体任务持久化表
-- 支持 DAG 依赖模式 (blocked_by) 和任务状态生命周期
--
-- Status lifecycle:
-- pending -> in_progress -> completed
-- (可回退: in_progress -> pending)
--
-- blocked_by 存储 JSON 数组格式: ["task_id_1", "task_id_2"]
-- 应用层负责验证 DAG 有效性(无自引用、无循环依赖)
CREATE TABLE IF NOT EXISTS agent_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'in_progress', 'completed')),
blocked_by TEXT NOT NULL DEFAULT '[]',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
-- 按会话查询任务(最常用)
CREATE INDEX IF NOT EXISTS idx_agent_tasks_session ON agent_tasks(session_id);
-- 按状态过滤(用于恢复/展示)
CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(session_id, status);
-- 保证同一会话内 task_id 唯一
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_tasks_session_task ON agent_tasks(session_id, task_id);

View File

@ -1,19 +0,0 @@
-- 智能体审计日志表
-- 记录所有工具调用的详细信息:工具名称、执行状态、耗时、输出预览
--
-- status: OK (成功) / FAIL (失败) / SESSION_STOP (会话终止)
CREATE TABLE IF NOT EXISTS agent_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
step INTEGER NOT NULL DEFAULT 0,
tool_name TEXT,
status TEXT NOT NULL CHECK(status IN ('OK', 'FAIL', 'SESSION_STOP')),
elapsed_ms INTEGER NOT NULL DEFAULT 0,
output_preview TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_audit_log_session ON agent_audit_log(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON agent_audit_log(created_at);

View File

@ -1,36 +0,0 @@
-- 智能体身份隔离与团队协作支持
--
-- 为子代理delegate_research和多智能体团队spawn_teammate
-- 提供 agent 粒度的消息隔离、审计追踪和任务归属。
--
-- 所有 ALTER TABLE 使用 DEFAULT 值,保证向后兼容:
-- 现有数据自动标记为 'lead',旧代码无需修改。
-- 1. agent_messages: 消息归属
ALTER TABLE agent_messages ADD COLUMN agent_name TEXT NOT NULL DEFAULT 'lead';
-- 2. agent_audit_log: 审计归属
ALTER TABLE agent_audit_log ADD COLUMN agent_name TEXT NOT NULL DEFAULT 'lead';
-- 3. agent_tasks: 任务分配目标(支持角色路由)
ALTER TABLE agent_tasks ADD COLUMN owner TEXT NOT NULL DEFAULT '';
-- 4. 新建: 团队成员注册表
CREATE TABLE IF NOT EXISTS agent_team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
agent_role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('spawning', 'active', 'idle', 'shutdown')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE,
UNIQUE(session_id, agent_name)
);
CREATE INDEX IF NOT EXISTS idx_team_members_session ON agent_team_members(session_id);
CREATE INDEX IF NOT EXISTS idx_team_members_status ON agent_team_members(session_id, status);
-- 重建 agent_messages 索引(包含 agent_name 过滤加速)
CREATE INDEX IF NOT EXISTS idx_agent_messages_agent ON agent_messages(session_id, agent_name);

View File

@ -1,20 +0,0 @@
-- Migration: Session Rewind Support
--
-- 添加会话回退 (undo) 所需的软删除基础设施。
-- 参考 Hermes-Agent hermes_state.py active=0 模型。
--
-- active 列:
-- 1 = 活跃消息(对 LLM 可见,默认值)
-- 0 = 软删除rewind 操作移除LLM 不可见,保留用于审计)
--
-- rewind_count:
-- 单调递增计数器,记录会话被回退的次数
-- 1. agent_messages: 添加 active 列
ALTER TABLE agent_messages ADD COLUMN active INTEGER NOT NULL DEFAULT 1;
-- 2. agent_sessions: 添加 rewind_count
ALTER TABLE agent_sessions ADD COLUMN rewind_count INTEGER NOT NULL DEFAULT 0;
-- 3. 加速 active=1 过滤的索引(覆盖最常用的查询模式)
CREATE INDEX IF NOT EXISTS idx_agent_messages_active ON agent_messages(session_id, active);

View File

@ -1,13 +0,0 @@
-- Migration: Session Branch Support
--
-- 添加会话分叉所需的基础设施。
-- 参考 Hermes-Agent parent_session_id 模型。
--
-- parent_session_id: 分叉的源会话 IDNULL = 根会话)
-- branched_from: 分叉的消息 ID在源会话中的分叉点
-- 1. agent_sessions: 添加 parent_session_id
ALTER TABLE agent_sessions ADD COLUMN parent_session_id TEXT REFERENCES agent_sessions(session_id);
-- 2. agent_sessions: 添加分支元数据 JSON存储 {branched_from, branch_reason} 等)
ALTER TABLE agent_sessions ADD COLUMN branch_metadata TEXT;

View File

@ -1,73 +0,0 @@
-- FTS5 跨会话全文搜索
-- 在 agent_sessions 和 agent_messages 上创建虚拟表,支持 BM25 排序搜索。
CREATE VIRTUAL TABLE IF NOT EXISTS agent_sessions_fts USING fts5(
session_id,
title,
summary,
metadata,
content='agent_sessions',
content_rowid='rowid'
);
CREATE VIRTUAL TABLE IF NOT EXISTS agent_messages_fts USING fts5(
session_id,
role,
content,
thought,
tool_calls,
content='agent_messages',
content_rowid='rowid'
);
-- Triggers: sessions
CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions
BEGIN
INSERT INTO agent_sessions_fts(session_id, title, summary, metadata)
VALUES (NEW.session_id, NEW.title, NEW.summary, NEW.metadata);
END;
CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON agent_sessions
BEGIN
UPDATE agent_sessions_fts SET
session_id = NEW.session_id,
title = NEW.title,
summary = NEW.summary,
metadata = NEW.metadata
WHERE rowid = OLD.rowid;
END;
CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON agent_sessions
BEGIN
DELETE FROM agent_sessions_fts WHERE rowid = OLD.rowid;
END;
-- Triggers: messages
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages
BEGIN
INSERT INTO agent_messages_fts(session_id, role, content, thought, tool_calls)
VALUES (NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON agent_messages
BEGIN
UPDATE agent_messages_fts SET
session_id = NEW.session_id,
role = NEW.role,
content = NEW.content,
thought = NEW.thought,
tool_calls = NEW.tool_calls
WHERE rowid = OLD.rowid;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON agent_messages
BEGIN
DELETE FROM agent_messages_fts WHERE rowid = OLD.rowid;
END;
-- 回填已有数据
INSERT OR IGNORE INTO agent_sessions_fts(session_id, title, summary, metadata)
SELECT session_id, title, summary, metadata FROM agent_sessions;
INSERT OR IGNORE INTO agent_messages_fts(session_id, role, content, thought, tool_calls)
SELECT session_id, role, content, thought, tool_calls FROM agent_messages;

View File

@ -1,30 +0,0 @@
-- 修复 FTS5 全文搜索中虚拟表与原始物理表的 rowid 不一致问题
-- 增量迁移:删除旧触发器,以正确映射 rowid 的触发器替代,并重建现有 FTS5 数据
-- 1. 删除旧的不安全触发器
DROP TRIGGER IF EXISTS sessions_fts_insert;
DROP TRIGGER IF EXISTS messages_fts_insert;
-- 2. 重新创建指定 rowid 列的插入触发器,确保 FTS 行 ID 与原始物理表行 ID 完全一致
CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions
BEGIN
INSERT INTO agent_sessions_fts(rowid, session_id, title, summary, metadata)
VALUES (NEW.rowid, NEW.session_id, NEW.title, NEW.summary, NEW.metadata);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages
BEGIN
INSERT INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls)
VALUES (NEW.rowid, NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls);
END;
-- 3. 清理之前因不一致导致的错乱 FTS 数据
DELETE FROM agent_sessions_fts;
DELETE FROM agent_messages_fts;
-- 4. 使用正确的 rowid 重新索引现有数据
INSERT OR IGNORE INTO agent_sessions_fts(rowid, session_id, title, summary, metadata)
SELECT rowid, session_id, title, summary, metadata FROM agent_sessions;
INSERT OR IGNORE INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls)
SELECT rowid, session_id, role, content, thought, tool_calls FROM agent_messages;

View File

@ -1,11 +0,0 @@
-- Migration: Add Agent Session Mode Support
--
-- 为代理会话增加运行模式 (mode) 字段,支持多模式 Agent 架构。
-- 现有会话自动获得 mode='default'(保持当前行为不变)。
--
-- mode 取值:
-- 'default' — 通用科研助手
-- 'deep-research' — 深度研究模式
-- 'literature-reader' — 文献阅读助手模式
ALTER TABLE agent_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'default';

View File

@ -123,6 +123,7 @@ pub async fn spawn_background_task(
queue: Arc<BgNotificationQueue>,
tool_name: String,
bibcode: String,
tasks: Vec<String>,
) -> BgTaskHandle {
let task_id = uuid::Uuid::new_v4().to_string();
// 取前 8 位便于显示
@ -152,7 +153,7 @@ pub async fn spawn_background_task(
// 构造 ToolContext 和参数 (后台任务:静默模式)
let tool_ctx = ToolContext::silent(app_state_clone.clone());
let args = serde_json::json!({"bibcode": bibcode_clone});
let args = serde_json::json!({"bibcode": bibcode_clone, "tasks": tasks});
let tool_registry = ToolRegistry::new(app_state_clone.skill_registry.clone());
let output = match tool_registry.get(&tool_name_clone) {

221
src/agent/enums.rs Normal file
View File

@ -0,0 +1,221 @@
// src/agent/enums.rs
//
// 强类型状态/模式枚举,替代散落各处的魔法字符串。
//
// 设计:用 Display/FromStr/serde 而非 sqlx::Type。
// - SQL 字面量(如 WHERE status = 'pending')保持原样——它们与 DB 存储值一致
// - .bind() 处传枚举值,获得编译期类型安全(拼错编译报错)
// - emoji 映射等重复逻辑统一到枚举方法,消除多处 match 重复
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
// ════════════════════════════════════════════════════════════════════════
// TaskStatus — agent_tasks.status (pending / in_progress / completed)
// ════════════════════════════════════════════════════════════════════════
/// 共享任务看板与待办列表的任务状态。
///
/// DB CHECK 约束:`status IN ('pending', 'in_progress', 'completed')`。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
InProgress,
Completed,
}
impl TaskStatus {
/// 数据库存储的字符串值。
pub fn as_str(&self) -> &'static str {
match self {
TaskStatus::Pending => "pending",
TaskStatus::InProgress => "in_progress",
TaskStatus::Completed => "completed",
}
}
/// 前端显示用的状态图标,统一三处重复的 emoji 映射。
pub fn icon(&self) -> &'static str {
match self {
TaskStatus::Pending => "",
TaskStatus::InProgress => "🔄",
TaskStatus::Completed => "",
}
}
}
impl fmt::Display for TaskStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for TaskStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pending" => Ok(TaskStatus::Pending),
"in_progress" => Ok(TaskStatus::InProgress),
"completed" => Ok(TaskStatus::Completed),
other => Err(format!("未知任务状态: {}", other)),
}
}
}
// ════════════════════════════════════════════════════════════════════════
// AuditStatus — agent_audit_log.status (OK / FAIL / SESSION_STOP)
// ════════════════════════════════════════════════════════════════════════
/// 审计日志的工具调用结果状态。
///
/// DB CHECK 约束:`status IN ('OK', 'FAIL', 'SESSION_STOP')`。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum AuditStatus {
Ok,
Fail,
SessionStop,
}
impl AuditStatus {
pub fn as_str(&self) -> &'static str {
match self {
AuditStatus::Ok => "OK",
AuditStatus::Fail => "FAIL",
AuditStatus::SessionStop => "SESSION_STOP",
}
}
}
impl fmt::Display for AuditStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for AuditStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"OK" => Ok(AuditStatus::Ok),
"FAIL" => Ok(AuditStatus::Fail),
"SESSION_STOP" => Ok(AuditStatus::SessionStop),
other => Err(format!("未知审计状态: {}", other)),
}
}
}
// ════════════════════════════════════════════════════════════════════════
// SessionMode — agent_sessions.mode (default / deep-research / literature-reader)
// ════════════════════════════════════════════════════════════════════════
/// Agent 会话运行模式。
///
/// DB 无 CHECK 约束,仅 `DEFAULT 'default'`。
/// 值为 kebab-case序列化/反序列化保持 kebab-case 以兼容前端。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionMode {
#[serde(rename = "default")]
#[default]
Default,
#[serde(rename = "deep-research")]
DeepResearch,
#[serde(rename = "literature-reader")]
LiteratureReader,
}
impl SessionMode {
pub fn as_str(&self) -> &'static str {
match self {
SessionMode::Default => "default",
SessionMode::DeepResearch => "deep-research",
SessionMode::LiteratureReader => "literature-reader",
}
}
pub fn default_str() -> &'static str {
"default"
}
}
impl fmt::Display for SessionMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for SessionMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"default" => Ok(SessionMode::Default),
"deep-research" => Ok(SessionMode::DeepResearch),
"literature-reader" => Ok(SessionMode::LiteratureReader),
other => Err(format!("未知会话模式: {}", other)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_status_roundtrip() {
for s in [
TaskStatus::Pending,
TaskStatus::InProgress,
TaskStatus::Completed,
] {
let back = TaskStatus::from_str(s.as_str()).unwrap();
assert_eq!(s, back);
}
assert_eq!(TaskStatus::Pending.icon(), "");
assert_eq!(TaskStatus::InProgress.icon(), "🔄");
assert_eq!(TaskStatus::Completed.icon(), "");
}
#[test]
fn test_audit_status_roundtrip() {
for s in [AuditStatus::Ok, AuditStatus::Fail, AuditStatus::SessionStop] {
let back = AuditStatus::from_str(s.as_str()).unwrap();
assert_eq!(s, back);
}
}
#[test]
fn test_session_mode_roundtrip() {
for s in [
SessionMode::Default,
SessionMode::DeepResearch,
SessionMode::LiteratureReader,
] {
let back = SessionMode::from_str(s.as_str()).unwrap();
assert_eq!(s, back);
}
}
#[test]
fn test_session_mode_serde_kebab() {
// 序列化必须是 kebab-case兼容前端
let json = serde_json::to_string(&SessionMode::DeepResearch).unwrap();
assert_eq!(json, "\"deep-research\"");
let back: SessionMode = serde_json::from_str("\"literature-reader\"").unwrap();
assert_eq!(back, SessionMode::LiteratureReader);
}
#[test]
fn test_task_status_serde_snake() {
let json = serde_json::to_string(&TaskStatus::InProgress).unwrap();
assert_eq!(json, "\"in_progress\"");
}
#[test]
fn test_from_str_invalid() {
assert!(TaskStatus::from_str("defualt").is_err());
assert!(AuditStatus::from_str("warn").is_err());
assert!(SessionMode::from_str("research").is_err());
}
}

View File

@ -203,7 +203,11 @@ impl AgentHook for AuditLogHook {
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
let status = if ctx.is_error { "FAIL" } else { "OK" };
let status = if ctx.is_error {
crate::agent::enums::AuditStatus::Fail
} else {
crate::agent::enums::AuditStatus::Ok
};
let preview: String = ctx.output_content.chars().take(200).collect();
info!(
@ -228,7 +232,7 @@ impl AgentHook for AuditLogHook {
.bind(&session_id)
.bind(step as i32)
.bind(&tool_name)
.bind(status)
.bind(status.as_str())
.bind(elapsed as i32)
.bind(&preview_clone)
.bind(&agent_name)

View File

@ -269,8 +269,10 @@ mod tests {
#[tokio::test]
async fn test_session_start_hook_called() {
let started: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
struct StartTrackingHook {
started: std::sync::Mutex<Vec<String>>,
started: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
@ -284,7 +286,7 @@ mod tests {
}
let hook = StartTrackingHook {
started: std::sync::Mutex::new(Vec::new()),
started: Arc::clone(&started),
};
let mut registry = HookRegistry::new();
@ -296,15 +298,25 @@ mod tests {
is_resume: false,
};
registry.run_on_session_start(&ctx).await;
// 验证 hook 确实被调用了
let calls = started.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "test_sid");
}
#[tokio::test]
async fn test_new_lifecycle_events_called() {
let subagent_start = Arc::new(Mutex::new(false));
let subagent_stop = Arc::new(Mutex::new(false));
let pre_compact = Arc::new(Mutex::new(false));
let post_compact = Arc::new(Mutex::new(false));
struct LifecycleTracker {
subagent_start: std::sync::Mutex<bool>,
subagent_stop: std::sync::Mutex<bool>,
pre_compact: std::sync::Mutex<bool>,
post_compact: std::sync::Mutex<bool>,
subagent_start: Arc<Mutex<bool>>,
subagent_stop: Arc<Mutex<bool>>,
pre_compact: Arc<Mutex<bool>>,
post_compact: Arc<Mutex<bool>>,
}
#[async_trait]
@ -327,10 +339,10 @@ mod tests {
}
let tracker = LifecycleTracker {
subagent_start: std::sync::Mutex::new(false),
subagent_stop: std::sync::Mutex::new(false),
pre_compact: std::sync::Mutex::new(false),
post_compact: std::sync::Mutex::new(false),
subagent_start: Arc::clone(&subagent_start),
subagent_stop: Arc::clone(&subagent_stop),
pre_compact: Arc::clone(&pre_compact),
post_compact: Arc::clone(&post_compact),
};
let mut registry = HookRegistry::new();
@ -367,13 +379,31 @@ mod tests {
})
.await;
// If no panic, all hooks were called successfully
// 验证所有 4 个 lifecycle hook 都被调用了
assert!(
*subagent_start.lock().unwrap(),
"on_subagent_start should be called"
);
assert!(
*subagent_stop.lock().unwrap(),
"on_subagent_stop should be called"
);
assert!(
*pre_compact.lock().unwrap(),
"on_pre_compact should be called"
);
assert!(
*post_compact.lock().unwrap(),
"on_post_compact should be called"
);
}
#[tokio::test]
async fn test_user_prompt_submit_hook_called() {
let prompts: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
struct PromptTrackingHook {
prompts: std::sync::Mutex<Vec<String>>,
prompts: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
@ -387,7 +417,7 @@ mod tests {
}
let hook = PromptTrackingHook {
prompts: std::sync::Mutex::new(Vec::new()),
prompts: Arc::clone(&prompts),
};
let mut registry = HookRegistry::new();
@ -399,6 +429,11 @@ mod tests {
turn_index: 2,
};
registry.run_on_user_prompt_submit(&ctx).await;
// 验证 hook 被调用并接收了正确的 prompt
let calls = prompts.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "Hello, agent!");
}
#[tokio::test]

View File

@ -416,6 +416,7 @@ impl MemoryManager {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_build_empty_reminder() {
@ -426,4 +427,134 @@ mod tests {
};
assert!(manager.build_system_reminder(10).is_none());
}
#[test]
fn test_save_memory_creates_file_and_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = MemoryManager::new(tmp.path().to_path_buf());
mgr.save_memory(
"test-slug",
"Test Name",
"A test memory",
MemoryType::User,
"Memory content",
)
.expect("save should succeed");
// 验证文件存在
let file_path = tmp.path().join("memory").join("test-slug.md");
assert!(file_path.exists(), "memory file should exist on disk");
// 验证内容正确
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(content.contains("name: Test Name"));
assert!(content.contains("Memory content"));
// 验证 reload 后条目存在
assert_eq!(mgr.entries().len(), 1, "should have one entry after save");
assert_eq!(mgr.entries()[0].slug, "test-slug");
}
#[test]
fn test_save_memory_updates_existing_and_archives() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = MemoryManager::new(tmp.path().to_path_buf());
// 第一次保存
mgr.save_memory(
"update-slug",
"V1",
"desc",
MemoryType::User,
"Version 1 content",
)
.expect("first save");
assert_eq!(mgr.entries().len(), 1);
// 第二次保存同一 slug旧版本归档为 _v1.md但 reload 会同时扫描到)
mgr.save_memory(
"update-slug",
"V2",
"desc updated",
MemoryType::User,
"Version 2 content",
)
.expect("second save");
// 归档的旧版本也会被加载reload 不排除 _v1.md 文件),所以 entries.len() >= 1
assert!(
mgr.entries().len() >= 1,
"at least the active entry should exist"
);
// 验证最新版本存在
let latest = mgr.entries().iter().find(|e| e.slug == "update-slug");
assert!(latest.is_some(), "latest version should be loaded");
assert_eq!(latest.unwrap().name, "V2");
// 验证旧版本被归档
let archive_path = tmp.path().join("memory").join("update-slug_v1.md");
assert!(
archive_path.exists(),
"old version should be archived as _v1.md"
);
let archived = std::fs::read_to_string(&archive_path).unwrap();
assert!(archived.contains("Version 1 content"));
}
#[test]
fn test_build_system_reminder_with_entries() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = MemoryManager::new(tmp.path().to_path_buf());
mgr.save_memory("m1", "Memory One", "First", MemoryType::User, "Content 1")
.unwrap();
mgr.save_memory(
"m2",
"Memory Two",
"Second",
MemoryType::Project,
"Content 2",
)
.unwrap();
mgr.save_memory(
"m3",
"Memory Three",
"Third",
MemoryType::Reference,
"Content 3",
)
.unwrap();
let reminder = mgr
.build_system_reminder(2)
.expect("should produce reminder with entries");
assert!(reminder.contains("<project-memory-context>"));
assert!(reminder.contains("</project-memory-context>"));
// 应按 mtime 排序(最近的在前面),返回最多 2 条
assert!(reminder.contains("Memory Three") || reminder.contains("Memory Two"));
}
#[test]
fn test_reload_from_disk() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr_a = MemoryManager::new(tmp.path().to_path_buf());
mgr_a
.save_memory(
"shared",
"Shared Mem",
"desc",
MemoryType::User,
"Shared content",
)
.unwrap();
// 第二个 MemoryManager 指向同一目录reload 应加载到相同条目
let mgr_b = MemoryManager::new(tmp.path().to_path_buf());
assert_eq!(mgr_b.entries().len(), 1);
assert_eq!(mgr_b.entries()[0].slug, "shared");
assert_eq!(mgr_b.entries()[0].name, "Shared Mem");
}
}

View File

@ -8,7 +8,7 @@
// 3. 支持工具感知过滤(排除最近使用工具相关的记忆)
// 4. 失败时优雅降级到 recency 回退
use crate::clients::llm::LlmClient;
use crate::clients::llm::ChatCompleter;
use tracing::info;
use super::types::MemoryEntry;
@ -40,7 +40,7 @@ impl Default for SelectionContext {
/// `sel_ctx.already_surfaced` 中的条目会被排除在候选之外。
pub async fn select_structured(
entries: &[MemoryEntry],
llm: &LlmClient,
llm: &dyn ChatCompleter,
context: &str,
sel_ctx: &SelectionContext,
) -> Vec<usize> {
@ -93,7 +93,7 @@ pub async fn select_structured(
);
match llm
.chat_completion(
.complete(
"你是一个记忆检索助手。根据用户话题选择最相关的记忆。仅返回 JSON 字符串数组。",
&prompt,
)
@ -348,4 +348,154 @@ mod tests {
assert_eq!(sorted[0], 0, "活跃记忆应在前");
assert_eq!(sorted[1], 1, "historical 应在后");
}
#[test]
fn test_fallback_recency_with_empty_entries() {
let entries: Vec<MemoryEntry> = vec![];
let result = fallback_recency(&entries, 5, &[]);
assert!(
result.is_empty(),
"empty entries should produce empty result"
);
}
#[test]
fn test_fallback_recency_under_max_entries() {
let entries = vec![
make_entry("a", "Entry A", "desc"),
make_entry("b", "Entry B", "desc"),
];
let result = fallback_recency(&entries, 5, &[]);
assert_eq!(result.len(), 2, "should return all entries when under max");
}
#[test]
fn test_fallback_recency_all_already_surfaced() {
let entries = vec![
make_entry("a", "Entry A", "desc"),
make_entry("b", "Entry B", "desc"),
make_entry("c", "Entry C", "desc"),
];
let surfaced: &[usize] = &[0, 1, 2];
let result = fallback_recency(&entries, 5, surfaced);
assert!(result.is_empty(), "all surfaced should return empty");
}
#[test]
fn test_fallback_recency_partial_surfaced() {
let entries = vec![
make_entry("a", "Entry A", "desc"),
make_entry("b", "Entry B", "desc"),
make_entry("c", "Entry C", "desc"),
make_entry("d", "Entry D", "desc"),
];
let surfaced: &[usize] = &[1, 3];
let result = fallback_recency(&entries, 5, surfaced);
assert_eq!(result.len(), 2, "should return non-surfaced entries");
assert!(result.contains(&0), "should include index 0");
assert!(result.contains(&2), "should include index 2");
assert!(!result.contains(&1), "should exclude surfaced index 1");
assert!(!result.contains(&3), "should exclude surfaced index 3");
}
#[test]
fn test_fallback_recency_respects_max_limit() {
let entries = vec![
make_entry("a", "Entry A", "desc"),
make_entry("b", "Entry B", "desc"),
make_entry("c", "Entry C", "desc"),
];
let result = fallback_recency(&entries, 2, &[]);
assert_eq!(result.len(), 2, "should respect max_entries limit");
}
// ── Mock ChatCompleter 测试 select_structured 的 LLM 路径 ──
use crate::clients::llm::ChatCompleter;
use async_trait::async_trait;
use std::sync::Mutex;
struct MockChat {
responses: Mutex<Vec<String>>,
call_count: Mutex<usize>,
}
impl MockChat {
fn new(responses: Vec<String>) -> Self {
MockChat {
responses: Mutex::new(responses),
call_count: Mutex::new(0),
}
}
}
#[async_trait]
impl ChatCompleter for MockChat {
async fn complete(&self, _sys: &str, _user: &str) -> anyhow::Result<String> {
let mut count = self.call_count.lock().unwrap();
let idx = *count;
*count += 1;
let responses = self.responses.lock().unwrap();
if idx < responses.len() {
Ok(responses[idx].clone())
} else {
Ok("[]".to_string())
}
}
}
#[tokio::test]
async fn test_select_structured_llm_returns_valid_slugs() {
let entries = vec![
make_entry("m1", "Memory One", "First memory"),
make_entry("m2", "Memory Two", "Second memory"),
make_entry("m3", "Memory Three", "Third memory"),
make_entry("m4", "Memory Four", "Fourth memory"),
];
let mock = MockChat::new(vec![r#"["m1", "m3"]"#.to_string()]);
let sel_ctx = SelectionContext {
max_entries: 2,
..Default::default()
};
let result = select_structured(&entries, &mock, "test query", &sel_ctx).await;
assert_eq!(result.len(), 2, "should return 2 selected entries");
assert!(result.contains(&0), "should include m1 (index 0)");
assert!(result.contains(&2), "should include m3 (index 2)");
}
#[tokio::test]
async fn test_select_structured_llm_invalid_json_falls_back() {
let entries = vec![
make_entry("m1", "Memory One", "First"),
make_entry("m2", "Memory Two", "Second"),
];
let mock = MockChat::new(vec!["not valid json".to_string()]);
let sel_ctx = SelectionContext {
max_entries: 1,
..Default::default()
};
let result = select_structured(&entries, &mock, "test", &sel_ctx).await;
assert_eq!(result.len(), 1, "fallback should return max_entries items");
}
#[tokio::test]
async fn test_select_structured_llm_numeric_array() {
let entries = vec![
make_entry("m1", "Memory One", "First"),
make_entry("m2", "Memory Two", "Second"),
make_entry("m3", "Memory Three", "Third"),
];
let mock = MockChat::new(vec![r#"[0, 2]"#.to_string()]);
let sel_ctx = SelectionContext {
max_entries: 2,
..Default::default()
};
let result = select_structured(&entries, &mock, "test", &sel_ctx).await;
assert_eq!(result.len(), 2);
assert!(result.contains(&0));
assert!(result.contains(&2));
}
}

View File

@ -12,6 +12,7 @@
pub mod autonomous;
pub mod background;
pub mod compact;
pub mod enums;
pub mod hooks;
pub mod memory;
pub mod modes;

View File

@ -23,10 +23,10 @@ pub const DEEP_RESEARCH_MODE: AgentMode = AgentMode {
#
1. 广使 search_papers broad searchrows=25
2. + 8-15
3. download_paper parse_paper get_paper_content
3. 使 process_paper (tasks: [\"download\",\"parse\"]) → get_paper_content 深读,提取关键发现和方法论。
4. 使 subagent
5. rag_search
6.
6. 使 get_citation_network
7. 使 todo_write
8. /
9.

View File

@ -12,8 +12,11 @@ pub const LITERATURE_READER_TOOLS: &[&str] = &[
"grep_files",
"glob_files",
"search_papers",
"search_local_library",
"get_paper_metadata",
"get_paper_outline",
"get_paper_content",
"get_citation_network",
"rag_search",
"query_target",
"save_note",
@ -43,11 +46,11 @@ pub const LITERATURE_READER_MODE: AgentMode = AgentMode {
principles_override: Some(
"\
#
1. 使 get_paper_content
1. 使 get_paper_outline 使 get_paper_content
2.
3. LaTeX Trie
4.
5.
5. 使 get_citation_network
6. 使 save_note ////
7. Shell
8. 使 LaTeX

View File

@ -276,11 +276,13 @@ mod tests {
assert!(!tools_set.contains("run_bash"));
assert!(!tools_set.contains("file_write"));
assert!(!tools_set.contains("file_edit"));
assert!(!tools_set.contains("download_paper"));
assert!(!tools_set.contains("parse_paper"));
// 确保包含核心阅读工具
assert!(!tools_set.contains("process_paper"));
// 确保包含核心阅读与研究工具
assert!(tools_set.contains("get_paper_content"));
assert!(tools_set.contains("get_paper_outline"));
assert!(tools_set.contains("search_papers"));
assert!(tools_set.contains("search_local_library"));
assert!(tools_set.contains("get_citation_network"));
assert!(tools_set.contains("rag_search"));
assert!(tools_set.contains("save_note"));
} else {

View File

@ -805,14 +805,20 @@ mod tests {
// First turn
mgr.new_turn();
let result = mgr.ensure_checkpoint(&work, "initial");
// git2 may fail in test environments without git config
// We just verify it doesn't panic
let _ = result;
// git2 may fail in test environments without git config;
// the dedup tracker always works regardless
if result {
// git2 succeeded — verify the commit was created
let entries = mgr.list_checkpoints(&work);
assert!(!entries.is_empty(), "should have at least one checkpoint");
}
// List checkpoints
let entries = mgr.list_checkpoints(&work);
// Not asserting count since git2 may behave differently
let _ = entries;
// 验证 turn 内去重:同一目录同一 turn 再次调用应返回 false
let second = mgr.ensure_checkpoint(&work, "second");
assert!(
!second,
"dedup should prevent second checkpoint in same turn"
);
}
#[test]
@ -843,12 +849,17 @@ mod tests {
let mgr = setup(&tmp, true);
mgr.new_turn();
let _ = mgr.ensure_checkpoint(&work, "turn1");
let _first = mgr.ensure_checkpoint(&work, "turn1");
mgr.new_turn(); // reset
// 新 turn 应该可以再次快照
mgr.new_turn(); // reset dedup tracker
// 新 turn 应该重置去重状态:即使 git2 可能因无变更而跳过,
// ensure_checkpoint 至少不应被 turn 内去重拦截
let result = mgr.ensure_checkpoint(&work, "turn2");
let _ = result; // may or may not create depending on changes
// 验证:如果 turn1 创建了快照turn2 的去重计数器已被重置,
// ensure_checkpoint 不会被"同一 turn 已快照"的逻辑拦截
// result 可能为 truegit2 创建了 commit或 false无文件变更
// 但不应 panic
let _ = result;
}
#[test]

View File

@ -27,7 +27,6 @@ pub enum CircuitState {
}
/// 压缩熔断器
#[derive(Debug)]
pub struct CompactionCircuitBreaker {
/// 连续失败计数
consecutive_failures: usize,
@ -37,6 +36,19 @@ pub struct CompactionCircuitBreaker {
state: CircuitState,
/// 熔断器打开的时间(用于自动恢复)
opened_at: Option<Instant>,
/// 可注入的时间源仅用于测试。None 时使用 Instant::now()。
time_source: Option<Box<dyn Fn() -> Instant + Send>>,
}
impl std::fmt::Debug for CompactionCircuitBreaker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompactionCircuitBreaker")
.field("consecutive_failures", &self.consecutive_failures)
.field("total_compactions", &self.total_compactions)
.field("state", &self.state)
.field("opened_at", &self.opened_at)
.finish()
}
}
impl CompactionCircuitBreaker {
@ -47,9 +59,17 @@ impl CompactionCircuitBreaker {
total_compactions: 0,
state: CircuitState::Closed,
opened_at: None,
time_source: None,
}
}
/// 设置可注入的时间源(仅用于测试)。
#[cfg(test)]
fn with_time_source(mut self, f: Box<dyn Fn() -> Instant + Send>) -> Self {
self.time_source = Some(f);
self
}
/// 记录一次成功的压缩(重置失败计数,关闭熔断器)
pub fn record_success(&mut self) {
self.consecutive_failures = 0;
@ -92,7 +112,13 @@ impl CompactionCircuitBreaker {
CircuitState::Open => {
// 检查是否已超时,可自动进入 HalfOpen
if let Some(opened) = self.opened_at {
if opened.elapsed().as_secs() >= AUTO_RECOVERY_TIMEOUT_SECS {
let now = if let Some(ref ts) = self.time_source {
ts()
} else {
Instant::now()
};
let elapsed = now.duration_since(opened);
if elapsed.as_secs() >= AUTO_RECOVERY_TIMEOUT_SECS {
self.state = CircuitState::HalfOpen;
warn!(
"[CircuitBreaker] 熔断器超时,进入 HalfOpen 状态,\
@ -211,4 +237,49 @@ mod tests {
assert!(breaker.is_open());
assert_eq!(breaker.consecutive_failures(), 4);
}
#[test]
fn test_half_open_after_timeout() {
let mut breaker = CompactionCircuitBreaker::new();
// 触发 3 次失败进入 Open 状态
for _ in 0..3 {
breaker.record_failure();
}
assert!(breaker.is_open());
assert!(
!breaker.can_attempt(),
"should be blocked while Open and not timed out"
);
// 注入假时钟:返回 opened_at + 301s已超过 300s 超时)
let opened_at = breaker.opened_at.unwrap();
breaker.time_source = Some(Box::new(move || {
opened_at + std::time::Duration::from_secs(301)
}));
assert!(breaker.can_attempt(), "should allow attempt after timeout");
// 状态应转换为 HalfOpen
assert!(!breaker.is_open(), "should no longer be Open after timeout");
}
#[test]
fn test_cannot_attempt_before_timeout() {
let mut breaker = CompactionCircuitBreaker::new();
for _ in 0..3 {
breaker.record_failure();
}
assert!(breaker.is_open());
// 注入假时钟:仅过了 10s远未到 300s 超时
let opened_at = breaker.opened_at.unwrap();
breaker.time_source = Some(Box::new(move || {
opened_at + std::time::Duration::from_secs(10)
}));
assert!(
!breaker.can_attempt(),
"should still be blocked before timeout"
);
assert!(breaker.is_open(), "should remain Open");
}
}

View File

@ -56,11 +56,10 @@ async fn restore_tasks_from_db(db: &SqlitePool, session_id: &str) -> Option<Stri
let mut lines: Vec<String> = Vec::new();
for (task_id, content, status, blocked_by, owner) in &rows {
let icon = match status.as_str() {
"in_progress" => "🔄",
"completed" => "",
_ => "",
};
let task_status: crate::agent::enums::TaskStatus =
std::str::FromStr::from_str(status.as_str())
.unwrap_or(crate::agent::enums::TaskStatus::Pending);
let icon = task_status.icon();
let blocked: Vec<String> = serde_json::from_str(blocked_by).unwrap_or_default();
let mut line = format!("{} [{}] {}", icon, task_id, content);

View File

@ -566,7 +566,36 @@ mod tests {
}
#[test]
fn test_file_unchanged_stub_is_static() {
assert!(FILE_UNCHANGED_STUB.contains("File unchanged"));
fn test_build_restore_context_respects_limit() {
let mut snapshot: FileStateSnapshot = Vec::new();
// 按时间倒序排列(调用方已排序,最新的在前)
for i in (0..5).rev() {
snapshot.push((
format!("/tmp/file_{}.txt", i),
FileState {
content: format!("content_{}", i),
timestamp: 1000 + i as i64,
offset: 0,
limit: None,
},
));
}
let context = FileStateCache::build_restore_context(&snapshot, 2);
assert!(
!context.is_empty(),
"should produce context when snapshot has files"
);
// build_restore_context 返回 Vec<String>,包含最多 max_files 条
assert!(
context.len() <= 2,
"should return at most 2 entries, got {}",
context.len()
);
// 应包含时间戳最大的文件(排在最前面)
assert!(
context.iter().any(|s| s.contains("content_4")),
"should include the newest file content"
);
}
}

View File

@ -25,13 +25,12 @@ pub struct ResolvedProfile {
fn builtin_readonly() -> ResolvedProfile {
ResolvedProfile {
name: "readonly".into(),
description: "只读访问 — 禁止 Shell 执行、文件写入、论文下载/解析".into(),
description: "只读访问 — 禁止 Shell 执行、文件写入、论文处理".into(),
deny_rules: vec![
"run_bash".into(),
"file_write".into(),
"file_edit".into(),
"download_paper".into(),
"parse_paper".into(),
"process_paper".into(),
"subagent".into(),
],
allow_rules: vec![
@ -41,6 +40,9 @@ fn builtin_readonly() -> ResolvedProfile {
"search_papers".into(),
"get_paper_metadata".into(),
"get_paper_content".into(),
"get_paper_outline".into(),
"search_local_library".into(),
"get_citation_network".into(),
"rag_search".into(),
"query_target".into(),
"load_skill".into(),
@ -71,8 +73,10 @@ fn builtin_research() -> ResolvedProfile {
"search_papers".into(),
"get_paper_metadata".into(),
"get_paper_content".into(),
"download_paper".into(),
"parse_paper".into(),
"get_paper_outline".into(),
"search_local_library".into(),
"get_citation_network".into(),
"process_paper".into(),
"rag_search".into(),
"query_target".into(),
"save_note".into(),

View File

@ -1001,4 +1001,83 @@ mod tests {
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 1);
}
// ── create_or_resume_session 测试 ──
async fn setup_db_with_mode() -> SqlitePool {
let pool = setup_db().await;
// 添加 mode 列(生产环境中由 migration 20260624000000_add_session_mode.sql 添加)
sqlx::query("ALTER TABLE agent_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'default'")
.execute(&pool)
.await
.ok(); // 如果列已存在则忽略
pool
}
#[tokio::test]
async fn test_create_new_session() {
let db = setup_db_with_mode().await;
let llm = LlmClient::new("key".into(), "http://localhost".into(), "test-model".into());
let info = create_or_resume_session(&db, None, &llm, "deep-research")
.await
.expect("create should succeed");
assert!(!info.session_id.is_empty(), "new session should have an id");
assert_eq!(info.turn_index, 0, "new session turn_index should be 0");
assert_eq!(info.mode, "deep-research", "mode should match input");
// 验证 DB 中确实插入了行
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM agent_sessions WHERE session_id = ?")
.bind(&info.session_id)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(count, 1, "session should be persisted");
}
#[tokio::test]
async fn test_resume_existing_session() {
let db = setup_db_with_mode().await;
let llm = LlmClient::new("key".into(), "http://localhost".into(), "test-model".into());
// 先创建一个会话
let created = create_or_resume_session(&db, None, &llm, "literature-reader")
.await
.unwrap();
// 再恢复mode 参数应被忽略,从 DB 读取)
let resumed = create_or_resume_session(
&db,
Some(created.session_id.clone()),
&llm,
"default", // 这个值应被忽略
)
.await
.expect("resume should succeed");
assert_eq!(resumed.session_id, created.session_id);
assert_eq!(
resumed.mode, "literature-reader",
"mode should come from DB, not the parameter"
);
}
#[tokio::test]
async fn test_resume_nonexistent_session_errors() {
let db = setup_db_with_mode().await;
let llm = LlmClient::new("key".into(), "http://localhost".into(), "test-model".into());
let result =
create_or_resume_session(&db, Some("nonexistent-id".into()), &llm, "default").await;
assert!(result.is_err(), "resuming nonexistent session should error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("不存在") || err.contains("not found"),
"error should mention nonexistent, got: {}",
err
);
}
}

View File

@ -470,31 +470,417 @@ impl StreamingToolExecutor {
#[cfg(test)]
mod tests {
// 注意StreamingToolExecutor 的集成测试放在 src/agent/runtime/ 的 #[cfg(test)] 模块中,
// 需要完整的 AppState 和 ToolContext。此处的单元测试仅验证核心数据结构。
//
// 以下测试验证 TrackedToolStatus 枚举和状态转换逻辑,不依赖外部设施。
use super::*;
use crate::agent::tools::AgentTool;
use crate::agent::tools::ToolContext;
use async_trait::async_trait;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn test_tracked_tool_status_debug() {
assert_eq!(format!("{:?}", TrackedToolStatus::Queued), "Queued");
assert_eq!(format!("{:?}", TrackedToolStatus::Executing), "Executing");
assert_eq!(format!("{:?}", TrackedToolStatus::Completed), "Completed");
assert_eq!(format!("{:?}", TrackedToolStatus::Yielded), "Yielded");
/// 可配置的 Mock 工具,用于测试 StreamingToolExecutor 状态机。
struct MockAgentTool {
name_str: &'static str,
concurrency_safe: bool,
causes_abort: bool,
/// 执行返回的内容
result_content: &'static str,
/// 执行是否返回错误
result_is_error: bool,
/// 可选:执行后设置此标志(用于验证工具是否被调用)
executed: AtomicBool,
}
#[test]
fn test_abort_reason_display() {
let sibling = AbortReason::SiblingError {
description: "bash(rm -rf /)".into(),
};
let user = AbortReason::UserInterrupted;
assert_eq!(
format!("{:?}", sibling),
"SiblingError { description: \"bash(rm -rf /)\" }"
impl MockAgentTool {
fn new(name: &'static str) -> Self {
MockAgentTool {
name_str: name,
concurrency_safe: false,
causes_abort: false,
result_content: "mock result",
result_is_error: false,
executed: AtomicBool::new(false),
}
}
fn concurrency_safe(mut self, v: bool) -> Self {
self.concurrency_safe = v;
self
}
fn causes_abort(mut self, v: bool) -> Self {
self.causes_abort = v;
self
}
fn result(mut self, content: &'static str, is_error: bool) -> Self {
self.result_content = content;
self.result_is_error = is_error;
self
}
}
#[async_trait]
impl AgentTool for MockAgentTool {
fn name(&self) -> &str {
self.name_str
}
fn description(&self) -> &str {
"mock tool for testing"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn execute(&self, _args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
self.executed.store(true, Ordering::SeqCst);
if self.result_is_error {
ToolOutput::error(self.result_content)
} else {
ToolOutput::success(self.result_content, serde_json::json!({}))
}
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
self.concurrency_safe
}
fn causes_sibling_abort(&self) -> bool {
self.causes_abort
}
}
/// 构建测试用的 ToolContext。
async fn make_test_tool_context() -> ToolContext {
use crate::agent::memory::MemoryManager;
use crate::agent::runtime::file_cache::FileStateCache;
use crate::agent::runtime::permission::PermissionChecker;
use crate::agent::skills::SkillRegistry;
use crate::api::AppState;
use crate::clients::ads::AdsClient;
use crate::clients::arxiv::ArxivClient;
use crate::clients::llm::{EmbeddingClient, LlmClient};
use crate::clients::qiniu::QiniuClient;
use crate::services::batch::asset::AssetBatchStatus;
use crate::services::batch::meta::MetaSyncStatus;
use crate::services::download::Downloader;
use crate::services::translation::Dictionary;
use crate::Config;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, RwLock};
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
let config = Config::from_env();
let llm = LlmClient::new("tk".into(), "http://localhost".into(), "m".into());
let embedding = EmbeddingClient::new("tk".into(), "http://localhost".into(), "e".into());
let ads = AdsClient::new("tk".into());
let arxiv = ArxivClient::new();
let qiniu = QiniuClient::new(
"ak".into(),
"sk".into(),
"b".into(),
"http://localhost".into(),
);
assert_eq!(format!("{:?}", user), "UserInterrupted");
let app_state = Arc::new(AppState {
config,
db: pool,
dict: Dictionary::default(),
qiniu,
ads,
arxiv,
llm: llm.clone(),
medium_llm: llm.clone(),
fast_llm: llm.clone(),
vision_llm: None,
embedding,
downloader: Downloader::new().expect("downloader"),
harvest_status: Arc::new(tokio::sync::Mutex::new(MetaSyncStatus::default())),
batch_status: Arc::new(tokio::sync::Mutex::new(AssetBatchStatus::default())),
active_bibcode: Arc::new(tokio::sync::Mutex::new(None)),
cancelled_runs: Arc::new(Mutex::new(std::collections::HashSet::new())),
skill_registry: Arc::new(RwLock::new(SkillRegistry::new(PathBuf::from("/tmp/sk")))),
pending_questions: Arc::new(Mutex::new(std::collections::HashMap::new())),
pending_permissions: Arc::new(Mutex::new(std::collections::HashMap::new())),
session_permission_checker: Arc::new(RwLock::new(PermissionChecker::new())),
sse_broadcast: None,
memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from(
"/tmp/test_mem",
)))),
sessions: Arc::new(Mutex::new(std::collections::HashMap::new())),
});
ToolContext::new(app_state)
}
/// 创建包含指定 Mock 工具的 StreamingToolExecutor。
async fn make_executor(tools: Vec<MockAgentTool>) -> StreamingToolExecutor {
let skill_registry = Arc::new(std::sync::RwLock::new(
crate::agent::skills::SkillRegistry::new(std::path::PathBuf::from("/tmp/sk")),
));
let mut registry = ToolRegistry::new(skill_registry);
for tool in tools {
registry.add_tool(Box::new(tool));
}
let tool_context = make_test_tool_context().await;
StreamingToolExecutor::new(Arc::new(registry), tool_context, 4, 4000)
}
#[tokio::test]
async fn test_on_tool_use_spawns_concurrency_safe_immediately() {
let mut executor =
make_executor(vec![MockAgentTool::new("safe_tool").concurrency_safe(true)]).await;
let spawned = executor.on_tool_use(
"call_1".into(),
"safe_tool".into(),
serde_json::json!({"key": "val"}),
);
assert!(spawned, "concurrency-safe tool should spawn immediately");
assert_eq!(executor.tracked.len(), 1);
assert_eq!(
executor.tracked[0].status,
TrackedToolStatus::Executing,
"safe tool should be executing"
);
assert!(
!executor.executing_non_concurrent,
"safe tool does not lock executor"
);
assert!(
executor.has_unfinished(),
"should have unfinished tool (executing)"
);
}
#[tokio::test]
async fn test_on_tool_use_queues_non_concurrency_safe() {
let mut executor = make_executor(vec![
MockAgentTool::new("unsafe_tool").concurrency_safe(false)
])
.await;
let spawned =
executor.on_tool_use("call_1".into(), "unsafe_tool".into(), serde_json::json!({}));
assert!(!spawned, "non-concurrency-safe tool should be queued");
assert_eq!(executor.tracked.len(), 1);
assert_eq!(executor.tracked[0].status, TrackedToolStatus::Queued);
assert!(
executor.executing_non_concurrent,
"non-concurrent tool sets the lock flag"
);
assert!(
executor.has_unfinished(),
"queued tool counts as unfinished"
);
assert!(!executor.has_pending_results(), "no results yet");
}
#[tokio::test]
async fn test_on_tool_use_queues_safe_tool_when_non_concurrent_executing() {
// 先加入一个非并发安全工具(设为 executing_non_concurrent=true
// 再尝试加入一个并发安全工具,应排队而非立即执行
let mut executor = make_executor(vec![
MockAgentTool::new("unsafe_tool").concurrency_safe(false),
MockAgentTool::new("safe_tool").concurrency_safe(true),
])
.await;
// 第一个:非并发安全,排队但设置 executing_non_concurrent
executor.on_tool_use("call_1".into(), "unsafe_tool".into(), serde_json::json!({}));
// 第二个:并发安全但 executor 被非并发工具锁定,应排队
let spawned =
executor.on_tool_use("call_2".into(), "safe_tool".into(), serde_json::json!({}));
assert!(
!spawned,
"safe tool should be queued when executor is locked"
);
assert_eq!(executor.tracked.len(), 2);
assert_eq!(executor.tracked[1].status, TrackedToolStatus::Queued);
}
#[tokio::test]
async fn test_get_tool_description_parses_args() {
let mut executor = make_executor(vec![MockAgentTool::new("bash")]).await;
// 手动添加 tracked tool绕过 on_tool_use 的 spawn
executor.tracked.push(TrackedTool {
tool_call_id: "c1".into(),
tool_name: "bash".into(),
args: serde_json::json!({"command": "git push origin main"}),
status: TrackedToolStatus::Completed,
output: None,
handle: None,
});
let desc = executor.get_tool_description(0);
assert!(
desc.contains("bash"),
"description should contain tool name, got: {}",
desc
);
assert!(
desc.contains("git push"),
"description should contain command arg, got: {}",
desc
);
}
#[tokio::test]
async fn test_get_tool_description_falls_back_to_name() {
let mut executor = make_executor(vec![MockAgentTool::new("unknown_tool")]).await;
executor.tracked.push(TrackedTool {
tool_call_id: "c1".into(),
tool_name: "unknown_tool".into(),
args: serde_json::json!({}), // no command/file_path/pattern
status: TrackedToolStatus::Completed,
output: None,
handle: None,
});
let desc = executor.get_tool_description(0);
assert_eq!(desc, "unknown_tool");
}
#[tokio::test]
async fn test_flush_starts_queued_and_awaits_completion() {
// 使用并发安全工具测试 flushon_tool_use 立即 spawnflush 等待完成
let mut executor = make_executor(vec![MockAgentTool::new("tool_a")
.concurrency_safe(true)
.result("done", false)])
.await;
let spawned = executor.on_tool_use("call_1".into(), "tool_a".into(), serde_json::json!({}));
assert!(spawned, "concurrency-safe tool should spawn immediately");
assert_eq!(executor.tracked[0].status, TrackedToolStatus::Executing);
executor.flush().await;
assert_eq!(
executor.tracked[0].status,
TrackedToolStatus::Completed,
"after flush, tool should be completed"
);
assert!(
executor.has_pending_results(),
"completed tool = pending result"
);
assert!(!executor.has_unfinished(), "nothing queued or executing");
let result = executor.next_result();
assert!(
result.is_some(),
"next_result should return the completed tool"
);
let (call_id, output) = result.unwrap();
assert_eq!(call_id, "call_1");
assert!(!output.is_error, "tool should succeed");
assert_eq!(output.content, "done");
}
#[tokio::test]
async fn test_next_result_yields_in_insertion_order() {
let mut executor = make_executor(vec![
MockAgentTool::new("tool_a")
.concurrency_safe(true)
.result("result_a", false),
MockAgentTool::new("tool_b")
.concurrency_safe(true)
.result("result_b", false),
])
.await;
// 两个并发安全工具,都应该立即 spawn
executor.on_tool_use("call_a".into(), "tool_a".into(), serde_json::json!({}));
executor.on_tool_use("call_b".into(), "tool_b".into(), serde_json::json!({}));
// flush 等待它们完成
executor.flush().await;
// 结果应按插入顺序产出
let result_a = executor.next_result();
assert!(result_a.is_some());
assert_eq!(result_a.as_ref().unwrap().0, "call_a");
assert_eq!(result_a.as_ref().unwrap().1.content, "result_a");
let result_b = executor.next_result();
assert!(result_b.is_some());
assert_eq!(result_b.as_ref().unwrap().0, "call_b");
assert_eq!(result_b.as_ref().unwrap().1.content, "result_b");
// 第三次调用返回 None全部已 yield
let result_c = executor.next_result();
assert!(result_c.is_none());
assert!(!executor.has_pending_results());
}
#[tokio::test]
async fn test_sibling_abort_broadcast_on_error() {
// 注册两个工具tool_a 会报错且触发 sibling aborttool_b 并发执行中被取消
let mut executor = make_executor(vec![
MockAgentTool::new("tool_a")
.concurrency_safe(true)
.causes_abort(true)
.result("critical failure", true),
MockAgentTool::new("tool_b")
.concurrency_safe(true)
.result("should be aborted", false),
])
.await;
executor.on_tool_use("call_a".into(), "tool_a".into(), serde_json::json!({}));
executor.on_tool_use("call_b".into(), "tool_b".into(), serde_json::json!({}));
executor.flush().await;
// tool_a 的结果应该是错误
let result_a = executor.next_result();
assert!(result_a.is_some());
assert!(result_a.unwrap().1.is_error, "tool_a should have errored");
// tool_b 可能被取消sibling abort或正常完成取决于竞态
let result_b = executor.next_result();
assert!(result_b.is_some(), "tool_b should also have a result");
// 验证 has_errored 被设置
assert!(
executor.has_errored,
"has_errored should be set after sibling abort"
);
assert!(
!executor.errored_tool_desc.is_empty(),
"errored_tool_desc should be populated"
);
}
#[tokio::test]
async fn test_all_results_mut_drains_all_outputs() {
let mut executor =
make_executor(vec![MockAgentTool::new("tool_a").result("result_a", false)]).await;
executor.on_tool_use("call_a".into(), "tool_a".into(), serde_json::json!({}));
// 不 flush — all_results_mut 是同步方法,只收集已完成的结果
// 工具可能仍在执行,所以不能保证一定有结果
let results = executor.all_results_mut();
// 无论有没有结果,调用后 tracked tool 被标记为 Yielded
for tool in &executor.tracked {
assert_eq!(tool.status, TrackedToolStatus::Yielded);
assert!(tool.output.is_none(), "output should be taken");
}
// 验证返回的 results 和 tracked 一致
let _ = results; // 如果 joinhandle 还没完成results 可能是空的
}
#[tokio::test]
async fn test_abort_sender_clone_works() {
let executor = make_executor(vec![]).await;
let sender = executor.abort_sender();
// 验证 sender 可用
assert_eq!(sender.receiver_count(), 1);
}
#[tokio::test]
async fn test_empty_executor_has_no_pending_or_unfinished() {
let executor = make_executor(vec![]).await;
assert!(!executor.has_pending_results());
assert!(!executor.has_unfinished());
}
}

View File

@ -204,20 +204,36 @@ mod tests {
}
#[test]
fn test_static_sections_not_empty() {
assert!(!SYSTEM_CONTEXT_SECTION.is_empty());
assert!(!TOOL_USAGE_SECTION.is_empty());
assert!(!SAFETY_SECTION.is_empty());
assert!(!PRINCIPLES_SECTION.is_empty());
assert!(!IDENTITY_SECTION.is_empty());
}
fn test_assemble_with_static_and_dynamic_sections() {
let mut sp = SystemPrompt::new();
sp.add_section("identity", IDENTITY_SECTION.to_string());
sp.add_section("principles", PRINCIPLES_SECTION.to_string());
sp.add_section(
"tools",
"Available tools: read_file, search_papers".to_string(),
);
#[test]
fn test_static_sections_have_headers() {
assert!(SYSTEM_CONTEXT_SECTION.starts_with("# 系统上下文"));
assert!(TOOL_USAGE_SECTION.starts_with("# 工具使用指南"));
assert!(SAFETY_SECTION.starts_with("# 操作安全"));
assert!(PRINCIPLES_SECTION.starts_with("# 核心原则"));
let result = sp.assemble();
assert_eq!(sp.section_count(), 3);
// 验证 section 顺序
assert!(
result.starts_with(IDENTITY_SECTION),
"identity should be first"
);
assert!(
result.contains(PRINCIPLES_SECTION),
"principles should be present"
);
assert!(
result.ends_with("Available tools: read_file, search_papers"),
"dynamic section should be last"
);
// 验证双换行分隔符
assert_eq!(
result.matches("\n\n").count(),
2,
"3 sections = 2 double-newline separators"
);
}
// ── SystemPromptCache tests ──

View File

@ -5,15 +5,18 @@
use serde::Serialize;
use sqlx::SqlitePool;
use std::str::FromStr;
use tracing::info;
use crate::agent::enums::TaskStatus;
/// 任务摘要
#[derive(Debug, Clone, Serialize)]
pub struct TaskSummary {
pub session_id: String,
pub task_id: String,
pub content: String,
pub status: String,
pub status: TaskStatus,
pub owner: Option<String>,
pub can_start: bool,
pub blocked_by: Vec<String>,
@ -56,7 +59,7 @@ impl TaskBoard {
.await?
.flatten();
if dep_status.as_deref() != Some("completed") {
if dep_status.as_deref() != Some(TaskStatus::Completed.as_str()) {
return Ok(false);
}
}
@ -114,7 +117,7 @@ impl TaskBoard {
session_id,
task_id,
content,
status,
status: TaskStatus::from_str(&status).unwrap_or(TaskStatus::Pending),
owner,
can_start,
blocked_by: blocked,
@ -134,9 +137,169 @@ impl TaskBoard {
mod tests {
use super::*;
#[test]
fn test_can_start_no_deps() {
// can_start 需要数据库连接,此处仅验证结构
// 实际集成测试应在 tests/ 目录中
#[tokio::test]
async fn test_can_start_no_deps() {
// 使用内存数据库进行完整的 TaskBoard 集成测试
let pool = sqlx::SqlitePool::connect("sqlite::memory:")
.await
.expect("failed to create in-memory db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations failed");
// 先创建会话(满足外键约束)
sqlx::query(
"INSERT INTO agent_sessions (session_id, title, created_at) \
VALUES (?, ?, datetime('now'))",
)
.bind("test_session")
.bind("test session")
.execute(&pool)
.await
.unwrap();
// 插入一个无依赖的任务
sqlx::query(
"INSERT INTO agent_tasks (session_id, task_id, content, status, blocked_by, owner) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("test_session")
.bind("task_1")
.bind("搜索文献")
.bind(TaskStatus::Pending.as_str())
.bind("[]")
.bind("lead")
.execute(&pool)
.await
.unwrap();
let board = TaskBoard::new(pool);
let can = board
.can_start("test_session", "task_1")
.await
.expect("can_start should succeed");
assert!(can, "task with no dependencies should be able to start");
}
#[tokio::test]
async fn test_can_start_with_completed_deps() {
let pool = sqlx::SqlitePool::connect("sqlite::memory:")
.await
.expect("failed to create in-memory db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations failed");
// 先创建会话(满足外键约束)
sqlx::query(
"INSERT INTO agent_sessions (session_id, title, created_at) \
VALUES (?, ?, datetime('now'))",
)
.bind("test_session")
.bind("test session")
.execute(&pool)
.await
.unwrap();
// 依赖任务(已完成)
sqlx::query(
"INSERT INTO agent_tasks (session_id, task_id, content, status, blocked_by, owner) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("test_session")
.bind("dep_1")
.bind("前置任务")
.bind(TaskStatus::Completed.as_str())
.bind("[]")
.bind("lead")
.execute(&pool)
.await
.unwrap();
// 当前任务(依赖 dep_1
sqlx::query(
"INSERT INTO agent_tasks (session_id, task_id, content, status, blocked_by, owner) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("test_session")
.bind("task_2")
.bind("分析数据")
.bind(TaskStatus::Pending.as_str())
.bind(r#"["dep_1"]"#)
.bind("lead")
.execute(&pool)
.await
.unwrap();
let board = TaskBoard::new(pool);
let can = board
.can_start("test_session", "task_2")
.await
.expect("can_start should succeed");
assert!(
can,
"task whose deps are all completed should be able to start"
);
}
#[tokio::test]
async fn test_cannot_start_with_pending_deps() {
let pool = sqlx::SqlitePool::connect("sqlite::memory:")
.await
.expect("failed to create in-memory db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations failed");
// 先创建会话(满足外键约束)
sqlx::query(
"INSERT INTO agent_sessions (session_id, title, created_at) \
VALUES (?, ?, datetime('now'))",
)
.bind("test_session")
.bind("test session")
.execute(&pool)
.await
.unwrap();
// 依赖任务(未完成)
sqlx::query(
"INSERT INTO agent_tasks (session_id, task_id, content, status, blocked_by, owner) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("test_session")
.bind("dep_1")
.bind("阻塞任务")
.bind(TaskStatus::InProgress.as_str())
.bind("[]")
.bind("lead")
.execute(&pool)
.await
.unwrap();
// 当前任务(依赖 dep_1
sqlx::query(
"INSERT INTO agent_tasks (session_id, task_id, content, status, blocked_by, owner) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("test_session")
.bind("task_3")
.bind("被阻塞任务")
.bind(TaskStatus::Pending.as_str())
.bind(r#"["dep_1"]"#)
.bind("lead")
.execute(&pool)
.await
.unwrap();
let board = TaskBoard::new(pool);
let can = board
.can_start("test_session", "task_3")
.await
.expect("can_start should succeed");
assert!(!can, "task with pending deps should NOT be able to start");
}
}

View File

@ -5,7 +5,7 @@
// 仅在 LLM_VISION_MODEL 环境变量配置时注册。主 Agent 可能为纯文本模型,
// 图片分析通过此工具委托给专用 vision 模型,增量结果通过 SSE 实时推送到前端。
use std::path::Path;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde_json::json;
@ -13,6 +13,7 @@ use tokio::sync::mpsc::UnboundedSender;
use tracing::{error, info};
use crate::agent::runtime::AgentStreamEvent;
use crate::agent::tools::filesystem::security::{has_path_traversal, is_path_allowed};
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
use crate::clients::llm::LlmClient;
@ -129,6 +130,10 @@ impl AgentTool for AnalyzeImageTool {
true
}
fn group(&self) -> &str {
"as:system"
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
self.execute_with_progress(args, ctx, None).await
}
@ -156,6 +161,25 @@ impl AgentTool for AnalyzeImageTool {
}
};
// ── 安全校验(与 read_file 工具一致的沙箱范式)──
// 远程 URL 由 load_image 内部做 SSRF 防护;本地路径在此做沙箱校验。
let is_remote = image_path.starts_with("http://") || image_path.starts_with("https://");
if !is_remote {
if has_path_traversal(&image_path) {
return ToolOutput::error("路径包含不安全字符 (.. 或 ~)");
}
// 相对路径相对于 library_dir 解析(保留原语义),再走沙箱校验。
// is_path_allowed 内部会做 canonicalize故传入拼接后的绝对路径即可。
let resolved: PathBuf = if Path::new(&image_path).is_absolute() {
PathBuf::from(&image_path)
} else {
ctx.app_state.config.library_dir.join(&image_path)
};
if !is_path_allowed(&resolved, ctx) {
return ToolOutput::error(format!("路径不在允许的沙箱范围内: {}", image_path));
}
}
Self::analyze(
&image_path,
&question,
@ -171,7 +195,27 @@ impl AgentTool for AnalyzeImageTool {
/// 加载图片:本地路径或 HTTP(S) URL → base64 data + mime type
async fn load_image(library_dir: &Path, path: &str) -> anyhow::Result<(String, String)> {
if path.starts_with("http://") || path.starts_with("https://") {
let client = reqwest::Client::new();
// ── SSRF 防护 ──
let parsed =
reqwest::Url::parse(path).map_err(|e| anyhow::anyhow!("无效的图片 URL: {}", e))?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("URL 缺少 host"))?;
// 字面量快检 + 异步 DNS 深度校验(不阻塞 tokio worker
if crate::utils::ssrf::is_host_literal_blocked(host) {
return Err(anyhow::anyhow!(
"出于安全考虑,禁止请求内网/保留地址: {}",
host
));
}
if crate::utils::ssrf::is_host_blocked_async(host).await {
return Err(anyhow::anyhow!("DNS 解析后指向内网/保留地址: {}", host));
}
// 使用安全的重定向策略:允许跟随合法 CDN 重定向S3/Imgur 等),
// 但每次跳转都对目标 URL 做 scheme + host 字面量校验,拦截内网目标。
let client = reqwest::Client::builder()
.redirect(crate::utils::ssrf::safe_redirect_policy())
.build()?;
let resp = client.get(path).send().await?;
let mime = resp
.headers()

View File

@ -1,17 +1,31 @@
// src/agent/tools/astro/mod.rs
//
// 天文科研工具集 — 文献搜索/下载/解析、RAG 检索、天体目标查询、研究笔记。
// 天文科研工具集 — 两级架构:
// system/ — 系统级工具AI 代替人工操作基础设施(外部 API、下载、解析、向量化
// research/ — 研究级工具:科研人员消费本地数据进行分析
//
// 向后兼容:所有工具通过 pub use 从子模块重导出,外部 import 路径不变。
pub mod analyze_image;
pub mod note;
pub mod paper;
pub mod rag;
pub mod search;
pub mod target;
pub mod research;
pub mod system;
// 系统级工具
pub use analyze_image::AnalyzeImageTool;
pub use note::SaveNoteTool;
pub use paper::{DownloadPaperTool, GetPaperContentTool, ParsePaperTool};
pub use rag::RagSearchTool;
pub use search::{GetPaperMetadataTool, SearchPapersTool};
pub use target::QueryTargetTool;
pub use system::process::ProcessPaperTool;
// SearchPapersTool 重新导出(来自 system 模块,名称不变)
pub use system::search::SearchPapersTool;
// 研究级工具
pub use research::citation_network::GetCitationNetworkTool;
pub use research::library_search::SearchLocalLibraryTool;
pub use research::metadata::GetPaperMetadataTool;
pub use research::note::SaveNoteTool;
pub use research::paper_content::GetPaperContentTool;
pub use research::paper_outline::GetPaperOutlineTool;
pub use research::rag::RagSearchTool;
pub use research::target::QueryTargetTool;
// 以下工具已合并入 ProcessPaperTool不再独立导出
// - DownloadPaperTool (合并入 process_paper)
// - ParsePaperTool (合并入 process_paper)

View File

@ -1,259 +0,0 @@
// src/agent/tools/paper.rs — 文献下载、解析、内容读取工具
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
// ── GetPaperContentTool ──
/// 获取文献内容工具:仅从本地读取已解析的 Markdown 全文
pub struct GetPaperContentTool;
#[async_trait]
impl AgentTool for GetPaperContentTool {
fn name(&self) -> &str {
"get_paper_content"
}
fn description(&self) -> &str {
"读取并在本地库中获取已解析的文献 Markdown 完整文本内容。\
\
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
}
},
"required": ["bibcode"]
})
}
/// 纯读取操作,并发安全
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
info!("[GetPaperContent] 获取文献内容: {}", bibcode);
let state = &ctx.app_state;
let paths = crate::api::helpers::check_paper_paths_in_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await;
let md_opt = match paths {
Ok(Some((_, _, md_opt, _))) => md_opt,
Ok(None) => return ToolOutput::error(
"获取文献内容失败:该文献未在本地数据库中注册,请先使用 search_papers 搜索该文献。",
),
Err(e) => return ToolOutput::error(format!("获取文献内容失败: {}", e)),
};
let md_rel = match md_opt {
Some(rel) => rel,
None => {
return ToolOutput::error(
"获取文献内容失败:该文献尚未完成结构化解析。如果未下载,请先调用 download_paper如果已下载请先调用 parse_paper 进行解析。",
)
}
};
let md_abs = state.config.library_dir.join(&md_rel);
if !md_abs.exists() {
return ToolOutput::error(
"获取文献内容失败:文献本地 Markdown 文件已丢失,请重新调用 parse_paper 进行解析。",
);
}
match std::fs::read_to_string(&md_abs) {
Ok(content) => ToolOutput::success(
content.clone(),
json!({ "bibcode": bibcode, "chars": content.len() }),
),
Err(e) => ToolOutput::error(format!("获取文献内容失败,读取本地文件错误: {}", e)),
}
}
fn is_readonly(&self) -> bool {
true
}
}
// ── DownloadPaperTool ──
/// 下载文献全文资源工具
pub struct DownloadPaperTool;
#[async_trait]
impl AgentTool for DownloadPaperTool {
fn name(&self) -> &str {
"download_paper"
}
fn description(&self) -> &str {
"下载指定文献的全文资源PDF 或 HTML至本地图书馆为后续的结构化解析做好准备。\
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"force": {
"type": "boolean",
"description": "是否强制重新下载(即使本地已下载该文献)"
}
},
"required": ["bibcode"]
})
}
/// 下载有副作用,中断时应阻塞以完成
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
/// 下载失败时应中止兄弟并行执行(避免继续处理同一文献)
fn causes_sibling_abort(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let force = args.get("force").and_then(|f| f.as_bool()).unwrap_or(false);
info!("[DownloadPaper] 下载文献: {}, 强制重下: {}", bibcode, force);
let state = &ctx.app_state;
match state
.downloader
.download_paper_service(&state.db, &state.config.library_dir, &bibcode, force)
.await
{
Ok(paper) => ToolOutput::success(
format!(
"文献 {} 全文资源下载成功。格式 - PDF: {}, HTML: {}",
bibcode, paper.has_pdf, paper.has_html
),
json!({ "bibcode": bibcode, "has_pdf": paper.has_pdf, "has_html": paper.has_html }),
),
Err(e) => ToolOutput::error(format!("文献 {} 下载失败: {}", bibcode, e)),
}
}
fn defer_loading(&self) -> bool {
true
}
}
// ── ParsePaperTool ──
/// 结构化解析文献内容工具
pub struct ParsePaperTool;
#[async_trait]
impl AgentTool for ParsePaperTool {
fn name(&self) -> &str {
"parse_paper"
}
fn description(&self) -> &str {
"将指定文献本地已下载的 HTML 或 PDF 资源解析为结构化的 Markdown 文本,并保存至本地 Markdown 文件夹。\
download_paper"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"force": {
"type": "boolean",
"description": "是否强制重新解析(即使本地已解析过该文献)"
}
},
"required": ["bibcode"]
})
}
/// 解析有副作用(写文件),中断时应阻塞以完成
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
/// 解析失败时应中止兄弟并行执行
fn causes_sibling_abort(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let force = args.get("force").and_then(|f| f.as_bool()).unwrap_or(false);
info!("[ParsePaper] 解析文献: {}, 强制重析: {}", bibcode, force);
let state = &ctx.app_state;
match crate::services::parser::parse_paper_service(
&state.db,
&state.config.library_dir,
&state.qiniu,
&state.config,
&bibcode,
force,
)
.await
{
Ok(markdown) => ToolOutput::success(
format!(
"文献 {} 结构化解析成功。解析后 Markdown 字符总数: {}",
bibcode,
markdown.len()
),
json!({ "bibcode": bibcode, "chars": markdown.len() }),
),
Err(e) => {
let msg = e.to_string();
if msg.contains("请先下载") {
ToolOutput::error(format!(
"文献 {} 解析失败:未检测到已下载的本地资源文件,请先调用 download_paper 工具进行下载。",
bibcode
))
} else {
ToolOutput::error(format!("文献 {} 解析失败: {}", bibcode, msg))
}
}
}
}
fn defer_loading(&self) -> bool {
true
}
}

View File

@ -1,108 +0,0 @@
// src/agent/tools/rag.rs — RAG 语义检索工具
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{truncate_content, AgentTool, ToolContext, ToolOutput};
/// RAG 向量检索工具:基于语义相似度检索文献切片
pub struct RagSearchTool;
#[async_trait]
impl AgentTool for RagSearchTool {
fn name(&self) -> &str {
"rag_search"
}
fn description(&self) -> &str {
"在已向量化的文献库中进行语义检索。输入自然语言问题,返回最相关的文献片段。\
embed"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "用于语义检索的自然语言问题"
},
"top_k": {
"type": "integer",
"description": "返回最相关的片段数量默认5",
"default": 5
}
},
"required": ["question"]
})
}
/// 纯读取向量数据库,并发安全
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let question = match args.get("question").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'question'"),
};
let top_k = args.get("top_k").and_then(|k| k.as_u64()).unwrap_or(5) as usize;
info!(
"[RagSearch] 语义检索: question='{}', top_k={}",
question, top_k
);
let state = &ctx.app_state;
match crate::services::rag::retrieve(&state.db, &state.embedding, &question, top_k).await {
Ok(results) => {
if results.is_empty() {
return ToolOutput::success(
"未找到相关的文献片段。文献库中可能尚无向量化数据,请先对目标文献执行向量化操作。",
json!({ "count": 0 }),
);
}
let content = results
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"[片段 {} | 来源: {} | 段落: {} | 相似度距离: {:.4}]\n{}",
i + 1,
r.bibcode,
r.paragraph_index,
r.distance,
r.content
)
})
.collect::<Vec<_>>()
.join("\n\n---\n\n");
let sources: Vec<serde_json::Value> = results
.iter()
.map(|r| {
json!({
"bibcode": r.bibcode,
"paragraph_index": r.paragraph_index,
"distance": r.distance,
"preview": truncate_content(&r.content, 100)
})
})
.collect();
ToolOutput::success(
truncate_content(&content, 4000),
json!({ "count": results.len(), "sources": sources }),
)
}
Err(e) => ToolOutput::error(format!("RAG 语义检索失败: {}", e)),
}
}
fn is_readonly(&self) -> bool {
true
}
}

View File

@ -0,0 +1,445 @@
// src/agent/tools/astro/research/citation_network.rs
// GetCitationNetworkTool — 引用查找与引用网络浏览
//
// 两个核心场景:
// 1. 引用查找:阅读中遇到 "Lei et al. 2023",通过作者+年份匹配定位文献
// 2. 引用网络:分页浏览参考文献/被引列表,支持排序
use async_trait::async_trait;
use serde_json::json;
use sqlx::FromRow;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
/// 引用条目(关联查询结果)
#[derive(Debug, FromRow)]
struct CitationEntry {
bibcode: String,
title: String,
authors: Option<String>,
year: Option<String>,
citation_count: Option<i32>,
}
pub struct GetCitationNetworkTool;
#[async_trait]
impl AgentTool for GetCitationNetworkTool {
fn name(&self) -> &str {
"get_citation_network"
}
fn description(&self) -> &str {
"浏览和查找文献的引用关系。两种用法:\
1. query 'Lei 2023'/+\
2. query\
direction='citations' "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "源文献标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"query": {
"type": "string",
"description": "可选的引用查找字符串,如 'Lei 2023'、'Zhang et al. 2020'。通过作者名+年份在引用列表中模糊匹配。不提供时返回完整列表"
},
"direction": {
"type": "string",
"enum": ["references", "citations"],
"description": "查询方向。references: 该文献引用了哪些文献默认citations: 哪些文献引用了该文献",
"default": "references"
},
"sort": {
"type": "string",
"enum": ["citation_count", "year", "default"],
"description": "排序方式。citation_count: 按被引次数降序year: 按年份降序default: 数据库默认顺序",
"default": "citation_count"
},
"offset": {
"type": "integer",
"description": "分页偏移量0-based默认 0",
"default": 0
},
"limit": {
"type": "integer",
"description": "每页返回数量,默认 20最大 50",
"default": 20
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let query = args.get("query").and_then(|q| q.as_str()).map(String::from);
let direction = args
.get("direction")
.and_then(|d| d.as_str())
.unwrap_or("references");
let sort = args
.get("sort")
.and_then(|s| s.as_str())
.unwrap_or("citation_count");
let offset = args.get("offset").and_then(|o| o.as_u64()).unwrap_or(0) as i64;
let limit = args
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(20)
.min(50) as i64;
info!(
"[GetCitationNetwork] bibcode={}, query={:?}, direction={}, sort={}, offset={}, limit={}",
bibcode, query, direction, sort, offset, limit
);
let state = &ctx.app_state;
// 验证源文献存在
let paper = match crate::api::helpers::get_paper_from_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await
{
Ok(p) => p,
Err(_) => {
return ToolOutput::error(format!(
"文献 {} 未在本地数据库中。请先使用 search_papers 检索该文献。",
bibcode
));
}
};
// 构建基础查询
let (join_col, filter_col) = match direction {
"citations" => ("source_bibcode", "target_bibcode"),
_ => ("target_bibcode", "source_bibcode"),
};
let order_clause = match sort {
"year" => "p.year DESC",
"citation_count" => "p.citation_count DESC",
_ => "p.bibcode ASC",
};
// 有 query → 引用查找模式:在关联文献中按作者+年份搜索
if let Some(ref q) = query {
let (author_part, year_part) = parse_query(q);
let results = search_citations(
&state.db,
&paper.bibcode,
join_col,
filter_col,
&author_part,
&year_part,
)
.await;
if results.is_empty() {
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
return ToolOutput::success(
format!(
"在 {} 的{}列表中未找到匹配 '{}' 的文献。请尝试调整搜索词(如仅用作者姓氏)。",
paper.bibcode, dir_label, q
),
json!({ "bibcode": paper.bibcode, "query": q, "direction": direction, "count": 0, "results": [] }),
);
}
let display: Vec<serde_json::Value> = results.iter().map(citation_to_json).collect();
let content = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 作者: {} | 被引: {} 次",
i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["authors"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
)
})
.collect::<Vec<_>>()
.join("\n\n");
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
ToolOutput::success(
format!(
"在 {} 的{}列表中找到 {} 篇匹配 '{}' 的文献:\n\n{}",
paper.bibcode,
dir_label,
results.len(),
q,
content
),
json!({
"bibcode": paper.bibcode,
"query": q,
"direction": direction,
"count": results.len(),
"results": display,
"mode": "search"
}),
)
} else {
// 无 query → 分页浏览模式
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM citations_references WHERE {} = ?",
filter_col
))
.bind(&paper.bibcode)
.fetch_one(&state.db)
.await
.unwrap_or(0);
let query_sql = format!(
"SELECT p.bibcode, p.title, p.authors, p.year, p.citation_count \
FROM papers p \
JOIN citations_references cr ON p.bibcode = cr.{} \
WHERE cr.{} = ? \
ORDER BY {} \
LIMIT ? OFFSET ?",
join_col, filter_col, order_clause
);
let rows: Vec<CitationEntry> = sqlx::query_as(&query_sql)
.bind(&paper.bibcode)
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let page_count = rows.len();
let display: Vec<serde_json::Value> = rows.iter().map(citation_to_json).collect();
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
let sort_label = match sort {
"citation_count" => "按被引次数降序",
"year" => "按年份降序",
_ => "默认顺序",
};
let content = if rows.is_empty() {
format!("文献 {} 暂无{}记录。", paper.bibcode, dir_label)
} else {
let items = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 作者: {} | 被引: {} 次",
offset as usize + i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["authors"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
)
})
.collect::<Vec<_>>()
.join("\n\n");
format!(
"文献 {} 的{}列表({},共 {} 篇,第 {}-{} 条):\n\n{}",
paper.bibcode,
dir_label,
sort_label,
total,
offset + 1,
offset + page_count as i64,
items,
)
};
ToolOutput::success(
content,
json!({
"bibcode": paper.bibcode,
"direction": direction,
"sort": sort,
"total": total,
"offset": offset,
"limit": limit,
"page_count": page_count,
"results": display,
"mode": "browse"
}),
)
}
}
}
/// 解析查询字符串,提取作者部分和年份部分。
/// "Lei 2023" → ("Lei", Some("2023"))
/// "Zhang et al. 2020" → ("Zhang et al.", Some("2020"))
/// "Smith" → ("Smith", None)
fn parse_query(q: &str) -> (String, Option<String>) {
// 去掉 "et al." 变体中的句号,保留为空格分隔
let cleaned = q.replace("et al.", "et al");
let parts: Vec<&str> = cleaned.split_whitespace().collect();
// 找最后一个看起来像年份的 token4位数字19xx 或 20xx
let mut year: Option<String> = None;
let mut author_tokens: Vec<&str> = Vec::new();
for (i, &p) in parts.iter().enumerate().rev() {
if year.is_none()
&& p.len() == 4
&& p.chars().all(|c| c.is_ascii_digit())
&& (p.starts_with("19") || p.starts_with("20"))
{
year = Some(p.to_string());
// 年份之前的是作者,之后的不属于查询
author_tokens = parts[..i].to_vec();
break;
}
}
if year.is_none() {
// 没有找到年份,整个字符串作为作者搜索
author_tokens = parts;
}
let author_part = if author_tokens.is_empty() {
q.to_string()
} else {
author_tokens.join(" ")
};
(author_part, year)
}
/// 在引用关系中搜索匹配的文献
async fn search_citations(
db: &sqlx::SqlitePool,
bibcode: &str,
join_col: &str,
filter_col: &str,
author: &str,
year: &Option<String>,
) -> Vec<CitationEntry> {
let author_pattern = format!("%{}%", author);
if let Some(ref y) = year {
let sql = format!(
"SELECT p.bibcode, p.title, p.authors, p.year, p.citation_count \
FROM papers p \
JOIN citations_references cr ON p.bibcode = cr.{} \
WHERE cr.{} = ? AND p.authors LIKE ? AND p.year = ?",
join_col, filter_col
);
sqlx::query_as::<_, CitationEntry>(&sql)
.bind(bibcode)
.bind(&author_pattern)
.bind(y)
.fetch_all(db)
.await
.unwrap_or_default()
} else {
let sql = format!(
"SELECT p.bibcode, p.title, p.authors, p.year, p.citation_count \
FROM papers p \
JOIN citations_references cr ON p.bibcode = cr.{} \
WHERE cr.{} = ? AND p.authors LIKE ?",
join_col, filter_col
);
sqlx::query_as::<_, CitationEntry>(&sql)
.bind(bibcode)
.bind(&author_pattern)
.fetch_all(db)
.await
.unwrap_or_default()
}
}
fn citation_to_json(e: &CitationEntry) -> serde_json::Value {
let authors: Vec<String> = e
.authors
.as_deref()
.and_then(|a| serde_json::from_str(a).ok())
.unwrap_or_default();
let first_author = authors
.first()
.cloned()
.unwrap_or_else(|| "未知".to_string());
json!({
"bibcode": e.bibcode,
"title": e.title,
"authors": authors,
"first_author": first_author,
"year": e.year,
"citation_count": e.citation_count.unwrap_or(0),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_query_with_year() {
let (author, year) = parse_query("Lei 2023");
assert_eq!(author, "Lei");
assert_eq!(year, Some("2023".into()));
}
#[test]
fn test_parse_query_et_al() {
let (author, year) = parse_query("Zhang et al. 2020");
assert_eq!(author, "Zhang et al");
assert_eq!(year, Some("2020".into()));
}
#[test]
fn test_parse_query_author_only() {
let (author, year) = parse_query("Smith");
assert_eq!(author, "Smith");
assert_eq!(year, None);
}
#[test]
fn test_parse_query_full_name() {
let (author, year) = parse_query("Gaia Collaboration 2018");
assert_eq!(author, "Gaia Collaboration");
assert_eq!(year, Some("2018".into()));
}
}

View File

@ -0,0 +1,131 @@
// src/agent/tools/astro/research/library_search.rs
// SearchLocalLibraryTool — 本地文献库 FTS5 全文检索
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct SearchLocalLibraryTool;
#[async_trait]
impl AgentTool for SearchLocalLibraryTool {
fn name(&self) -> &str {
"search_local_library"
}
fn description(&self) -> &str {
"在本地文献库中进行全文检索。搜索范围包括标题、作者、关键词、摘要和期刊名。\
使 BM25 "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词。支持多词联合搜索,如 'white dwarf atmosphere'"
},
"limit": {
"type": "integer",
"description": "返回结果数量,默认 10最大 50",
"default": 10
}
},
"required": ["query"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let query = match args.get("query").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'query'"),
};
let limit = args
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(10)
.min(50) as usize;
info!(
"[SearchLocalLibrary] 本地检索: query='{}', limit={}",
query, limit
);
let state = &ctx.app_state;
match crate::services::search::search_local_library(&state.db, &query, limit).await {
Ok(results) => {
if results.is_empty() {
return ToolOutput::success(
"本地文献库中未找到匹配的文献。请尝试调整搜索关键词,或使用 search_papers 从外部检索新文献。",
json!({ "count": 0 }),
);
}
let display: Vec<serde_json::Value> = results
.iter()
.map(|p| {
let first_author = p
.authors
.first()
.cloned()
.unwrap_or_else(|| "未知".to_string());
json!({
"bibcode": p.bibcode,
"title": p.title,
"first_author": first_author,
"year": p.year,
"pub_journal": p.pub_journal,
"citation_count": p.citation_count,
"has_markdown": p.has_markdown,
})
})
.collect();
let content = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 第一作者: {} | 期刊: {}\n 被引: {} 次 | 已解析: {}",
i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["first_author"].as_str().unwrap_or(""),
r["pub_journal"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
if r["has_markdown"].as_bool().unwrap_or(false) {
""
} else {
""
},
)
})
.collect::<Vec<_>>()
.join("\n\n");
ToolOutput::success(
content,
json!({ "count": results.len(), "papers": display }),
)
}
Err(e) => ToolOutput::error(format!("本地文献检索失败: {}", e)),
}
}
}

View File

@ -0,0 +1,73 @@
// src/agent/tools/astro/research/metadata.rs
// GetPaperMetadataTool — 查询文献完整元数据
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetPaperMetadataTool;
#[async_trait]
impl AgentTool for GetPaperMetadataTool {
fn name(&self) -> &str {
"get_paper_metadata"
}
fn description(&self) -> &str {
"获取指定文献的完整元数据信息(包括完整标题、所有作者、出版期刊、关键字、引用数、完整摘要等)。\
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode如 '2024ApJ...960..123A'、DOI 或 arXiv ID"
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
info!("[GetPaperMetadata] 获取文献元数据: {}", bibcode);
let state = &ctx.app_state;
match crate::api::helpers::get_paper_from_db(&state.db, &state.config.library_dir, &bibcode)
.await
{
Ok(paper) => {
let content = format!(
"文献元数据 [{}]\n 标题: {}\n 作者: {}\n 年份: {}\n 期刊: {}\n 关键字: {}\n DOI: {}\n arXiv ID: {}\n 引用数: {} 次\n 参考文献数: {} 次\n 文献类型: {}\n 已下载: {}\n 已解析为 Markdown: {}\n 摘要:\n{}",
paper.bibcode, paper.title, paper.authors.join(", "), paper.year,
paper.pub_journal, paper.keywords.join(", "), paper.doi, paper.arxiv_id,
paper.citation_count, paper.reference_count, paper.doctype,
paper.is_downloaded, paper.has_markdown, paper.abstract_text
);
ToolOutput::success(content, json!(paper))
}
Err(e) => ToolOutput::error(format!("获取文献 {} 元数据失败: {}", bibcode, e)),
}
}
}

View File

@ -0,0 +1,20 @@
// src/agent/tools/astro/research/mod.rs
// 研究级工具:科研人员消费本地数据进行分析
pub mod citation_network;
pub mod library_search;
pub mod metadata;
pub mod note;
pub mod paper_content;
pub mod paper_outline;
pub mod rag;
pub mod target;
pub use citation_network::GetCitationNetworkTool;
pub use library_search::SearchLocalLibraryTool;
pub use metadata::GetPaperMetadataTool;
pub use note::SaveNoteTool;
pub use paper_content::GetPaperContentTool;
pub use paper_outline::GetPaperOutlineTool;
pub use rag::RagSearchTool;
pub use target::QueryTargetTool;

View File

@ -1,4 +1,5 @@
// src/agent/tools/note.rs — 研究笔记保存工具
// src/agent/tools/astro/research/note.rs
// SaveNoteTool — 保存研究笔记
use async_trait::async_trait;
use serde_json::json;
@ -6,7 +7,6 @@ use tracing::info;
use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
/// 研究笔记保存工具:将 Agent 的研究发现保存为 Markdown 笔记
pub struct SaveNoteTool;
#[async_trait]
@ -16,7 +16,7 @@ impl AgentTool for SaveNoteTool {
}
fn description(&self) -> &str {
"将研究中间结果或最终结论保存为 Markdown 格式的笔记文件。适用于:保存文献综述、记录研究发现、导出分析结果"
"将研究中间结果或最终结论保存为 Markdown 笔记文件。适用于记录阅读摘要、研究思路、文献分析等"
}
fn parameters(&self) -> serde_json::Value {
@ -25,18 +25,21 @@ impl AgentTool for SaveNoteTool {
"properties": {
"title": {
"type": "string",
"description": "笔记标题(将作为文件名的一部分"
"description": "笔记标题,将被用于文件名(特殊字符会被过滤"
},
"content": {
"type": "string",
"description": "笔记正文内容,支持 Markdown 格式(包括 LaTeX 数学公式)"
"description": "笔记正文内容,支持 Markdown 格式( LaTeX 数学公式)"
}
},
"required": ["title", "content"]
})
}
/// 写文件有副作用,中断时应阻塞以完成
fn group(&self) -> &str {
"as:research"
}
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
@ -51,9 +54,11 @@ impl AgentTool for SaveNoteTool {
None => return ToolOutput::error("缺少必需参数 'content'"),
};
info!("[SaveNote] 保存笔记: title='{}'", title);
info!("[SaveNote] 保存笔记: {}", title);
let state = &ctx.app_state;
let safe_filename: String = title
// 文件名安全化
let safe_title: String = title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' {
@ -63,36 +68,32 @@ impl AgentTool for SaveNoteTool {
}
})
.collect::<String>()
.trim()
.replace(' ', "_");
let filename = format!("{}.md", safe_filename);
let notes_dir = ctx.app_state.config.library_dir.join("notes");
let notes_dir = state.config.library_dir.join("notes");
if let Err(e) = std::fs::create_dir_all(&notes_dir) {
return ToolOutput::error(format!("无法创建笔记目录: {}", e));
return ToolOutput::error(format!("创建笔记目录失败: {}", e));
}
let filename = format!("{}.md", safe_title);
let filepath = notes_dir.join(&filename);
let now = chrono::Local::now();
let full_content = format!(
"---\ntitle: {}\ndate: {}\ngenerated_by: AstroResearch Agent\n---\n\n{}",
title,
now.format("%Y-%m-%d %H:%M:%S"),
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
content
);
match std::fs::write(&filepath, &full_content) {
Ok(_) => {
info!("[SaveNote] 笔记已保存: {}", filepath.display());
ToolOutput::success(
format!(
"笔记已保存到 {} {} 字符)",
filepath.display(),
content.len()
),
json!({ "filename": filename, "path": filepath.to_string_lossy().to_string(), "size": content.len() }),
)
}
Ok(_) => ToolOutput::success(
format!("笔记已保存: {}", filename),
json!({
"filename": filename,
"path": filepath.to_string_lossy(),
"size": full_content.len()
}),
),
Err(e) => ToolOutput::error(format!("保存笔记失败: {}", e)),
}
}

View File

@ -0,0 +1,166 @@
// src/agent/tools/astro/research/paper_content.rs
// GetPaperContentTool — 文献内容读取(全文 + 按章节)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetPaperContentTool;
#[async_trait]
impl AgentTool for GetPaperContentTool {
fn name(&self) -> &str {
"get_paper_content"
}
fn description(&self) -> &str {
"读取已解析文献的 Markdown 内容。默认返回全文;可通过 section_index序号或 section_name标题名\
使 get_paper_outline token \
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"section_index": {
"type": "integer",
"description": "章节序号0-based。从 get_paper_outline 返回的 sections[index] 获取。与 section_name 二选一"
},
"section_name": {
"type": "string",
"description": "章节标题名称,大小写不敏感,支持模糊匹配。如 'Introduction'、'Results'、'Discussion'。与 section_index 二选一"
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let section_index = args
.get("section_index")
.and_then(|s| s.as_u64())
.map(|n| n as usize);
let section_name = args
.get("section_name")
.and_then(|s| s.as_str())
.map(String::from);
let want_section = section_index.is_some() || section_name.is_some();
info!(
"[GetPaperContent] bibcode={}, section_index={:?}, section_name={:?}",
bibcode, section_index, section_name
);
let state = &ctx.app_state;
let paths = crate::api::helpers::check_paper_paths_in_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await;
let md_opt = match paths {
Ok(Some((_, _, md_opt, _))) => md_opt,
Ok(None) => return ToolOutput::error(
"获取文献内容失败:该文献未在本地数据库中注册,请先使用 search_papers 搜索该文献。",
),
Err(e) => return ToolOutput::error(format!("获取文献内容失败: {}", e)),
};
let md_rel = match md_opt {
Some(rel) => rel,
None => {
return ToolOutput::error(
"获取文献内容失败:该文献尚未完成结构化解析。请使用 process_paper 执行 download 和 parse 任务。",
)
}
};
let md_abs = state.config.library_dir.join(&md_rel);
if !md_abs.exists() {
return ToolOutput::error(
"获取文献内容失败:文献本地 Markdown 文件已丢失,请重新使用 process_paper 执行 parse 任务。",
);
}
let content = match std::fs::read_to_string(&md_abs) {
Ok(c) => c,
Err(e) => {
return ToolOutput::error(format!("获取文献内容失败,读取本地文件错误: {}", e))
}
};
// 按章节读取
if want_section {
let section_content = if let Some(idx) = section_index {
crate::services::section_parser::extract_section_by_index(&content, idx)
} else if let Some(ref name) = section_name {
crate::services::section_parser::extract_section_by_name(&content, name)
} else {
None
};
match section_content {
Some(sec) => {
let label = section_name
.or_else(|| section_index.map(|i| format!("#{}", i)))
.unwrap_or_default();
ToolOutput::success(
sec.clone(),
json!({
"bibcode": bibcode,
"chars": sec.len(),
"section": label,
"full_text": false
}),
)
}
None => {
let hint = if section_name.is_some() {
"请使用 get_paper_outline 查看可用的章节标题,确认章节名称拼写正确。"
} else {
"请使用 get_paper_outline 查看该文献的章节数量。"
};
ToolOutput::error(format!("获取章节内容失败:未找到匹配的章节。{}", hint))
}
}
} else {
// 返回全文(原行为)
ToolOutput::success(
content.clone(),
json!({
"bibcode": bibcode,
"chars": content.len(),
"section": null,
"full_text": true
}),
)
}
}
}

View File

@ -0,0 +1,135 @@
// src/agent/tools/astro/research/paper_outline.rs
// GetPaperOutlineTool — 提取文献章节大纲(目录)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetPaperOutlineTool;
#[async_trait]
impl AgentTool for GetPaperOutlineTool {
fn name(&self) -> &str {
"get_paper_outline"
}
fn description(&self) -> &str {
"提取已解析文献的章节大纲(目录)。返回所有章节标题、层级和序号,供后续按章节读取使用。\
get_paper_content section_index/section_name 使"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
info!("[GetPaperOutline] 提取大纲: {}", bibcode);
let state = &ctx.app_state;
let paths =
match crate::api::helpers::check_paper_paths_in_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await
{
Ok(Some((_, _, md_opt, _))) => md_opt,
Ok(None) => return ToolOutput::error(
"获取大纲失败:该文献未在本地数据库中注册。请先使用 search_papers 搜索该文献。",
),
Err(e) => return ToolOutput::error(format!("获取大纲失败: {}", e)),
};
let md_rel = match paths {
Some(rel) => rel,
None => {
return ToolOutput::error(
"获取大纲失败:该文献尚未完成结构化解析。请先使用 process_paper 执行 download 和 parse 任务。",
)
}
};
let md_abs = state.config.library_dir.join(&md_rel);
let content = match std::fs::read_to_string(&md_abs) {
Ok(c) => c,
Err(e) => return ToolOutput::error(format!("获取大纲失败,读取文件错误: {}", e)),
};
let sections = crate::services::section_parser::extract_outline(&content, 3);
if sections.is_empty() {
return ToolOutput::success(
"该文献未检测到章节标题(## 或 ### 格式)。请使用 get_paper_content 直接读取全文。",
json!({ "bibcode": bibcode, "sections": [] }),
);
}
let display_sections: Vec<serde_json::Value> = sections
.iter()
.map(|s| {
json!({
"index": s.index,
"level": s.level,
"heading": s.heading,
})
})
.collect();
let content_display = sections
.iter()
.map(|s| {
let prefix = "#".repeat(s.level);
format!(
"[{}] {} {} ({} 字符)",
s.index,
prefix,
s.heading,
s.char_end - s.char_start
)
})
.collect::<Vec<_>>()
.join("\n");
let header = format!("文献 {} 章节大纲(共 {} 节):\n", bibcode, sections.len());
let footer =
"\n\n使用 get_paper_content 并传入 section_index 或 section_name 读取指定章节内容。";
ToolOutput::success(
header + &content_display + footer,
json!({
"bibcode": bibcode,
"section_count": sections.len(),
"sections": display_sections
}),
)
}
}

View File

@ -0,0 +1,121 @@
// src/agent/tools/astro/research/rag.rs
// RagSearchTool — 混合检索(稠密向量 + 稀疏 BM25RRF 融合)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{truncate_content, AgentTool, ToolContext, ToolOutput};
pub struct RagSearchTool;
#[async_trait]
impl AgentTool for RagSearchTool {
fn name(&self) -> &str {
"rag_search"
}
fn description(&self) -> &str {
"在向量化文献库中执行混合语义检索。同时利用稠密向量(语义相似)和稀疏 BM25关键词精确匹配\
RRF\
process_paper embed "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "自然语言问题或搜索词"
},
"top_k": {
"type": "integer",
"description": "返回最相关的段落数量默认5",
"default": 5
}
},
"required": ["question"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let question = match args.get("question").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'question'"),
};
let top_k = args.get("top_k").and_then(|k| k.as_u64()).unwrap_or(5) as usize;
info!(
"[RagSearch] 混合检索: question='{}', top_k={}",
question, top_k
);
let state = &ctx.app_state;
match crate::services::rag::retrieve_hybrid(&state.db, &state.embedding, &question, top_k)
.await
{
Ok(chunks) => {
if chunks.is_empty() {
return ToolOutput::success(
"未找到匹配的文献段落。请尝试调整问题表述,或确保文献已完成向量化。",
json!({ "count": 0, "sources": [] }),
);
}
let max_chars = ctx.max_output_chars;
let sources: Vec<serde_json::Value> = chunks
.iter()
.map(|c| {
json!({
"bibcode": c.bibcode,
"paragraph_index": c.paragraph_index,
"headings": c.headings,
"distance": c.distance,
"preview": truncate_content(&c.content, 300)
})
})
.collect();
let content = chunks
.iter()
.enumerate()
.map(|(i, c)| {
let heading_info = if c.headings.is_empty() || c.headings == "Document" {
String::new()
} else {
format!(", 章节: {}", c.headings)
};
format!(
"[片段 {}] 来源: {} (段落 #{}{})\n{}",
i + 1,
c.bibcode,
c.paragraph_index,
heading_info,
truncate_content(&c.content, max_chars / chunks.len().max(1))
)
})
.collect::<Vec<_>>()
.join("\n\n---\n\n");
ToolOutput::success(
content,
json!({ "count": chunks.len(), "sources": sources }),
)
}
Err(e) => ToolOutput::error(format!("混合检索失败: {}", e)),
}
}
}

View File

@ -0,0 +1,104 @@
// src/agent/tools/astro/research/target.rs
// QueryTargetTool — 天体目标查询 (CDS Sesame)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct QueryTargetTool;
#[async_trait]
impl AgentTool for QueryTargetTool {
fn name(&self) -> &str {
"query_target"
}
fn description(&self) -> &str {
"查询天体目标的基本物理参数(坐标 RA/Dec、视星等、光谱类型、视差等。数据来源为 CDS SIMBAD/Sesame 名称解析服务,结果自动缓存。\
'NGC 6752'、'GD 358'、'HD 209458'、'M 31' "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"object_name": {
"type": "string",
"description": "天体目标名称,如 'NGC 6752'、'GD 358'、'HD 209458'、'M 31'"
}
},
"required": ["object_name"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let object_name = match args.get("object_name").and_then(|n| n.as_str()) {
Some(n) => n.to_string(),
None => return ToolOutput::error("缺少必需参数 'object_name'"),
};
info!("[QueryTarget] 查询天体: {}", object_name);
let state = &ctx.app_state;
let client = reqwest::Client::new();
match crate::services::target::query_target_cached(&state.db, &object_name, None, &client)
.await
{
Ok(target) => {
let mut content = String::new();
content.push_str(&format!("天体 {} 查询结果:\n", target.target_name));
if let Some(ref ra) = target.ra {
content.push_str(&format!(" 赤经 (RA): {}\n", ra));
}
if let Some(ref dec) = target.dec {
content.push_str(&format!(" 赤纬 (Dec): {}\n", dec));
}
if let Some(p) = target.parallax {
content.push_str(&format!(" 视差: {:.4} mas\n", p));
}
if let Some(ref st) = target.spectral_type {
content.push_str(&format!(" 光谱类型: {}\n", st));
}
if let Some(v) = target.v_magnitude {
content.push_str(&format!(" V 波段星等: {:.3}\n", v));
}
if let Some(ref ot) = target.otype {
content.push_str(&format!(" 天体类型: {}\n", ot));
}
if let Some(ref on) = target.oname {
content.push_str(&format!(" 主要名称: {}\n", on));
}
if !target.aliases.is_empty() {
content.push_str(&format!(" 别名: {}\n", target.aliases.join(", ")));
}
if let Some(ref phot) = target.photometry {
if !phot.is_empty() {
content.push_str(" 多波段测光:\n");
let mut bands: Vec<_> = phot.iter().collect();
bands.sort_by_key(|(k, _)| k.to_string());
for (band, mag) in bands {
content.push_str(&format!(" {}: {:.3}\n", band, mag));
}
}
}
ToolOutput::success(content, json!(target))
}
Err(e) => ToolOutput::error(format!("天体查询失败: {}", e)),
}
}
}

View File

@ -0,0 +1,8 @@
// src/agent/tools/astro/system/mod.rs
// 系统级工具AI 代替人工操作基础设施(外部 API 调用、文件下载、解析、向量化)
pub mod process;
pub mod search;
pub use process::ProcessPaperTool;
pub use search::SearchPapersTool;

View File

@ -0,0 +1,261 @@
// src/agent/tools/astro/system/process.rs
// ProcessPaperTool — 文献处理流水线:下载 → 解析 → 向量化
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
pub struct ProcessPaperTool;
#[async_trait]
impl AgentTool for ProcessPaperTool {
fn name(&self) -> &str {
"process_paper"
}
fn description(&self) -> &str {
"文献处理流水线工具。按需执行一个或多个任务步骤下载全文资源PDF/HTML、解析为结构化 Markdown、向量化嵌入。\
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"tasks": {
"type": "array",
"items": {
"type": "string",
"enum": ["download", "parse", "embed"]
},
"description": "需执行的任务列表按序执行。download: 下载 PDF/HTMLparse: 解析为 Markdownembed: 切片并向量化入库"
},
"force": {
"type": "boolean",
"description": "是否强制重新执行(跳过已有结果检查,对所有任务生效)",
"default": false
}
},
"required": ["bibcode", "tasks"]
})
}
fn group(&self) -> &str {
"as:system"
}
fn defer_loading(&self) -> bool {
true
}
fn is_readonly(&self) -> bool {
false
}
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let tasks: Vec<String> = match args.get("tasks").and_then(|t| t.as_array()) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
None => {
return ToolOutput::error(
"缺少必需参数 'tasks',请指定至少一个任务: download, parse, embed",
)
}
};
if tasks.is_empty() {
return ToolOutput::error(
"tasks 数组不能为空,请指定至少一个任务: download, parse, embed",
);
}
let force = args.get("force").and_then(|f| f.as_bool()).unwrap_or(false);
let state = &ctx.app_state;
let mut completed: Vec<String> = Vec::new();
let mut has_pdf = false;
let mut has_html = false;
let mut has_markdown = false;
let mut chunk_count = 0usize;
for task in &tasks {
match task.as_str() {
"download" => {
info!("[ProcessPaper] download: {}", bibcode);
match state
.downloader
.download_paper_service(
&state.db,
&state.config.library_dir,
&bibcode,
force,
)
.await
{
Ok(paper) => {
has_pdf = paper.has_pdf;
has_html = paper.has_html;
completed.push("download".into());
}
Err(e) => {
return ToolOutput::error(format!(
"文献 {} 下载失败(已完成: [{}]: {}",
bibcode,
completed.join(", "),
e
));
}
}
}
"parse" => {
info!("[ProcessPaper] parse: {}", bibcode);
match crate::services::parser::parse_paper_service(
&state.db,
&state.config.library_dir,
&state.qiniu,
&state.config,
&bibcode,
force,
)
.await
{
Ok(markdown) => {
has_markdown = true;
completed.push("parse".into());
info!("[ProcessPaper] parse done: {} chars", markdown.len());
}
Err(e) => {
let msg = e.to_string();
let hint = if msg.contains("请先下载") {
"未检测到已下载的本地资源文件,请确保 tasks 中 download 在 parse 之前执行。"
} else {
""
};
return ToolOutput::error(format!(
"文献 {} 解析失败(已完成: [{}]: {} {}",
bibcode,
completed.join(", "),
msg,
hint
));
}
}
}
"embed" => {
info!("[ProcessPaper] embed: {}", bibcode);
// 读取 markdown 文件
let paths = crate::api::helpers::check_paper_paths_in_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await;
let md_rel = match paths {
Ok(Some((_, _, Some(md), _))) => md,
Ok(_) => {
return ToolOutput::error(format!(
"文献 {} 向量化失败(已完成: [{}]):未找到已解析的 Markdown 文件,请确保 tasks 中 parse 在 embed 之前执行",
bibcode,
completed.join(", ")
));
}
Err(e) => {
return ToolOutput::error(format!(
"文献 {} 向量化失败(已完成: [{}]: {}",
bibcode,
completed.join(", "),
e
));
}
};
let md_abs = state.config.library_dir.join(&md_rel);
let markdown_text = match std::fs::read_to_string(&md_abs) {
Ok(t) => t,
Err(e) => {
return ToolOutput::error(format!(
"文献 {} 向量化失败(已完成: [{}]):读取 Markdown 文件错误: {}",
bibcode,
completed.join(", "),
e
));
}
};
match crate::services::rag::ingest_paper(
&state.db,
&state.embedding,
&bibcode,
&markdown_text,
None, // 使用默认 ChunkerConfig结构化分块
)
.await
{
Ok(n) => {
chunk_count = n;
completed.push("embed".into());
info!("[ProcessPaper] embed done: {} chunks", n);
}
Err(e) => {
return ToolOutput::error(format!(
"文献 {} 向量化失败(已完成: [{}]: {}",
bibcode,
completed.join(", "),
e
));
}
}
}
other => {
return ToolOutput::error(format!(
"无效的任务类型 '{}',仅支持: download, parse, embed",
other
));
}
}
}
let msg = format!(
"文献 {} 处理完成。已执行: [{}]。PDF: {}, HTML: {}, Markdown: {}, 向量块数: {}",
bibcode,
completed.join(""),
has_pdf,
has_html,
has_markdown,
chunk_count
);
ToolOutput::success(
msg,
json!({
"bibcode": bibcode,
"completed": completed,
"has_pdf": has_pdf,
"has_html": has_html,
"has_markdown": has_markdown,
"chunk_count": chunk_count
}),
)
}
}

View File

@ -1,4 +1,5 @@
// src/agent/tools/search.rs — 文献搜索与元数据工具
// src/agent/tools/astro/system/search.rs
// SearchPapersTool — 外部 API 检索 (ADS + arXiv)
use async_trait::async_trait;
use serde_json::json;
@ -6,9 +7,6 @@ use tracing::{error, info};
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
// ── SearchPapersTool ──
/// 文献搜索工具:调用 ADS/arXiv 进行跨库检索
pub struct SearchPapersTool;
#[async_trait]
@ -45,11 +43,18 @@ impl AgentTool for SearchPapersTool {
})
}
/// 纯读取外部 API并发安全
fn group(&self) -> &str {
"as:system"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let query = match args.get("query").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
@ -125,73 +130,4 @@ impl AgentTool for SearchPapersTool {
}
}
}
fn is_readonly(&self) -> bool {
true
}
}
// ── GetPaperMetadataTool ──
/// 获取文献元数据工具:获取指定文献的完整元数据
pub struct GetPaperMetadataTool;
#[async_trait]
impl AgentTool for GetPaperMetadataTool {
fn name(&self) -> &str {
"get_paper_metadata"
}
fn description(&self) -> &str {
"获取指定文献的完整元数据信息(包括完整标题、所有作者、出版期刊、关键字、引用数、完整摘要等)。\
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode如 '2024ApJ...960..123A'、DOI 或 arXiv ID"
}
},
"required": ["bibcode"]
})
}
/// 纯读取数据库,并发安全
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
info!("[GetPaperMetadata] 获取文献元数据: {}", bibcode);
let state = &ctx.app_state;
match crate::api::helpers::get_paper_from_db(&state.db, &state.config.library_dir, &bibcode)
.await
{
Ok(paper) => {
let content = format!(
"文献元数据 [{}]\n 标题: {}\n 作者: {}\n 年份: {}\n 期刊: {}\n 关键字: {}\n DOI: {}\n arXiv ID: {}\n 引用数: {} 次\n 参考文献数: {} 次\n 文献类型: {}\n 已下载: {}\n 已解析为 Markdown: {}\n 摘要:\n{}",
paper.bibcode, paper.title, paper.authors.join(", "), paper.year,
paper.pub_journal, paper.keywords.join(", "), paper.doi, paper.arxiv_id,
paper.citation_count, paper.reference_count, paper.doctype,
paper.is_downloaded, paper.has_markdown, paper.abstract_text
);
ToolOutput::success(content, json!(paper))
}
Err(e) => ToolOutput::error(format!("获取文献 {} 元数据失败: {}", bibcode, e)),
}
}
fn is_readonly(&self) -> bool {
true
}
}

View File

@ -1,93 +0,0 @@
// src/agent/tools/target.rs — 天体物理查询工具
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
/// 天体信息查询工具:通过 CDS Sesame 查询天体物理属性
pub struct QueryTargetTool;
#[async_trait]
impl AgentTool for QueryTargetTool {
fn name(&self) -> &str {
"query_target"
}
fn description(&self) -> &str {
"查询天体的基本物理属性信息。输入天体名称,返回坐标 (RA/Dec)、视星等、光谱型、视差等属性。\
CDS SIMBAD/Sesame "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"object_name": {
"type": "string",
"description": "天体名称,如 'NGC 6752', 'GD 358', 'HD 209458', 'M 31' 等"
}
},
"required": ["object_name"]
})
}
/// 纯读取外部天体数据库,并发安全
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let object_name = match args.get("object_name").and_then(|n| n.as_str()) {
Some(n) => n.to_string(),
None => return ToolOutput::error("缺少必需参数 'object_name'"),
};
info!("[QueryTarget] 查询天体: {}", object_name);
let state = &ctx.app_state;
let client = reqwest::Client::new();
match crate::services::target::query_target_cached(&state.db, &object_name, None, &client)
.await
{
Ok(info) => {
let content = format!(
"天体: {}\nRA: {}\nDec: {}\n视差: {}\n光谱型: {}\nV星等: {}\n别名: {}",
info.target_name,
info.ra.as_deref().unwrap_or("未知"),
info.dec.as_deref().unwrap_or("未知"),
info.parallax
.map(|p| format!("{:.4} mas", p))
.unwrap_or_else(|| "未知".to_string()),
info.spectral_type.as_deref().unwrap_or("未知"),
info.v_magnitude
.map(|v| format!("{:.2}", v))
.unwrap_or_else(|| "未知".to_string()),
if info.aliases.is_empty() {
"".to_string()
} else {
info.aliases.join(", ")
}
);
ToolOutput::success(
content,
json!({
"target_name": info.target_name,
"ra": info.ra, "dec": info.dec,
"parallax": info.parallax,
"spectral_type": info.spectral_type,
"v_magnitude": info.v_magnitude,
"aliases": info.aliases
}),
)
}
Err(e) => ToolOutput::error(format!("天体 '{}' 查询失败: {}", object_name, e)),
}
}
fn is_readonly(&self) -> bool {
true
}
}

Some files were not shown because too many files have changed in this diff Show More