AstroResearch/dashboard/src/components/agent/AuditLogViewer.tsx
Asfmq 5db4cc5998 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 (文献信息渲染)
2026-06-25 23:45:37 +08:00

228 lines
9.8 KiB
TypeScript

// dashboard/src/features/agent/AuditLogViewer.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { ScrollText, Clock, CheckCircle2, XCircle, AlertTriangle, Loader, ChevronDown, ChevronUp } from 'lucide-react';
import type { AuditLogEntry } from '../../types';
interface AuditLogViewerProps {
sessionId: string;
onClose?: () => void;
}
function getToolDisplayName(name: string | null): string {
if (!name) return '—';
const map: Record<string, string> = {
read_file: '读取文件',
grep_files: '搜索文件',
glob_files: '匹配文件',
run_bash: 'Shell 命令',
file_write: '写入文件',
file_edit: '编辑文件',
search_papers: '文献检索',
get_paper_metadata: '获取元数据',
download_paper: '下载文献',
parse_paper: '解析文献',
get_paper_content: '获取内容',
rag_search: 'RAG 检索',
query_target: '天体查询',
save_note: '保存笔记',
todo_write: '任务管理',
compress_context: '压缩上下文',
load_skill: '加载技能',
subagent: '派发子代理',
delegate_research: '子代理研究(旧)',
ask_user: '用户提问',
save_memory: '保存记忆',
load_memory: '读取记忆',
bg_task_run: '后台任务',
bg_task_check: '检查后台',
spawn_teammate: '创建队友',
send_teammate_message: '队友消息',
team_broadcast: '团队广播',
check_team_inbox: '收件箱检查',
};
return map[name] || name;
}
export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedPreview, setExpandedPreview] = useState<Record<number, boolean>>({});
useEffect(() => {
if (!sessionId) return;
setLoading(true);
setError(null);
axios
.get<AuditLogEntry[]>(`/api/chat/sessions/${sessionId}/audit`)
.then(res => setEntries(res.data))
.catch(e => {
console.error('加载审计日志失败:', e);
setError('审计日志加载失败');
})
.finally(() => setLoading(false));
}, [sessionId]);
const okCount = entries.filter(e => e.status === 'OK').length;
const failCount = entries.filter(e => e.status === 'FAIL').length;
const totalElapsed = entries.reduce((sum, e) => sum + e.elapsed_ms, 0);
return (
<div className="space-y-4">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ScrollText className="w-4 h-4 text-sky-600" />
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
</h3>
</div>
{onClose && (
<button
onClick={onClose}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
>
</button>
)}
</div>
{/* 汇总 */}
{entries.length > 0 && (
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-emerald-700">{okCount}</div>
<div className="text-[9px] font-bold text-emerald-500"></div>
</div>
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-rose-700">{failCount}</div>
<div className="text-[9px] font-bold text-rose-500"></div>
</div>
<div className="rounded-lg bg-slate-50 border border-slate-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-slate-700">{(totalElapsed / 1000).toFixed(1)}s</div>
<div className="text-[9px] font-bold text-slate-500"></div>
</div>
</div>
)}
{/* 加载/错误状态 */}
{loading && (
<div className="flex items-center justify-center py-8 text-slate-400 gap-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-xs font-bold">...</span>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-rose-600 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2 text-xs font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
{error}
</div>
)}
{/* 日志表格 */}
{!loading && entries.length === 0 && !error && (
<p className="text-xs text-slate-400 italic py-8 text-center">
</p>
)}
{entries.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-slate-200 text-left">
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-10"></th>
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider"></th>
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-12"></th>
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-16 text-right"></th>
<th className="pb-2 font-extrabold text-slate-400 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody>
{entries.map(entry => {
const isExpanded = expandedPreview[entry.id] || false;
const hasPreview = entry.output_preview && entry.output_preview.length > 0;
return (
<tr
key={entry.id}
className={`border-b border-slate-100 ${
entry.status === 'FAIL' ? 'bg-rose-50/30' : ''
}`}
>
<td className="py-2 pr-2 font-mono text-slate-500 align-top">
#{entry.step}
</td>
<td className="py-2 pr-2 font-semibold text-slate-700 align-top">
{getToolDisplayName(entry.tool_name)}
{entry.tool_name && (
<span className="text-[9px] text-slate-400 font-mono block">
{entry.tool_name}
</span>
)}
</td>
<td className="py-2 pr-2 align-top">
{entry.status === 'OK' ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold text-[9px]">
<CheckCircle2 className="w-2.5 h-2.5" />
OK
</span>
) : entry.status === 'FAIL' ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold text-[9px]">
<XCircle className="w-2.5 h-2.5" />
FAIL
</span>
) : (
<span className="text-slate-400 text-[9px]">{entry.status}</span>
)}
</td>
<td className="py-2 pr-2 text-right font-mono text-slate-500 align-top">
<span className="flex items-center gap-0.5 justify-end">
<Clock className="w-2.5 h-2.5" />
{entry.elapsed_ms >= 1000
? `${(entry.elapsed_ms / 1000).toFixed(1)}s`
: `${entry.elapsed_ms}ms`}
</span>
</td>
<td className="py-2 align-top">
{hasPreview ? (
<div>
{isExpanded ? (
<div className="space-y-1">
<pre className="font-mono text-[9px] text-slate-600 bg-slate-50 border border-slate-200 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap leading-relaxed">
{entry.output_preview}
</pre>
<button
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: false }))}
className="text-[9px] font-bold text-sky-600 hover:underline cursor-pointer flex items-center gap-0.5"
>
<ChevronUp className="w-2.5 h-2.5" />
</button>
</div>
) : (
<button
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: true }))}
className="text-left font-mono text-[9px] text-slate-500 hover:text-sky-600 transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]"
>
<ChevronDown className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{(entry.output_preview ?? '').slice(0, 60)}{(entry.output_preview ?? '').length > 60 ? '...' : ''}</span>
</button>
)}
</div>
) : (
<span className="text-slate-400 italic text-[9px]"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}