feat: Agent 思考模式前端可控、子代理全链路持久化、权限系统、工具 ID 追踪体系、前端面板与文档架构重构

- AgentConfig/LlmClient 新增 enable_thinking 参数,前端 SSE 请求传递 thinking
  开关,仅千问/DashScope 时启用
  - 完善权限系统,支持细粒度的权限控制和用户权限申请
  - delegate_research 工具重命名为 subagent,SubAgentTool/SubAgentRunner 重构
  - 子代理消息(system/user/assistant/tool)持久化到 agent_messages 表,带 agent_name 标识
  - 子代理活动日志(工具调用列表+思考摘要)注入返回结果,Hooks 获得正确 session_id 和 subagent_name
  - LLM 工具调用 ID 回退生成 UUID(llm.rs),ToolCall/ToolResult SSE 事件增加 id/tool_call_id 双字段
  - ToolContext 扩展 session_id/sse_tx/enable_thinking 字段,executor 统一注入而非构造函数传参
  - agent_messages 新增 metadata+raw_json 列,agent_sessions 暴露 summary 字段
  - 删除文件级 transcript 快照(compact.rs),改为依赖 DB 持久化
  - ResearchAgentPanel 重写:TimelineItem 类型替代 StreamStep,支持会话历史回放
  - 新增 AgentMetricsPanel/AskUserQuestionCard/AuditLogViewer 三个前端组件,types.ts 完整类型定义
  - docs/architecture/ 分层重组:概览/核心模块/核心工作流 + agent/ 子目录 11 篇专题文档
  - docs/api.md 补充 RAG/Target/Agent 接口,docs/development.md 新建开发指南
  - .env.example 完全重写,补充 FALLBACK_MODEL 等变量说明
