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:
hailin 2026-02-06 08:00:55 -08:00
parent 16cc0e4c08
commit 691a3523e8
10 changed files with 762 additions and 1 deletions

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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';

View File

@ -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;
},
};

View File

@ -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>

View File

@ -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
*/

View File

@ -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) {

View File

@ -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,

View File

@ -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;
}

View File

@ -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"`);
}
}