后端核心变更: - 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 (文献信息渲染)
228 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|