This commit is contained in:
fmq
2026-06-18 01:21:02 +08:00
parent 49784739fa
commit f6df9d8136
60 changed files with 9913 additions and 1844 deletions
@@ -0,0 +1,271 @@
// dashboard/src/features/agent/AgentMetricsPanel.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { BarChart3, Activity, AlertTriangle, Zap, Brain, RefreshCw, Loader } from 'lucide-react';
import type { AgentMetricsResponse } from '../../types';
interface AgentMetricsPanelProps {
showAlert?: (message: string, title?: string) => void;
}
// 工具名到中文显示名的映射
const TOOL_LABELS: 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: '收件箱检查',
};
// 工具调用的分类色
const CATEGORY_COLORS: Record<string, string> = {
read_file: 'bg-blue-100 text-blue-700 border-blue-200',
grep_files: 'bg-blue-100 text-blue-700 border-blue-200',
glob_files: 'bg-blue-100 text-blue-700 border-blue-200',
run_bash: 'bg-slate-200 text-slate-700 border-slate-300',
file_write: 'bg-blue-100 text-blue-700 border-blue-200',
file_edit: 'bg-blue-100 text-blue-700 border-blue-200',
search_papers: 'bg-emerald-100 text-emerald-700 border-emerald-200',
get_paper_metadata: 'bg-emerald-100 text-emerald-700 border-emerald-200',
download_paper: 'bg-emerald-100 text-emerald-700 border-emerald-200',
parse_paper: 'bg-emerald-100 text-emerald-700 border-emerald-200',
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',
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',
load_skill: 'bg-indigo-100 text-indigo-700 border-indigo-200',
subagent: 'bg-purple-100 text-purple-700 border-purple-200',
delegate_research: 'bg-purple-100 text-purple-700 border-purple-200',
ask_user: 'bg-amber-100 text-amber-700 border-amber-200',
save_memory: 'bg-pink-100 text-pink-700 border-pink-200',
load_memory: 'bg-pink-100 text-pink-700 border-pink-200',
bg_task_run: 'bg-cyan-100 text-cyan-700 border-cyan-200',
bg_task_check: 'bg-cyan-100 text-cyan-700 border-cyan-200',
spawn_teammate: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
send_teammate_message: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
team_broadcast: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
check_team_inbox: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
};
export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
const [metrics, setMetrics] = useState<AgentMetricsResponse | null>(null);
const [loading, setLoading] = useState(false);
const fetchMetrics = async () => {
setLoading(true);
try {
const res = await axios.get<AgentMetricsResponse>('/api/chat/metrics');
setMetrics(res.data);
} catch (e) {
console.error('获取智能体指标失败:', e);
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMetrics();
}, []);
// 提取工具调用排行(取前10
const toolBreakdown = metrics?.tool_call_breakdown
? Object.entries(metrics.tool_call_breakdown)
.sort(([, a], [, b]) => b - a)
.slice(0, 15)
: [];
const maxToolCalls = toolBreakdown.length > 0 ? toolBreakdown[0][1] : 1;
return (
<div className="space-y-5">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-sky-600" />
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
</h3>
</div>
<button
onClick={fetchMetrics}
disabled={loading}
className="p-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer disabled:opacity-50"
title="刷新指标"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading && !metrics ? (
<div className="flex items-center justify-center py-12 text-slate-400 gap-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-xs font-bold">...</span>
</div>
) : metrics ? (
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<MetricCard
icon={<Brain className="w-4 h-4" />}
label="总会话数"
value={metrics.total_sessions}
color="sky"
/>
<MetricCard
icon={<Zap className="w-4 h-4" />}
label="总工具调用"
value={metrics.total_tool_calls}
color="emerald"
/>
<MetricCard
icon={<Activity className="w-4 h-4" />}
label="平均步数/会话"
value={metrics.avg_steps_per_session.toFixed(1)}
color="violet"
/>
<MetricCard
icon={<AlertTriangle className="w-4 h-4" />}
label="错误率"
value={`${(metrics.error_rate * 100).toFixed(1)}%`}
color={metrics.error_rate > 0.1 ? 'rose' : 'emerald'}
/>
</div>
{/* 工具调用分布 */}
<div className="space-y-2.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
</span>
{toolBreakdown.length === 0 ? (
<p className="text-xs text-slate-400 italic py-4 text-center">
</p>
) : (
<div className="space-y-1.5">
{toolBreakdown.map(([name, count]) => {
const barWidth = Math.max((count / maxToolCalls) * 100, 2);
const colorClass = CATEGORY_COLORS[name] || 'bg-slate-100 text-slate-700 border-slate-200';
const label = TOOL_LABELS[name] || name;
return (
<div key={name} className="flex items-center gap-2.5">
<span className="text-[10px] text-slate-500 w-24 shrink-0 text-right font-medium truncate" title={label}>
{label}
</span>
<div className="flex-1 h-5 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
<div
className={`h-full rounded-full transition-all duration-500 ${colorClass.split(' ')[0]}`}
style={{ width: `${barWidth}%` }}
/>
</div>
<span className="text-[10px] font-bold text-slate-600 w-8 text-right shrink-0">
{count}
</span>
</div>
);
})}
</div>
)}
</div>
{/* 工具分类统计 */}
<div className="border-t border-slate-200 pt-3">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">
</span>
<div className="flex flex-wrap gap-1.5">
{Object.entries(getCategoryCounts(metrics.tool_call_breakdown)).map(([category, count]) => (
<span
key={category}
className="px-2.5 py-1 rounded-lg text-[10px] font-bold border bg-slate-50 text-slate-600 border-slate-200"
>
{category}: {count}
</span>
))}
</div>
</div>
</>
) : null}
</div>
);
}
// 小指标卡片
function MetricCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
color: string;
}) {
const colorMap: Record<string, string> = {
sky: 'border-sky-200 bg-sky-50 text-sky-700',
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700',
violet: 'border-violet-200 bg-violet-50 text-violet-700',
rose: 'border-rose-200 bg-rose-50 text-rose-700',
amber: 'border-amber-200 bg-amber-50 text-amber-700',
};
return (
<div className={`rounded-xl border p-3.5 ${colorMap[color] || colorMap.sky} transition-all`}>
<div className="flex items-center gap-1.5 mb-1.5">
<span className="opacity-60">{icon}</span>
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
</div>
<div className="text-lg font-extrabold tracking-tight">
{value}
</div>
</div>
);
}
// 按功能域分组统计
function getCategoryCounts(breakdown: Record<string, number>): Record<string, number> {
const categories: Record<string, string[]> = {
'文件系统': ['read_file', 'grep_files', 'glob_files', 'run_bash', 'file_write', 'file_edit'],
'文献科研': ['search_papers', 'get_paper_metadata', 'download_paper', 'parse_paper', 'get_paper_content'],
'RAG/天体': ['rag_search', 'query_target', 'save_note'],
'Agent控制': ['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'],
};
const result: Record<string, number> = {};
for (const [cat, tools] of Object.entries(categories)) {
const total = tools.reduce((sum, t) => sum + (breakdown[t] || 0), 0);
if (total > 0) {
result[cat] = total;
}
}
return result;
}
@@ -0,0 +1,293 @@
// dashboard/src/features/agent/AskUserQuestionCard.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { MessageCircle, Send, X, Loader, CheckSquare, Square } from 'lucide-react';
import type { PendingQuestion } from '../../types';
interface AskUserQuestionCardProps {
onAnswered?: () => void;
}
export function AskUserQuestionCard({ onAnswered }: 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);
};
}, []);
// 切换选项:使用 option.label 而非整个对象
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: any) {
console.error('提交答案失败:', e);
const msg = e.response?.status === 410
? '该问题已超时或已被回答'
: e.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="space-y-3">
{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="bg-amber-50 border-2 border-amber-300 rounded-xl shadow-lg overflow-hidden transition-all"
>
{/* 头部 — 显示 header 标签和问题摘要 */}
<button
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
className="w-full flex items-center justify-between px-4 py-3 bg-amber-100/50 hover:bg-amber-100 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-left min-w-0">
<MessageCircle className="w-4 h-4 text-amber-600 shrink-0" />
<span className="px-1.5 py-0.5 rounded bg-amber-200 border border-amber-300 text-[10px] font-extrabold text-amber-800 shrink-0">
{q.header || '提问'}
</span>
{!isExpanded && (
<span className="text-[10px] text-amber-700 font-medium truncate">
{q.question || ''}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!isExpanded && (
<span className="text-[9px] text-amber-500 font-bold px-1.5 py-0.5 bg-amber-100 rounded">
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); dismissQuestion(q.question_id); }}
className="p-1 rounded-md text-amber-400 hover:text-amber-600 hover:bg-amber-200 transition-colors cursor-pointer"
title="忽略此问题"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</button>
{/* 内容 */}
{isExpanded && (
<div className="px-4 py-3 space-y-3">
{/* 问题文本 */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-amber-700 uppercase tracking-wider">
</span>
<p className="text-xs text-slate-800 font-semibold leading-relaxed bg-white rounded-lg p-3 border border-amber-200">
{q.question || ''}
</p>
</div>
{/* 选项列表 — 每个选项是 {label, description} 对象 */}
{options.length > 0 && (
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{multiSelect ? '可多选' : '请选择一项'}
</span>
<div className="space-y-1.5">
{options.map((option, idx) => {
// 防御:确保 option 是有效对象
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-3 py-2.5 rounded-lg border text-xs font-medium transition-all cursor-pointer flex items-start gap-2.5 ${
selected
? 'bg-sky-50 border-sky-300 text-sky-800'
: 'bg-white border-slate-200 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50'
} disabled:opacity-50`}
>
{/* 选择指示器 */}
{multiSelect ? (
selected
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0 mt-0.5" />
: <Square className="w-3.5 h-3.5 text-slate-400 shrink-0 mt-0.5" />
) : (
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 mt-0.5 ${
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-300'
}`}>
{selected && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white" />
</div>
)}
</div>
)}
{/* label + description */}
<div className="min-w-0">
<div className="font-semibold text-slate-800">{label}</div>
{desc && (
<div className="text-[10px] text-slate-500 mt-0.5 leading-relaxed">
{desc}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
)}
{/* 自由文本 */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
</span>
<textarea
value={freeText[q.question_id] || ''}
onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))}
disabled={isSubmitting}
placeholder="输入您的补充说明..."
rows={2}
className="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/20 resize-none disabled:opacity-50"
/>
</div>
{/* 错误提示(按问题 ID 隔离) */}
{qError && (
<div className="text-[10px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 font-medium">
{qError}
</div>
)}
{/* 提交按钮 */}
<div className="flex gap-2 pt-1">
<button
onClick={() => handleSubmit(q.question_id)}
disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg py-2.5 text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader className="w-3.5 h-3.5 animate-spin" />
<span>...</span>
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
<span></span>
</>
)}
</button>
<button
onClick={() => dismissQuestion(q.question_id)}
disabled={isSubmitting}
className="px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
>
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,227 @@
// 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>
);
}
@@ -0,0 +1,129 @@
// dashboard/src/features/agent/PermissionRequestCard.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Shield, Check, X, Loader } from 'lucide-react';
import type { PendingPermissionRequest } from '../../types';
interface PermissionRequestCardProps {
sessionId: string;
}
/** 显示待处理的工具执行权限请求卡片 */
export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps) {
const [pending, setPending] = useState<PendingPermissionRequest[]>([]);
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
// 轮询待处理权限请求
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
const poll = async () => {
try {
const res = await axios.get<PendingPermissionRequest[]>(
`/api/chat/sessions/${sessionId}/permissions`,
);
if (!cancelled) {
const data = Array.isArray(res.data) ? res.data : [];
setPending(data);
}
} catch {
// 静默失败,等待下次轮询
}
};
poll();
const interval = setInterval(poll, 2000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId]);
const respond = async (
toolCallId: string,
allowed: boolean,
allowAlways: boolean,
) => {
setSubmitting(prev => ({ ...prev, [toolCallId]: true }));
try {
await axios.post(
`/api/chat/sessions/${sessionId}/permissions/respond`,
{ tool_call_id: toolCallId, allowed, allow_always: allowAlways },
);
setResponses(prev => ({
...prev,
[toolCallId]: allowed ? 'allow' : 'deny',
}));
} catch (err) {
console.error('权限响应发送失败:', err);
} finally {
setSubmitting(prev => ({ ...prev, [toolCallId]: false }));
}
};
// 不显示已处理的请求
const activeRequests = pending.filter(p => !responses[p.tool_call_id]);
if (activeRequests.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{activeRequests.map(req => (
<div
key={req.permission_id}
className="rounded-lg border border-amber-500/30 bg-amber-950/20 p-4"
>
<div className="mb-2 flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-400" />
<span className="font-semibold text-amber-300"></span>
<code className="rounded bg-amber-900/40 px-1.5 py-0.5 text-xs text-amber-200">
{req.tool_name}
</code>
</div>
<p className="mb-3 text-sm text-amber-200/80">{req.message}</p>
{/* 显示工具参数的简化预览 */}
{Object.keys(req.arguments).length > 0 && (
<pre className="mb-3 max-h-24 overflow-auto rounded bg-black/30 p-2 text-xs text-amber-300/60">
{JSON.stringify(req.arguments, null, 2)}
</pre>
)}
<div className="flex gap-2">
<button
onClick={() => respond(req.tool_call_id, true, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-emerald-600 px-3 py-1.5 text-sm text-white transition hover:bg-emerald-500 disabled:opacity-50"
>
{submitting[req.tool_call_id] ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
Allow
</button>
<button
onClick={() => respond(req.tool_call_id, true, true)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-emerald-700 px-3 py-1.5 text-sm text-emerald-200 transition hover:bg-emerald-600 disabled:opacity-50"
>
Always Allow
</button>
<button
onClick={() => respond(req.tool_call_id, false, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-red-600 px-3 py-1.5 text-sm text-white transition hover:bg-red-500 disabled:opacity-50"
>
<X className="h-4 w-4" />
Deny
</button>
</div>
</div>
))}
</div>
);
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -50,3 +50,125 @@ export interface SavedSyncQuery {
limit_count: number;
last_run: string;
}
// ── Agent 相关类型 ──
export interface SessionSummary {
session_id: string;
title: string;
model: string;
turn_count: number;
created_at: string;
updated_at: string;
}
export interface MessageRecord {
id: number;
agent_name: string;
turn_index: number;
step_index: number;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
thought?: string | null;
tool_calls?: ToolCall[] | null;
tool_call_id?: string | null;
token_count: number;
metadata?: any | null;
created_at: string;
}
export interface ToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string; // JSON string
};
}
export interface SessionDetail {
session: SessionSummary;
messages: MessageRecord[];
}
// ── Agent 指标 ──
export interface AgentMetricsResponse {
total_sessions: number;
total_tool_calls: number;
tool_call_breakdown: Record<string, number>;
avg_steps_per_session: number;
error_rate: number;
}
// ── 审计日志 ──
export interface AuditLogEntry {
id: number;
step: number;
tool_name: string | null;
status: string;
elapsed_ms: number;
output_preview: string | null;
created_at: string;
}
// ── 交互式问答 (ask_user 工具) ──
export interface UserOption {
label: string;
description: string;
}
export interface PendingQuestion {
question_id: string;
question: string;
header: string;
options: UserOption[];
multi_select: boolean;
}
export interface AnswerQuestionRequest {
question_id: string;
answers: string[];
free_text?: string;
}
// ── 权限请求 (Permission Checker) ──
export interface PendingPermissionRequest {
permission_id: string;
tool_call_id: string;
tool_name: string;
message: string;
arguments: Record<string, unknown>;
}
export interface PermissionResponseRequest {
tool_call_id: string;
allowed: boolean;
allow_always: boolean;
}
// ── 项目记忆 ──
export interface MemoryEntry {
slug: string;
name: string;
description: string;
memory_type: string;
content: string;
created_at: string;
updated_at: string;
}
// ── Agent 任务板 ──
export interface AgentTask {
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
dependencies: string[];
created_at: string;
updated_at: string;
}