feat: 接入 VizieR 星表检索与 LAMOST/Gaia/SDSS/DESI 跨源光谱下载

新增天文观测数据获取能力,覆盖星表查询与一维光谱下载两大场景:

星表检索(CDS VizieR)
- VizieR TAP 客户端(JSON 优先 + VOTable 降级),共享 IVOA VOTable 解析层
- 业务层支持自由 ADQL、锥形检索、交叉证认、星表发现与 CSV 导出
- ADQL 注入防护(标识符清洗 + 字符串字面量转义),TTL 缓存(7 天)

跨望远镜光谱下载(统一入口)
- 接入 LAMOST(ConeSearch + FITS.gz)、Gaia(TAP + DataLink ZIP)、
  SDSS(Data Lab TAP + SAS)、DESI(HEALPix coadd)四源
- 双模式:坐标模式(cone 检索 → 选源 → 下载)/ 标识符模式(直按 ID 下载)
- 光谱文件永久缓存(不可变),按 source+source_id 去重

Agent 与 API
- +4 工具:query_vizier / cone_search / find_spectrum / catalog_operation(22 → 26)
- +6 路由:/catalog/vizier、/cone、/crossmatch、/spectrum/{download,list}
- 前端新增 VizierResultCard / FindSpectrumCard 可视化卡片

工程重构
- services/target.rs (832 行) 拆分为 services/cds/{target,vizier}.rs + clients/cds/sesame.rs,
  贯彻 client(通信)/ service(缓存+编排)分层
