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:
@@ -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 控制工具
|
||||
|
||||
Reference in New Issue
Block a user