diff --git a/packages/admin-client/src/features/analytics/presentation/components/AgentAnalyticsTab.tsx b/packages/admin-client/src/features/analytics/presentation/components/AgentAnalyticsTab.tsx new file mode 100644 index 0000000..862c126 --- /dev/null +++ b/packages/admin-client/src/features/analytics/presentation/components/AgentAnalyticsTab.tsx @@ -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 = { + policy_expert: '#1890ff', + assessment_expert: '#52c41a', + strategist: '#722ed1', + objection_handler: '#eb2f96', + case_analyst: '#faad14', + memory_manager: '#13c2c2', +}; + +const AGENT_LABELS: Record = { + 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>(); + 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 = [ + { + title: 'Agent', + dataIndex: 'agentType', + key: 'agentType', + render: (type: string, record) => ( + + {AGENT_LABELS[type] || record.agentName} + + ), + }, + { + title: '调用次数', + dataIndex: 'totalCalls', + key: 'totalCalls', + sorter: (a, b) => a.totalCalls - b.totalCalls, + defaultSortOrder: 'descend', + }, + { + title: '成功率', + dataIndex: 'successRate', + key: 'successRate', + render: (rate: number) => ( + = 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 ? {count} : 0, + }, + ]; + + return ( +
+ {/* Summary Cards */} + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + } + valueStyle={{ color: summary.overallSuccessRate >= 95 ? '#52c41a' : '#faad14' }} + /> + + + + + } + valueStyle={{ color: '#722ed1' }} + /> + + + + + } + valueStyle={{ color: '#eb2f96', fontSize: 18 }} + /> + + + + + {/* Agent Statistics Table */} + + 时间范围: + + + } + > + + + + + + + + + {trendAgentTypes.map((agentType) => ( + + ))} + + + + +
+ ); +} diff --git a/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx b/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx index a659a5a..58c0825 100644 --- a/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx +++ b/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx @@ -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() { ), }, + { + key: 'agents', + label: 'Agent 使用分析', + children: , + }, ]} /> diff --git a/packages/admin-client/src/features/conversations/application/useConversations.ts b/packages/admin-client/src/features/conversations/application/useConversations.ts index 2e5bb74..c363136 100644 --- a/packages/admin-client/src/features/conversations/application/useConversations.ts +++ b/packages/admin-client/src/features/conversations/application/useConversations.ts @@ -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({ + queryKey: ['conversations', 'agent-executions', id], + queryFn: () => conversationsApi.getConversationAgentExecutions(id), + enabled: enabled && !!id, + }); +} + +// Global agent statistics +export function useAgentStatistics(days = 30) { + return useQuery({ + queryKey: ['agent-statistics', days], + queryFn: () => conversationsApi.getAgentStatistics(days), + }); +} + +// Agent trend data +export function useAgentTrend(days = 7, agentType?: string) { + return useQuery({ + 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'; diff --git a/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts b/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts index 4176b85..a3dfeae 100644 --- a/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts +++ b/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts @@ -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 => { + const response = await api.get(`/conversations/admin/${id}/agent-executions`); + return response.data.data; + }, + + // Get agent statistics (aggregated) + getAgentStatistics: async (days = 30): Promise => { + 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 => { + const response = await api.get('/conversations/admin/statistics/agents/trend', { + params: { days: days.toString(), agentType }, + }); + return response.data.data; + }, }; diff --git a/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx b/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx index a16b30c..d7f9f31 100644 --- a/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx +++ b/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx @@ -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() { )} + {/* Agent Usage */} + + <Space> + <RobotOutlined /> + Agent 使用详情 + </Space> + + + {agentExecutions && agentExecutions.length > 0 ? ( + { + const colors: Record = { + policy_expert: '#1890ff', + assessment_expert: '#52c41a', + strategist: '#722ed1', + objection_handler: '#eb2f96', + case_analyst: '#faad14', + memory_manager: '#13c2c2', + }; + return ( + + {record.agentName} + + ); + }, + }, + { + title: '耗时', + dataIndex: 'durationMs', + key: 'durationMs', + render: (ms: number) => `${(ms / 1000).toFixed(2)}s`, + }, + { + title: '状态', + dataIndex: 'success', + key: 'success', + render: (success: boolean) => + success + ? 成功 + : 失败, + }, + { + title: '时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date: string) => dayjs(date).format('HH:mm:ss'), + }, + ]} + /> + ) : ( +
+ 暂无 Agent 使用记录 +
+ )} + + {/* Messages */} <Space> diff --git a/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts b/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts index 4d05189..f41f3e3 100644 --- a/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts +++ b/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts @@ -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 汇总) */ diff --git a/packages/services/conversation-service/src/application/services/conversation.service.ts b/packages/services/conversation-service/src/application/services/conversation.service.ts index 0f3912d..55d6cf6 100644 --- a/packages/services/conversation-service/src/application/services/conversation.service.ts +++ b/packages/services/conversation-service/src/application/services/conversation.service.ts @@ -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) { diff --git a/packages/services/conversation-service/src/conversation/conversation.module.ts b/packages/services/conversation-service/src/conversation/conversation.module.ts index dec00ce..c1acb7b 100644 --- a/packages/services/conversation-service/src/conversation/conversation.module.ts +++ b/packages/services/conversation-service/src/conversation/conversation.module.ts @@ -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, diff --git a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/agent-execution.orm.ts b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/agent-execution.orm.ts new file mode 100644 index 0000000..266b879 --- /dev/null +++ b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/agent-execution.orm.ts @@ -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; +} diff --git a/packages/services/conversation-service/src/migrations/AddAgentExecutionsTable.ts b/packages/services/conversation-service/src/migrations/AddAgentExecutionsTable.ts new file mode 100644 index 0000000..7704159 --- /dev/null +++ b/packages/services/conversation-service/src/migrations/AddAgentExecutionsTable.ts @@ -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"`); + } +}