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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user