核心架构重构: - Config 按职责拆分为 LlmConfig/EmbeddingConfig/VisionConfig/CdsConfig/StorageConfig 五个子结构 - AppState 拆分为 LlmState/DataSourceState/SessionState 三个子结构,消除 50+ 平铺字段 - 新增 ServiceError 结构化错误类型替代 handler 中的 msg.contains() 字符串匹配 - 移除 api::handlers 兼容命名空间,路由直接引用 agent/auth/papers 等模块 认证性能优化: - login_rate_limiter/upload_rate_limiter 从 Mutex<HashMap> 迁移为 DashMap(无锁) - 新增 session_last_active: DashMap<String, AtomicU64>,auth 中间件快速路径免写锁 - 会话过期清理改为按间隔触发(300s),避免每次请求全表扫描 - MAX_SESSIONS 1000→10000,SSE 广播通道 256→1024 Agent 工具增强: - AgentTool trait 新增 is_internal()/display_name(),SSE 事件携带工具元数据 - 新增 GET /chat/tools 端点暴露注册工具列表 - pending_questions/pending_permissions 增加 created_at 时间戳,自动清理过期条目(10min TTL) - Agent 超时现在正确 abort 后台任务并设置取消令牌 观测数据源修复: - FITS 解析: APOGEE/DESI 改用 read_image+切片替代 read_rows(修复 fitsio panic) - ZTF: CIRCLE 参数分隔符 +→空格(修复 IRSA 400),半径自动裁剪至硬上限 - MAST TESS: parse_tic_json 兼容数组/对象两种 API 响应格式 - 统一检索: per_target_limit 默认 50→1,sources 支持 per-source release/version - Gaia 测光从 VizieR 镜像切换至官方 TAP 服务 RAG 并发优化: - 向量化降级从串行改为并发 5 条/批(buffer_unordered) - 混合检索 RRF 合并从借用改为 owned RetrievalResult 安全加固: - PDF 中间件: URL 解码 %2F/%2E 后判扩展名;文件名过滤非 ASCII + 禁 \ 防头注入 - chat_agent 日志截断问题内容至 50 字符;list_sessions 强制 limit clamp 前端双主题: - 设计令牌三层架构: primitive→semantic→component,浅色暖纸张学术/暗色 Night Indigo - useTheme hook + ThemeToggle 侧边栏组件 + main.tsx 防 FOUC 初始化 - 全组件从硬编码 slate 色迁移至语义令牌(bg-surface/text-content/border-subtle 等) - 新增 ToastContainer 非阻塞通知系统 部署优化: - deploy.sh 引入 SSH ControlMaster 单次密码复用
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
// dashboard/src/features/agent/AskUserQuestionCard.tsx
|
|
import { useState, useEffect } from 'react';
|
|
import axios from 'axios';
|
|
import { MessageCircle, Send, Loader, CheckSquare, Square } from 'lucide-react';
|
|
import type { PendingQuestion } from '../../types';
|
|
|
|
interface AskUserQuestionCardProps {
|
|
onAnswered?: () => void;
|
|
onQuestionCountChange?: (count: number) => void;
|
|
}
|
|
|
|
export function AskUserQuestionCard({
|
|
onAnswered,
|
|
onQuestionCountChange,
|
|
}: AskUserQuestionCardProps) {
|
|
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>(
|
|
[]
|
|
);
|
|
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
|
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
|
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
const [error, setError] = useState<Record<string, string | null>>({});
|
|
|
|
// 轮询待回答问题
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const res = await axios.get<PendingQuestion[]>('/api/chat/questions');
|
|
if (!cancelled) {
|
|
const data = Array.isArray(res.data) ? res.data : [];
|
|
setPendingQuestions(data);
|
|
// 自动展开新问题
|
|
setExpanded((prev) => {
|
|
const next = { ...prev };
|
|
for (const q of data) {
|
|
if (q && q.question_id && !(q.question_id in next)) {
|
|
next[q.question_id] = true;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('获取待回答问题失败:', e);
|
|
}
|
|
};
|
|
|
|
poll();
|
|
const interval = setInterval(poll, 3000); // 每3秒轮询
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
// 当待处理问题数量改变时,报告给父容器
|
|
useEffect(() => {
|
|
onQuestionCountChange?.(pendingQuestions.length);
|
|
}, [pendingQuestions.length, onQuestionCountChange]);
|
|
|
|
const toggleOption = (
|
|
questionId: string,
|
|
optionLabel: string,
|
|
multiSelect: boolean
|
|
) => {
|
|
setAnswers((prev) => {
|
|
const current = prev[questionId] || [];
|
|
if (multiSelect) {
|
|
return {
|
|
...prev,
|
|
[questionId]: current.includes(optionLabel)
|
|
? current.filter((o) => o !== optionLabel)
|
|
: [...current, optionLabel],
|
|
};
|
|
} else {
|
|
return { ...prev, [questionId]: [optionLabel] };
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (questionId: string) => {
|
|
setSubmitting((prev) => ({ ...prev, [questionId]: true }));
|
|
setError((prev) => ({ ...prev, [questionId]: null }));
|
|
try {
|
|
await axios.post('/api/chat/answer', {
|
|
question_id: questionId,
|
|
answers: answers[questionId] || [],
|
|
free_text: freeText[questionId] || null,
|
|
});
|
|
// 移除已回答的问题
|
|
setPendingQuestions((prev) =>
|
|
prev.filter((q) => q.question_id !== questionId)
|
|
);
|
|
// 清理状态
|
|
setAnswers((prev) => {
|
|
const next = { ...prev };
|
|
delete next[questionId];
|
|
return next;
|
|
});
|
|
setFreeText((prev) => {
|
|
const next = { ...prev };
|
|
delete next[questionId];
|
|
return next;
|
|
});
|
|
setError((prev) => {
|
|
const next = { ...prev };
|
|
delete next[questionId];
|
|
return next;
|
|
});
|
|
onAnswered?.();
|
|
} catch (e: unknown) {
|
|
console.error('提交答案失败:', e);
|
|
const axiosError = e as { response?: { status?: number } };
|
|
const msg =
|
|
axiosError.response?.status === 410
|
|
? '该问题已超时或已被回答'
|
|
: axiosError.response?.status === 404
|
|
? '未找到该问题'
|
|
: '提交失败,请稍后重试';
|
|
setError((prev) => ({ ...prev, [questionId]: msg }));
|
|
} finally {
|
|
setSubmitting((prev) => ({ ...prev, [questionId]: false }));
|
|
}
|
|
};
|
|
|
|
const dismissQuestion = (questionId: string) => {
|
|
setPendingQuestions((prev) =>
|
|
prev.filter((q) => q.question_id !== questionId)
|
|
);
|
|
setExpanded((prev) => ({ ...prev, [questionId]: false }));
|
|
};
|
|
|
|
if (pendingQuestions.length === 0) return null;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
{pendingQuestions.map((q) => {
|
|
if (!q || !q.question_id) return null;
|
|
|
|
const isExpanded = expanded[q.question_id] !== false;
|
|
const isSubmitting = submitting[q.question_id] || false;
|
|
const qError = error[q.question_id] || null;
|
|
const options = Array.isArray(q.options) ? q.options : [];
|
|
const multiSelect = q.multi_select === true;
|
|
|
|
return (
|
|
<div
|
|
key={q.question_id}
|
|
className="rounded-md border-l-4 border-l-blueprint bg-sunken p-4 transition-all pointer-events-auto text-xs"
|
|
>
|
|
{/* 标题栏 - 头部与问题放在同一行,可省略独立的问题文本框 */}
|
|
<div className="mb-2.5 flex items-center justify-between border-b border-subtle pb-2 min-w-0">
|
|
<div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3">
|
|
<MessageCircle className="h-3.5 w-3.5 text-blueprint shrink-0" />
|
|
<span className="px-1.5 py-0.2 rounded bg-border-subtle text-[9px] font-bold text-content shrink-0">
|
|
{q.header || '提问'}
|
|
</span>
|
|
<span
|
|
className="font-extrabold text-content text-[11px] truncate flex-1"
|
|
title={q.question || ''}
|
|
>
|
|
{q.question || ''}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{options.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setExpanded((prev) => ({
|
|
...prev,
|
|
[q.question_id]: !prev[q.question_id],
|
|
}))
|
|
}
|
|
className="text-[10px] font-bold text-blueprint hover:underline cursor-pointer select-none"
|
|
>
|
|
{isExpanded ? '收起选项' : '展开选项'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 内容区 */}
|
|
{isExpanded && (
|
|
<div className="space-y-2.5">
|
|
{/* 选项列表 */}
|
|
{options.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<span className="text-[9px] font-bold text-tertiary uppercase tracking-wider block">
|
|
{multiSelect ? '可多选' : '请选择一项'}
|
|
</span>
|
|
<div className="space-y-1.5">
|
|
{options.map((option, idx) => {
|
|
const label =
|
|
typeof option?.label === 'string'
|
|
? option.label
|
|
: String(option);
|
|
const desc =
|
|
typeof option?.description === 'string'
|
|
? option.description
|
|
: '';
|
|
const selected = (
|
|
answers[q.question_id] || []
|
|
).includes(label);
|
|
|
|
return (
|
|
<button
|
|
key={idx}
|
|
onClick={() =>
|
|
toggleOption(q.question_id, label, multiSelect)
|
|
}
|
|
disabled={isSubmitting}
|
|
className={`w-full text-left px-2.5 py-1.5 rounded-md border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${
|
|
selected
|
|
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold'
|
|
: 'bg-surface border-subtle text-secondary hover:border-subtle hover:bg-sunken'
|
|
} disabled:opacity-50`}
|
|
>
|
|
{/* 选择指示器 */}
|
|
{multiSelect ? (
|
|
selected ? (
|
|
<CheckSquare className="w-3.5 h-3.5 text-blueprint shrink-0" />
|
|
) : (
|
|
<Square className="w-3.5 h-3.5 text-tertiary shrink-0" />
|
|
)
|
|
) : (
|
|
<div
|
|
className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
|
|
selected
|
|
? 'border-blueprint bg-blueprint'
|
|
: 'border-subtle'
|
|
}`}
|
|
>
|
|
{selected && (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="w-1 h-1 rounded-full bg-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
|
|
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 truncate">
|
|
<span className="font-extrabold text-content text-[11px] shrink-0 leading-normal">
|
|
{label}
|
|
</span>
|
|
{desc && (
|
|
<span
|
|
className="text-[10px] text-secondary font-medium truncate leading-normal"
|
|
title={desc}
|
|
>
|
|
— {desc}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 自由文本 */}
|
|
<div className="w-full">
|
|
<textarea
|
|
value={freeText[q.question_id] || ''}
|
|
onChange={(e) =>
|
|
setFreeText((prev) => ({
|
|
...prev,
|
|
[q.question_id]: e.target.value,
|
|
}))
|
|
}
|
|
disabled={isSubmitting}
|
|
placeholder="补充说明(可选)..."
|
|
rows={1}
|
|
className="w-full bg-surface border border-default rounded-md px-2.5 py-1.5 text-[11px] text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:ring-1 focus:ring-blueprint/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal"
|
|
/>
|
|
</div>
|
|
|
|
{/* 错误提示 */}
|
|
{qError && (
|
|
<div className="text-[10px] text-danger bg-red-50 border border-red-200 dark:bg-rose-500/10 dark:border-rose-500/30 rounded-md px-2.5 py-1.5 font-bold">
|
|
{qError}
|
|
</div>
|
|
)}
|
|
|
|
{/* 提交按钮 */}
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => handleSubmit(q.question_id)}
|
|
disabled={isSubmitting}
|
|
className="flex-1 flex items-center justify-center gap-1.5 bg-success hover:opacity-90 border border-success text-white rounded-md py-1.5 text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader className="w-3 h-3 animate-spin" />
|
|
<span>提交中...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send className="w-3 h-3" />
|
|
<span>提交回答</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => dismissQuestion(q.question_id)}
|
|
disabled={isSubmitting}
|
|
className="px-3 bg-sunken hover:bg-border-subtle text-secondary rounded-md text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
|
|
>
|
|
忽略
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|