- ADS 返回字段新增 data(关联数据表 URL),与星表功能联动
This commit is contained in:
fmq
2026-07-06 00:07:25 +08:00
parent 0ba85d7749
commit a156252bc3
48 changed files with 8837 additions and 388 deletions
@@ -31,6 +31,10 @@ const TOOL_LABELS: Record<string, string> = {
get_paper_content: '获取内容',
rag_search: 'RAG 检索',
query_target: '天体查询',
query_vizier: 'VizieR 星表',
cone_search: '锥形检索',
find_spectrum: '光谱检索下载',
catalog_operation: '星表操作',
save_note: '保存笔记',
todo_write: '任务管理',
compress_context: '上下文压缩',
@@ -63,6 +67,10 @@ const CATEGORY_COLORS: Record<string, string> = {
get_paper_content: 'bg-emerald-100 text-emerald-700 border-emerald-200',
rag_search: 'bg-violet-100 text-violet-700 border-violet-200',
query_target: 'bg-amber-100 text-amber-700 border-amber-200',
query_vizier: 'bg-violet-100 text-violet-700 border-violet-200',
cone_search: 'bg-violet-100 text-violet-700 border-violet-200',
find_spectrum: 'bg-slate-200 text-slate-700 border-slate-300',
catalog_operation: 'bg-violet-100 text-violet-700 border-violet-200',
save_note: 'bg-teal-100 text-teal-700 border-teal-200',
todo_write: 'bg-orange-100 text-orange-700 border-orange-200',
compress_context: 'bg-rose-100 text-rose-700 border-rose-200',
@@ -34,6 +34,10 @@ function getToolDisplayName(name: string | null): string {
get_paper_content: '获取内容',
rag_search: 'RAG 检索',
query_target: '天体查询',
query_vizier: 'VizieR 星表',
cone_search: '锥形检索',
find_spectrum: '光谱检索下载',
catalog_operation: '星表操作',
save_note: '保存笔记',
todo_write: '任务管理',
compress_context: '压缩上下文',
@@ -11,6 +11,8 @@ import {
AlertTriangle,
Cpu,
Check,
Table as TableIcon,
Database,
} from 'lucide-react';
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
@@ -608,3 +610,261 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
</div>
);
}
// ==========================================
// 5. VizieR 星表查询结果卡片 (query_vizier / cone_search)
// ==========================================
interface VizierFieldInfo {
name: string;
description?: string;
unit?: string;
datatype?: string;
}
interface VizierResultCardProps {
metadata: {
table_name?: string;
fields: VizierFieldInfo[];
rows: unknown[][];
row_count: number;
truncated: boolean;
};
}
// 最多展示行数,避免渲染过多 DOM 节点
const MAX_DISPLAY_ROWS = 50;
export function VizierResultCard({ metadata }: VizierResultCardProps) {
const { table_name, fields = [], rows = [], row_count, truncated } = metadata;
const displayRows = rows.slice(0, MAX_DISPLAY_ROWS);
const hiddenCount = rows.length - displayRows.length;
const renderCell = (val: unknown): string => {
if (val === null || val === undefined) return '—';
if (typeof val === 'number') {
// 数字保留合理精度
return Number.isInteger(val) ? String(val) : val.toFixed(6).replace(/\.?0+$/, '');
}
if (typeof val === 'boolean') return val ? 'true' : 'false';
return String(val);
};
const cdsUrl = table_name
? `https://vizier.cds.unistra.fr/viz-bin/VizieR-3?-source=${encodeURIComponent(table_name)}`
: 'https://vizier.cds.unistra.fr/';
return (
<div className="bg-violet-50/40 border border-violet-100 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏 */}
<div className="flex items-center justify-between border-b border-violet-100/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-violet-900 text-[11px] uppercase tracking-wide">
<TableIcon className="w-3.5 h-3.5 text-violet-600" />
<span>VizieR </span>
</div>
<a
href={cdsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-violet-600 hover:text-violet-800 flex items-center gap-0.5 hover:underline"
>
<span>CDS</span>
<ExternalLink className="w-2.5 h-2.5" />
</a>
</div>
{/* 元信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{table_name && (
<span className="flex items-center gap-1 font-semibold text-violet-700">
<Database className="w-3 h-3" />
{table_name}
</span>
)}
<span className="bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded font-medium">
{row_count}
</span>
<span className="text-slate-500">{fields.length} </span>
{truncated && (
<span className="flex items-center gap-0.5 text-amber-600 font-medium">
<AlertTriangle className="w-3 h-3" />
</span>
)}
</div>
{/* 表格 */}
{fields.length === 0 || rows.length === 0 ? (
<p className="text-[11px] text-slate-400 italic py-2">()</p>
) : (
<div className="overflow-x-auto max-h-72 overflow-y-auto border border-slate-200 rounded">
<table className="w-full text-[10px] border-collapse">
<thead className="sticky top-0 bg-slate-100 z-10">
<tr>
{fields.map((f, i) => (
<th
key={i}
className="px-2 py-1.5 text-left font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap"
title={f.description || f.unit ? `${f.description || ''} ${f.unit ? `[${f.unit}]` : ''}`.trim() : undefined}
>
{f.name}
{f.unit && (
<span className="text-slate-400 font-normal ml-1">[{f.unit}]</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{displayRows.map((row, ri) => (
<tr
key={ri}
className={ri % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}
>
{fields.map((_, ci) => (
<td
key={ci}
className="px-2 py-1 text-slate-700 border-b border-slate-100 whitespace-nowrap font-mono"
>
{renderCell(row[ci])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 底部提示 */}
{hiddenCount > 0 && (
<p className="text-[10px] text-slate-400">
{MAX_DISPLAY_ROWS} {hiddenCount}
</p>
)}
</div>
);
}
// ==========================================
// 6. 统一光谱下载结果卡片 (find_spectrum)
// ==========================================
interface SpectrumDownloadItem {
survey: string;
source_label: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
}
interface FindSpectrumCardProps {
metadata: {
survey: string;
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
downloads: SpectrumDownloadItem[];
failures: { source_label: string; error: string }[];
};
}
const SURVEY_THEME: Record<string, { badge: string; label: string }> = {
lamost: { badge: 'bg-amber-100 text-amber-700', label: 'LAMOST' },
gaia: { badge: 'bg-sky-100 text-sky-700', label: 'Gaia' },
sdss: { badge: 'bg-indigo-100 text-indigo-700', label: 'SDSS' },
desi: { badge: 'bg-emerald-100 text-emerald-700', label: 'DESI' },
};
export function FindSpectrumCard({ metadata }: FindSpectrumCardProps) {
const { survey, ra, dec, radius_deg, matched_count, downloads = [], failures = [] } = metadata;
const theme = SURVEY_THEME[survey] || SURVEY_THEME.lamost;
const hasResult = downloads.length > 0 || failures.length > 0;
return (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏 */}
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
<Activity className="w-3.5 h-3.5 text-slate-500" />
<span></span>
</div>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}>
{theme.label}
</span>
</div>
{/* 查询信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{ra !== undefined && dec !== undefined && (
<span className="font-mono">
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
</span>
)}
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
{matched_count}
</span>
{downloads.length > 0 && (
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
<CheckCircle2 className="w-3 h-3" />
{downloads.length}
</span>
)}
{failures.length > 0 && (
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
<AlertTriangle className="w-3 h-3" />
{failures.length}
</span>
)}
</div>
{/* 下载结果列表 */}
{downloads.map((d, i) => (
<div key={i} className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-mono font-medium text-slate-700">{d.source_label}</span>
{d.cached ? (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
<CheckCircle2 className="w-3 h-3" />
</span>
) : (
<span className="text-[10px] text-slate-400"></span>
)}
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-slate-500">
{d.file_format.toUpperCase()} · {d.size_bytes > 1024 ? `${(d.size_bytes / 1024).toFixed(1)} KB` : `${d.size_bytes} B`}
</span>
<a
href={d.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>FITS</span>
</a>
</div>
</div>
))}
{/* 失败列表 */}
{failures.map((f, i) => (
<div key={`f${i}`} className="border border-rose-200 rounded bg-rose-50/50 p-2.5">
<div className="flex items-center gap-1 text-rose-700 font-medium">
<AlertTriangle className="w-3 h-3" />
<span className="font-mono text-[10px]">{f.source_label}</span>
</div>
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
</div>
))}
{!hasResult && (
<p className="text-[11px] text-slate-400 italic py-2">()</p>
)}
</div>
);
}
@@ -10,6 +10,8 @@ import {
PaperListCard,
TodoTaskCard,
BgTaskProgressCard,
VizierResultCard,
FindSpectrumCard,
} from './SpecialToolRenderers';
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
@@ -94,6 +96,62 @@ export function ToolCallCard({
);
}
// 1b. VizieR 星表查询 / Cone Search
if (
(name === 'query_vizier' || name === 'cone_search') &&
metadata &&
typeof metadata === 'object' &&
'fields' in metadata &&
'rows' in metadata
) {
return (
<VizierResultCard
metadata={
metadata as {
table_name?: string;
fields: { name: string; description?: string; unit?: string; datatype?: string }[];
rows: unknown[][];
row_count: number;
truncated: boolean;
}
}
/>
);
}
// 1c. 统一光谱下载(find_spectrum
if (
name === 'find_spectrum' &&
metadata &&
typeof metadata === 'object' &&
'survey' in metadata &&
'downloads' in metadata
) {
return (
<FindSpectrumCard
metadata={
metadata as {
survey: string;
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
downloads: {
survey: string;
source_label: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
}[];
failures: { source_label: string; error: string }[];
}
}
/>
);
}
// 2. 文献检索 / 馆藏检索
if (
(name === 'search_papers' || name === 'search_local_library') &&
@@ -31,6 +31,14 @@ export function getToolDisplayName(name: string): string {
return '检索馆藏知识库';
case 'query_target':
return '查询天体物理参数 (CDS)';
case 'query_vizier':
return '查询 VizieR 星表';
case 'cone_search':
return '锥形检索天体';
case 'find_spectrum':
return '检索下载光谱';
case 'catalog_operation':
return '星表操作';
case 'save_note':
return '保存文献手札';
// Agent 控制工具