feat(analytics): add Agent usage analytics to admin panel
Add full-stack Agent execution tracking and analytics:
**Database (conversation-service)**
- New `agent_executions` table: tracks each specialist Agent invocation
with agentType, agentName, durationMs, success, tenantId
- Migration: AddAgentExecutionsTable1738800000000
- ORM entity: AgentExecutionORM with indexes on tenant, conversation,
agentType, createdAt, and (tenant+date) composite
**Data Capture (conversation-service)**
- conversation.service.ts: captures `agent_start` and `agent_complete`
StreamChunk events in the sendMessage() async generator loop
- Persists agent execution records to DB after each message completes
- Non-blocking: agent persistence failures are logged but don't break
the main conversation flow
**Admin API (conversation-service)**
- GET /conversations/admin/statistics/agents?days=30
Aggregated stats per agent type: totalCalls, successCount, failureCount,
successRate, avgDurationMs, min/max duration
- GET /conversations/admin/statistics/agents/trend?days=7&agentType=
Daily trend data: date, agentType, calls, avgDurationMs, successRate
- GET /conversations/admin/:id/agent-executions
Per-conversation agent execution records ordered by createdAt
**Admin Client - Analytics Page**
- New AgentAnalyticsTab component with:
- 4 summary cards (total calls, success rate, avg duration, top agent)
- Agent statistics table (Ant Design Table with sortable columns,
color-coded Tags, Progress bar for success rate)
- Stacked bar trend chart (Recharts BarChart, color per agent type)
- Time range selectors (7/14/30/90 days)
- Added as third tab "Agent 使用分析" in AnalyticsPage dimension tabs
**Admin Client - Conversations Page**
- Added "Agent 使用详情" section to conversation detail drawer
(between Token Usage and Messages sections)
- Shows per-conversation agent execution table with agent name (color Tag),
duration, success/failure status, and timestamp
- Empty state: "暂无 Agent 使用记录"
Agent color mapping: policy_expert=#1890ff, assessment_expert=#52c41a,
strategist=#722ed1, objection_handler=#eb2f96, case_analyst=#faad14,
memory_manager=#13c2c2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
16cc0e4c08
commit
691a3523e8
|
|
@ -0,0 +1,286 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Progress,
|
||||
} from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
useAgentStatistics,
|
||||
useAgentTrend,
|
||||
type AgentStatisticsDto,
|
||||
} from '../../../conversations/application/useConversations';
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
policy_expert: '#1890ff',
|
||||
assessment_expert: '#52c41a',
|
||||
strategist: '#722ed1',
|
||||
objection_handler: '#eb2f96',
|
||||
case_analyst: '#faad14',
|
||||
memory_manager: '#13c2c2',
|
||||
};
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
policy_expert: '政策专家',
|
||||
assessment_expert: '评估专家',
|
||||
strategist: '策略顾问',
|
||||
objection_handler: '异议处理',
|
||||
case_analyst: '案例分析',
|
||||
memory_manager: '记忆管理',
|
||||
};
|
||||
|
||||
export function AgentAnalyticsTab() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [trendDays, setTrendDays] = useState(7);
|
||||
|
||||
const { data: agentStats, isLoading: loadingStats } = useAgentStatistics(days);
|
||||
const { data: trendData, isLoading: loadingTrend } = useAgentTrend(trendDays);
|
||||
|
||||
// Summary calculations
|
||||
const summary = useMemo(() => {
|
||||
if (!agentStats || agentStats.length === 0) {
|
||||
return { totalCalls: 0, overallSuccessRate: 0, avgDuration: 0, topAgent: '-' };
|
||||
}
|
||||
const totalCalls = agentStats.reduce((sum, a) => sum + a.totalCalls, 0);
|
||||
const totalSuccess = agentStats.reduce((sum, a) => sum + a.successCount, 0);
|
||||
const weightedDuration = agentStats.reduce((sum, a) => sum + a.avgDurationMs * a.totalCalls, 0);
|
||||
const topAgent = agentStats.reduce((top, a) => (a.totalCalls > top.totalCalls ? a : top), agentStats[0]);
|
||||
|
||||
return {
|
||||
totalCalls,
|
||||
overallSuccessRate: totalCalls > 0 ? parseFloat(((totalSuccess / totalCalls) * 100).toFixed(1)) : 0,
|
||||
avgDuration: totalCalls > 0 ? Math.round(weightedDuration / totalCalls) : 0,
|
||||
topAgent: AGENT_LABELS[topAgent.agentType] || topAgent.agentName,
|
||||
};
|
||||
}, [agentStats]);
|
||||
|
||||
// Transform trend data for chart: pivot by date
|
||||
const chartData = useMemo(() => {
|
||||
if (!trendData || trendData.length === 0) return [];
|
||||
const dateMap = new Map<string, Record<string, number>>();
|
||||
for (const item of trendData) {
|
||||
const dateStr = typeof item.date === 'string' ? item.date.slice(0, 10) : String(item.date);
|
||||
if (!dateMap.has(dateStr)) {
|
||||
dateMap.set(dateStr, { date: dateStr as unknown as number });
|
||||
}
|
||||
const entry = dateMap.get(dateStr)!;
|
||||
entry[item.agentType] = item.calls;
|
||||
}
|
||||
return Array.from(dateMap.values()).map(entry => ({
|
||||
...entry,
|
||||
date: String(entry.date).slice(5), // MM-DD format
|
||||
}));
|
||||
}, [trendData]);
|
||||
|
||||
// Get unique agent types from trend data
|
||||
const trendAgentTypes = useMemo(() => {
|
||||
if (!trendData) return [];
|
||||
return [...new Set(trendData.map(d => d.agentType))];
|
||||
}, [trendData]);
|
||||
|
||||
const columns: ColumnsType<AgentStatisticsDto> = [
|
||||
{
|
||||
title: 'Agent',
|
||||
dataIndex: 'agentType',
|
||||
key: 'agentType',
|
||||
render: (type: string, record) => (
|
||||
<Tag color={AGENT_COLORS[type] || 'blue'}>
|
||||
{AGENT_LABELS[type] || record.agentName}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '调用次数',
|
||||
dataIndex: 'totalCalls',
|
||||
key: 'totalCalls',
|
||||
sorter: (a, b) => a.totalCalls - b.totalCalls,
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: '成功率',
|
||||
dataIndex: 'successRate',
|
||||
key: 'successRate',
|
||||
render: (rate: number) => (
|
||||
<Progress
|
||||
percent={rate}
|
||||
size="small"
|
||||
status={rate >= 95 ? 'success' : rate >= 80 ? 'normal' : 'exception'}
|
||||
format={(p) => `${p}%`}
|
||||
/>
|
||||
),
|
||||
sorter: (a, b) => a.successRate - b.successRate,
|
||||
},
|
||||
{
|
||||
title: '平均耗时',
|
||||
dataIndex: 'avgDurationMs',
|
||||
key: 'avgDurationMs',
|
||||
render: (ms: number) => `${(ms / 1000).toFixed(2)}s`,
|
||||
sorter: (a, b) => a.avgDurationMs - b.avgDurationMs,
|
||||
},
|
||||
{
|
||||
title: '最短',
|
||||
dataIndex: 'minDurationMs',
|
||||
key: 'minDurationMs',
|
||||
render: (ms: number) => `${(ms / 1000).toFixed(2)}s`,
|
||||
},
|
||||
{
|
||||
title: '最长',
|
||||
dataIndex: 'maxDurationMs',
|
||||
key: 'maxDurationMs',
|
||||
render: (ms: number) => `${(ms / 1000).toFixed(2)}s`,
|
||||
},
|
||||
{
|
||||
title: '失败',
|
||||
dataIndex: 'failureCount',
|
||||
key: 'failureCount',
|
||||
render: (count: number) =>
|
||||
count > 0 ? <Tag color="red">{count}</Tag> : <span className="text-gray-300">0</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="总调用次数"
|
||||
value={summary.totalCalls}
|
||||
prefix={<RobotOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="整体成功率"
|
||||
value={summary.overallSuccessRate}
|
||||
suffix="%"
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: summary.overallSuccessRate >= 95 ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="平均耗时"
|
||||
value={(summary.avgDuration / 1000).toFixed(2)}
|
||||
suffix="s"
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="最常用 Agent"
|
||||
value={summary.topAgent}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
valueStyle={{ color: '#eb2f96', fontSize: 18 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Agent Statistics Table */}
|
||||
<Card
|
||||
title="Agent 调用统计"
|
||||
className="mb-4"
|
||||
extra={
|
||||
<Space>
|
||||
<span>时间范围:</span>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
options={[
|
||||
{ value: 7, label: '7天' },
|
||||
{ value: 14, label: '14天' },
|
||||
{ value: 30, label: '30天' },
|
||||
{ value: 90, label: '90天' },
|
||||
]}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={agentStats || []}
|
||||
rowKey="agentType"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Trend Chart */}
|
||||
<Card
|
||||
title="Agent 调用趋势"
|
||||
extra={
|
||||
<Space>
|
||||
<span>时间跨度:</span>
|
||||
<Select
|
||||
value={trendDays}
|
||||
onChange={setTrendDays}
|
||||
options={[
|
||||
{ value: 7, label: '7天' },
|
||||
{ value: 14, label: '14天' },
|
||||
{ value: 30, label: '30天' },
|
||||
]}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loadingTrend}>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{trendAgentTypes.map((agentType) => (
|
||||
<Bar
|
||||
key={agentType}
|
||||
dataKey={agentType}
|
||||
name={AGENT_LABELS[agentType] || agentType}
|
||||
fill={AGENT_COLORS[agentType] || '#8884d8'}
|
||||
stackId="agents"
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ import {
|
|||
useBackfillStatistics,
|
||||
type DailyStatisticsDto,
|
||||
} from '../../application';
|
||||
import { AgentAnalyticsTab } from '../components/AgentAnalyticsTab';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
|
@ -352,6 +353,11 @@ export function AnalyticsPage() {
|
|||
</Spin>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agents',
|
||||
label: 'Agent 使用分析',
|
||||
children: <AgentAnalyticsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import {
|
|||
MessageDto,
|
||||
PaginatedConversations,
|
||||
ConversationStatistics,
|
||||
AgentExecutionDto,
|
||||
AgentStatisticsDto,
|
||||
AgentTrendDto,
|
||||
} from '../infrastructure/conversations.api';
|
||||
|
||||
// Conversation list query
|
||||
|
|
@ -53,6 +56,31 @@ export function useConversationStatistics() {
|
|||
});
|
||||
}
|
||||
|
||||
// Agent executions for a specific conversation
|
||||
export function useConversationAgentExecutions(id: string, enabled = true) {
|
||||
return useQuery<AgentExecutionDto[]>({
|
||||
queryKey: ['conversations', 'agent-executions', id],
|
||||
queryFn: () => conversationsApi.getConversationAgentExecutions(id),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Global agent statistics
|
||||
export function useAgentStatistics(days = 30) {
|
||||
return useQuery<AgentStatisticsDto[]>({
|
||||
queryKey: ['agent-statistics', days],
|
||||
queryFn: () => conversationsApi.getAgentStatistics(days),
|
||||
});
|
||||
}
|
||||
|
||||
// Agent trend data
|
||||
export function useAgentTrend(days = 7, agentType?: string) {
|
||||
return useQuery<AgentTrendDto[]>({
|
||||
queryKey: ['agent-trend', days, agentType],
|
||||
queryFn: () => conversationsApi.getAgentTrend(days, agentType),
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
ConversationDto,
|
||||
|
|
@ -66,4 +94,7 @@ export type {
|
|||
TokenDetails,
|
||||
GlobalTokenStats,
|
||||
TodayTokenStats,
|
||||
AgentExecutionDto,
|
||||
AgentStatisticsDto,
|
||||
AgentTrendDto,
|
||||
} from '../infrastructure/conversations.api';
|
||||
|
|
|
|||
|
|
@ -119,6 +119,40 @@ export interface ConversationQueryParams {
|
|||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
// ==================== Agent Analytics DTOs ====================
|
||||
|
||||
export interface AgentExecutionDto {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
agentType: string;
|
||||
agentName: string;
|
||||
durationMs: number;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
toolCallsCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentStatisticsDto {
|
||||
agentType: string;
|
||||
agentName: string;
|
||||
totalCalls: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
minDurationMs: number;
|
||||
maxDurationMs: number;
|
||||
}
|
||||
|
||||
export interface AgentTrendDto {
|
||||
date: string;
|
||||
agentType: string;
|
||||
calls: number;
|
||||
avgDurationMs: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
|
||||
export const conversationsApi = {
|
||||
|
|
@ -153,4 +187,26 @@ export const conversationsApi = {
|
|||
const response = await api.get('/conversations/admin/statistics/overview');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get agent executions for a conversation
|
||||
getConversationAgentExecutions: async (id: string): Promise<AgentExecutionDto[]> => {
|
||||
const response = await api.get(`/conversations/admin/${id}/agent-executions`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get agent statistics (aggregated)
|
||||
getAgentStatistics: async (days = 30): Promise<AgentStatisticsDto[]> => {
|
||||
const response = await api.get('/conversations/admin/statistics/agents', {
|
||||
params: { days: days.toString() },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get agent usage trend
|
||||
getAgentTrend: async (days = 7, agentType?: string): Promise<AgentTrendDto[]> => {
|
||||
const response = await api.get('/conversations/admin/statistics/agents/trend', {
|
||||
params: { days: days.toString(), agentType },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
DollarOutlined,
|
||||
ApiOutlined,
|
||||
ThunderboltOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -33,10 +34,12 @@ import {
|
|||
useConversationStatistics,
|
||||
useConversationDetail,
|
||||
useConversationMessages,
|
||||
useConversationAgentExecutions,
|
||||
type ConversationDto,
|
||||
type ConversationStatus,
|
||||
type ConversationQueryParams,
|
||||
type MessageDto,
|
||||
type AgentExecutionDto,
|
||||
} from '../../application';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
|
@ -77,6 +80,10 @@ export function ConversationsPage() {
|
|||
selectedConversationId || '',
|
||||
!!selectedConversationId && detailDrawerOpen
|
||||
);
|
||||
const { data: agentExecutions, isLoading: loadingAgentExecs } = useConversationAgentExecutions(
|
||||
selectedConversationId || '',
|
||||
!!selectedConversationId && detailDrawerOpen
|
||||
);
|
||||
|
||||
const showConversationDetail = (conv: ConversationDto) => {
|
||||
setSelectedConversationId(conv.id);
|
||||
|
|
@ -555,6 +562,72 @@ export function ConversationsPage() {
|
|||
)}
|
||||
</Row>
|
||||
|
||||
{/* Agent Usage */}
|
||||
<Title level={5}>
|
||||
<Space>
|
||||
<RobotOutlined />
|
||||
Agent 使用详情
|
||||
</Space>
|
||||
</Title>
|
||||
<Spin spinning={loadingAgentExecs}>
|
||||
{agentExecutions && agentExecutions.length > 0 ? (
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={agentExecutions}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
className="mb-4"
|
||||
columns={[
|
||||
{
|
||||
title: 'Agent',
|
||||
dataIndex: 'agentName',
|
||||
key: 'agentName',
|
||||
render: (_: string, record: AgentExecutionDto) => {
|
||||
const colors: Record<string, string> = {
|
||||
policy_expert: '#1890ff',
|
||||
assessment_expert: '#52c41a',
|
||||
strategist: '#722ed1',
|
||||
objection_handler: '#eb2f96',
|
||||
case_analyst: '#faad14',
|
||||
memory_manager: '#13c2c2',
|
||||
};
|
||||
return (
|
||||
<Tag color={colors[record.agentType] || 'blue'}>
|
||||
{record.agentName}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationMs',
|
||||
key: 'durationMs',
|
||||
render: (ms: number) => `${(ms / 1000).toFixed(2)}s`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'success',
|
||||
key: 'success',
|
||||
render: (success: boolean) =>
|
||||
success
|
||||
? <Tag color="green">成功</Tag>
|
||||
: <Tag color="red">失败</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => dayjs(date).format('HH:mm:ss'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center py-4 mb-4">
|
||||
暂无 Agent 使用记录
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* Messages */}
|
||||
<Title level={5}>
|
||||
<Space>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import * as jwt from 'jsonwebtoken';
|
|||
import { ConversationORM } from '../../infrastructure/database/postgres/entities/conversation.orm';
|
||||
import { MessageORM } from '../../infrastructure/database/postgres/entities/message.orm';
|
||||
import { TokenUsageORM } from '../../infrastructure/database/postgres/entities/token-usage.orm';
|
||||
import { AgentExecutionORM } from '../../infrastructure/database/postgres/entities/agent-execution.orm';
|
||||
|
||||
interface AdminPayload {
|
||||
id: string;
|
||||
|
|
@ -42,6 +43,8 @@ export class AdminConversationController {
|
|||
private messageRepo: Repository<MessageORM>,
|
||||
@InjectRepository(TokenUsageORM)
|
||||
private tokenUsageRepo: Repository<TokenUsageORM>,
|
||||
@InjectRepository(AgentExecutionORM)
|
||||
private agentExecutionRepo: Repository<AgentExecutionORM>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -229,6 +232,106 @@ export class AdminConversationController {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 调用统计(按 agentType 分组聚合)
|
||||
*/
|
||||
@Get('statistics/agents')
|
||||
async getAgentStatistics(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('days') days?: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const daysNum = parseInt(days || '30');
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - daysNum);
|
||||
|
||||
const results = await this.agentExecutionRepo
|
||||
.createQueryBuilder('a')
|
||||
.select([
|
||||
'a.agent_type as "agentType"',
|
||||
'a.agent_name as "agentName"',
|
||||
'COUNT(*) as "totalCalls"',
|
||||
'SUM(CASE WHEN a.success THEN 1 ELSE 0 END) as "successCount"',
|
||||
'SUM(CASE WHEN NOT a.success THEN 1 ELSE 0 END) as "failureCount"',
|
||||
'ROUND(AVG(a.duration_ms)) as "avgDurationMs"',
|
||||
'MIN(a.duration_ms) as "minDurationMs"',
|
||||
'MAX(a.duration_ms) as "maxDurationMs"',
|
||||
])
|
||||
.where('a.created_at >= :since', { since })
|
||||
.groupBy('a.agent_type')
|
||||
.addGroupBy('a.agent_name')
|
||||
.orderBy('"totalCalls"', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: results.map((r) => ({
|
||||
agentType: r.agentType,
|
||||
agentName: r.agentName,
|
||||
totalCalls: parseInt(r.totalCalls || '0'),
|
||||
successCount: parseInt(r.successCount || '0'),
|
||||
failureCount: parseInt(r.failureCount || '0'),
|
||||
successRate:
|
||||
parseInt(r.totalCalls || '0') > 0
|
||||
? parseFloat(((parseInt(r.successCount || '0') / parseInt(r.totalCalls || '0')) * 100).toFixed(1))
|
||||
: 0,
|
||||
avgDurationMs: parseInt(r.avgDurationMs || '0'),
|
||||
minDurationMs: parseInt(r.minDurationMs || '0'),
|
||||
maxDurationMs: parseInt(r.maxDurationMs || '0'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 调用趋势(每日分组)
|
||||
*/
|
||||
@Get('statistics/agents/trend')
|
||||
async getAgentTrend(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('days') days?: string,
|
||||
@Query('agentType') agentType?: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const daysNum = parseInt(days || '7');
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - daysNum);
|
||||
|
||||
const qb = this.agentExecutionRepo
|
||||
.createQueryBuilder('a')
|
||||
.select([
|
||||
'DATE(a.created_at) as "date"',
|
||||
'a.agent_type as "agentType"',
|
||||
'COUNT(*) as "calls"',
|
||||
'ROUND(AVG(a.duration_ms)) as "avgDurationMs"',
|
||||
'ROUND(SUM(CASE WHEN a.success THEN 1 ELSE 0 END)::numeric / NULLIF(COUNT(*), 0) * 100, 1) as "successRate"',
|
||||
])
|
||||
.where('a.created_at >= :since', { since });
|
||||
|
||||
if (agentType) {
|
||||
qb.andWhere('a.agent_type = :agentType', { agentType });
|
||||
}
|
||||
|
||||
const results = await qb
|
||||
.groupBy('DATE(a.created_at)')
|
||||
.addGroupBy('a.agent_type')
|
||||
.orderBy('"date"', 'ASC')
|
||||
.addOrderBy('a.agent_type', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: results.map((r) => ({
|
||||
date: r.date,
|
||||
agentType: r.agentType,
|
||||
calls: parseInt(r.calls || '0'),
|
||||
avgDurationMs: parseInt(r.avgDurationMs || '0'),
|
||||
successRate: parseFloat(r.successRate || '0'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个对话详情
|
||||
* 包含从 token_usage 表聚合的准确 token 数据
|
||||
|
|
@ -322,6 +425,37 @@ export class AdminConversationController {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个对话的 Agent 执行记录
|
||||
*/
|
||||
@Get(':id/agent-executions')
|
||||
async getConversationAgentExecutions(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') conversationId: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const executions = await this.agentExecutionRepo.find({
|
||||
where: { conversationId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: executions.map((e) => ({
|
||||
id: e.id,
|
||||
conversationId: e.conversationId,
|
||||
agentType: e.agentType,
|
||||
agentName: e.agentName,
|
||||
durationMs: e.durationMs,
|
||||
success: e.success,
|
||||
errorMessage: e.errorMessage,
|
||||
toolCallsCount: e.toolCallsCount,
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话统计(包含 Token 汇总)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TenantContextService } from '@iconsulting/shared';
|
||||
import {
|
||||
ConversationEntity,
|
||||
ConversationStatus,
|
||||
|
|
@ -23,6 +26,7 @@ import {
|
|||
LegacyConversationContext as ConversationContext,
|
||||
StreamChunk,
|
||||
} from '../../infrastructure/agents/coordinator/coordinator-agent.service';
|
||||
import { AgentExecutionORM } from '../../infrastructure/database/postgres/entities/agent-execution.orm';
|
||||
|
||||
export interface CreateConversationParams {
|
||||
userId: string;
|
||||
|
|
@ -55,6 +59,9 @@ export class ConversationService {
|
|||
@Inject(MESSAGE_REPOSITORY)
|
||||
private readonly messageRepo: IMessageRepository,
|
||||
private readonly claudeAgentService: CoordinatorAgentService,
|
||||
@InjectRepository(AgentExecutionORM)
|
||||
private readonly agentExecutionRepo: Repository<AgentExecutionORM>,
|
||||
private readonly tenantContext: TenantContextService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -161,6 +168,13 @@ export class ConversationService {
|
|||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let streamError: Error | null = null;
|
||||
const agentExecutions: Array<{
|
||||
agentType: string;
|
||||
agentName: string;
|
||||
startedAt: number;
|
||||
durationMs?: number;
|
||||
success?: boolean;
|
||||
}> = [];
|
||||
|
||||
// Stream response from Claude (with attachments for multimodal support)
|
||||
try {
|
||||
|
|
@ -194,6 +208,20 @@ export class ConversationService {
|
|||
inputTokens = chunk.inputTokens || 0;
|
||||
outputTokens = chunk.outputTokens || 0;
|
||||
console.warn(`[ConversationService] Stream error, captured partial tokens: in=${inputTokens}, out=${outputTokens}`);
|
||||
} else if (chunk.type === 'agent_start' && chunk.agentType) {
|
||||
agentExecutions.push({
|
||||
agentType: chunk.agentType,
|
||||
agentName: chunk.agentName || chunk.agentType,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
} else if (chunk.type === 'agent_complete' && chunk.agentType) {
|
||||
const exec = agentExecutions.find(
|
||||
e => e.agentType === chunk.agentType && e.durationMs === undefined,
|
||||
);
|
||||
if (exec) {
|
||||
exec.durationMs = chunk.durationMs ?? (Date.now() - exec.startedAt);
|
||||
exec.success = chunk.success ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
|
|
@ -224,6 +252,26 @@ export class ConversationService {
|
|||
await this.messageRepo.save(assistantMessage);
|
||||
}
|
||||
|
||||
// Save agent execution records
|
||||
if (agentExecutions.length > 0) {
|
||||
try {
|
||||
const tenantId = this.tenantContext.getCurrentTenantId() || '';
|
||||
await this.agentExecutionRepo.save(
|
||||
agentExecutions.map(exec => this.agentExecutionRepo.create({
|
||||
tenantId,
|
||||
conversationId: params.conversationId,
|
||||
userId: params.userId,
|
||||
agentType: exec.agentType,
|
||||
agentName: exec.agentName,
|
||||
durationMs: exec.durationMs ?? 0,
|
||||
success: exec.success ?? true,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[ConversationService] Failed to save agent executions:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update conversation statistics (always update tokens, even on error)
|
||||
conversation.incrementMessageCount('user');
|
||||
if (fullResponse) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||
import { ConversationORM } from '../infrastructure/database/postgres/entities/conversation.orm';
|
||||
import { MessageORM } from '../infrastructure/database/postgres/entities/message.orm';
|
||||
import { TokenUsageORM } from '../infrastructure/database/postgres/entities/token-usage.orm';
|
||||
import { AgentExecutionORM } from '../infrastructure/database/postgres/entities/agent-execution.orm';
|
||||
import { ConversationPostgresRepository } from '../adapters/outbound/persistence/conversation-postgres.repository';
|
||||
import { MessagePostgresRepository } from '../adapters/outbound/persistence/message-postgres.repository';
|
||||
import { TokenUsagePostgresRepository } from '../adapters/outbound/persistence/token-usage-postgres.repository';
|
||||
|
|
@ -16,7 +17,7 @@ import { AdminConversationController } from '../adapters/inbound/admin-conversat
|
|||
import { ConversationGateway } from '../adapters/inbound/conversation.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM])],
|
||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM, AgentExecutionORM])],
|
||||
controllers: [ConversationController, InternalConversationController, AdminConversationController],
|
||||
providers: [
|
||||
ConversationService,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Agent Execution ORM Entity
|
||||
* 记录每次 Agent 调用的执行数据,用于运营分析
|
||||
*/
|
||||
@Entity('agent_executions')
|
||||
@Index('idx_agent_executions_tenant', ['tenantId'])
|
||||
@Index('idx_agent_executions_conversation', ['conversationId'])
|
||||
@Index('idx_agent_executions_agent_type', ['agentType'])
|
||||
@Index('idx_agent_executions_created', ['createdAt'])
|
||||
@Index('idx_agent_executions_tenant_date', ['tenantId', 'createdAt'])
|
||||
export class AgentExecutionORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'uuid' })
|
||||
conversationId: string;
|
||||
|
||||
@Column({ name: 'message_id', type: 'uuid', nullable: true })
|
||||
messageId: string | null;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@Column({ name: 'agent_type', type: 'varchar', length: 30 })
|
||||
agentType: string;
|
||||
|
||||
@Column({ name: 'agent_name', type: 'varchar', length: 50 })
|
||||
agentName: string;
|
||||
|
||||
@Column({ name: 'duration_ms', type: 'int', default: 0 })
|
||||
durationMs: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
success: boolean;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({ name: 'tool_calls_count', type: 'int', default: 0 })
|
||||
toolCallsCount: number;
|
||||
|
||||
@Column({ name: 'input_tokens', type: 'int', default: 0 })
|
||||
inputTokens: number;
|
||||
|
||||
@Column({ name: 'output_tokens', type: 'int', default: 0 })
|
||||
outputTokens: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 创建 agent_executions 表
|
||||
* 用于持久化每次 Agent 调用的执行记录(类型、耗时、成功/失败)
|
||||
* 供 admin 面板的 Agent 调用统计和对话级 Agent 使用明细功能使用
|
||||
*/
|
||||
export class AddAgentExecutionsTable1738800000000 implements MigrationInterface {
|
||||
name = 'AddAgentExecutionsTable1738800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "agent_executions" (
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"conversation_id" UUID NOT NULL,
|
||||
"message_id" UUID,
|
||||
"user_id" UUID,
|
||||
"agent_type" VARCHAR(30) NOT NULL,
|
||||
"agent_name" VARCHAR(50) NOT NULL,
|
||||
"duration_ms" INT NOT NULL DEFAULT 0,
|
||||
"success" BOOLEAN NOT NULL DEFAULT true,
|
||||
"error_message" TEXT,
|
||||
"tool_calls_count" INT NOT NULL DEFAULT 0,
|
||||
"input_tokens" INT NOT NULL DEFAULT 0,
|
||||
"output_tokens" INT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_agent_executions_tenant"
|
||||
ON "agent_executions" ("tenant_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_agent_executions_conversation"
|
||||
ON "agent_executions" ("conversation_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_agent_executions_agent_type"
|
||||
ON "agent_executions" ("agent_type")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_agent_executions_created"
|
||||
ON "agent_executions" ("created_at")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_agent_executions_tenant_date"
|
||||
ON "agent_executions" ("tenant_id", "created_at")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_agent_executions_tenant_date"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_agent_executions_created"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_agent_executions_agent_type"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_agent_executions_conversation"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_agent_executions_tenant"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "agent_executions"`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue