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,
|
useBackfillStatistics,
|
||||||
type DailyStatisticsDto,
|
type DailyStatisticsDto,
|
||||||
} from '../../application';
|
} from '../../application';
|
||||||
|
import { AgentAnalyticsTab } from '../components/AgentAnalyticsTab';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
@ -352,6 +353,11 @@ export function AnalyticsPage() {
|
||||||
</Spin>
|
</Spin>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'agents',
|
||||||
|
label: 'Agent 使用分析',
|
||||||
|
children: <AgentAnalyticsTab />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import {
|
||||||
MessageDto,
|
MessageDto,
|
||||||
PaginatedConversations,
|
PaginatedConversations,
|
||||||
ConversationStatistics,
|
ConversationStatistics,
|
||||||
|
AgentExecutionDto,
|
||||||
|
AgentStatisticsDto,
|
||||||
|
AgentTrendDto,
|
||||||
} from '../infrastructure/conversations.api';
|
} from '../infrastructure/conversations.api';
|
||||||
|
|
||||||
// Conversation list query
|
// 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
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
ConversationDto,
|
ConversationDto,
|
||||||
|
|
@ -66,4 +94,7 @@ export type {
|
||||||
TokenDetails,
|
TokenDetails,
|
||||||
GlobalTokenStats,
|
GlobalTokenStats,
|
||||||
TodayTokenStats,
|
TodayTokenStats,
|
||||||
|
AgentExecutionDto,
|
||||||
|
AgentStatisticsDto,
|
||||||
|
AgentTrendDto,
|
||||||
} from '../infrastructure/conversations.api';
|
} from '../infrastructure/conversations.api';
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,40 @@ export interface ConversationQueryParams {
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
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 ====================
|
// ==================== API ====================
|
||||||
|
|
||||||
export const conversationsApi = {
|
export const conversationsApi = {
|
||||||
|
|
@ -153,4 +187,26 @@ export const conversationsApi = {
|
||||||
const response = await api.get('/conversations/admin/statistics/overview');
|
const response = await api.get('/conversations/admin/statistics/overview');
|
||||||
return response.data.data;
|
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,
|
DollarOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
|
RobotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
@ -33,10 +34,12 @@ import {
|
||||||
useConversationStatistics,
|
useConversationStatistics,
|
||||||
useConversationDetail,
|
useConversationDetail,
|
||||||
useConversationMessages,
|
useConversationMessages,
|
||||||
|
useConversationAgentExecutions,
|
||||||
type ConversationDto,
|
type ConversationDto,
|
||||||
type ConversationStatus,
|
type ConversationStatus,
|
||||||
type ConversationQueryParams,
|
type ConversationQueryParams,
|
||||||
type MessageDto,
|
type MessageDto,
|
||||||
|
type AgentExecutionDto,
|
||||||
} from '../../application';
|
} from '../../application';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
@ -77,6 +80,10 @@ export function ConversationsPage() {
|
||||||
selectedConversationId || '',
|
selectedConversationId || '',
|
||||||
!!selectedConversationId && detailDrawerOpen
|
!!selectedConversationId && detailDrawerOpen
|
||||||
);
|
);
|
||||||
|
const { data: agentExecutions, isLoading: loadingAgentExecs } = useConversationAgentExecutions(
|
||||||
|
selectedConversationId || '',
|
||||||
|
!!selectedConversationId && detailDrawerOpen
|
||||||
|
);
|
||||||
|
|
||||||
const showConversationDetail = (conv: ConversationDto) => {
|
const showConversationDetail = (conv: ConversationDto) => {
|
||||||
setSelectedConversationId(conv.id);
|
setSelectedConversationId(conv.id);
|
||||||
|
|
@ -555,6 +562,72 @@ export function ConversationsPage() {
|
||||||
)}
|
)}
|
||||||
</Row>
|
</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 */}
|
{/* Messages */}
|
||||||
<Title level={5}>
|
<Title level={5}>
|
||||||
<Space>
|
<Space>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import * as jwt from 'jsonwebtoken';
|
||||||
import { ConversationORM } from '../../infrastructure/database/postgres/entities/conversation.orm';
|
import { ConversationORM } from '../../infrastructure/database/postgres/entities/conversation.orm';
|
||||||
import { MessageORM } from '../../infrastructure/database/postgres/entities/message.orm';
|
import { MessageORM } from '../../infrastructure/database/postgres/entities/message.orm';
|
||||||
import { TokenUsageORM } from '../../infrastructure/database/postgres/entities/token-usage.orm';
|
import { TokenUsageORM } from '../../infrastructure/database/postgres/entities/token-usage.orm';
|
||||||
|
import { AgentExecutionORM } from '../../infrastructure/database/postgres/entities/agent-execution.orm';
|
||||||
|
|
||||||
interface AdminPayload {
|
interface AdminPayload {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -42,6 +43,8 @@ export class AdminConversationController {
|
||||||
private messageRepo: Repository<MessageORM>,
|
private messageRepo: Repository<MessageORM>,
|
||||||
@InjectRepository(TokenUsageORM)
|
@InjectRepository(TokenUsageORM)
|
||||||
private tokenUsageRepo: Repository<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 数据
|
* 包含从 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 汇总)
|
* 获取对话统计(包含 Token 汇总)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { TenantContextService } from '@iconsulting/shared';
|
||||||
import {
|
import {
|
||||||
ConversationEntity,
|
ConversationEntity,
|
||||||
ConversationStatus,
|
ConversationStatus,
|
||||||
|
|
@ -23,6 +26,7 @@ import {
|
||||||
LegacyConversationContext as ConversationContext,
|
LegacyConversationContext as ConversationContext,
|
||||||
StreamChunk,
|
StreamChunk,
|
||||||
} from '../../infrastructure/agents/coordinator/coordinator-agent.service';
|
} from '../../infrastructure/agents/coordinator/coordinator-agent.service';
|
||||||
|
import { AgentExecutionORM } from '../../infrastructure/database/postgres/entities/agent-execution.orm';
|
||||||
|
|
||||||
export interface CreateConversationParams {
|
export interface CreateConversationParams {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -55,6 +59,9 @@ export class ConversationService {
|
||||||
@Inject(MESSAGE_REPOSITORY)
|
@Inject(MESSAGE_REPOSITORY)
|
||||||
private readonly messageRepo: IMessageRepository,
|
private readonly messageRepo: IMessageRepository,
|
||||||
private readonly claudeAgentService: CoordinatorAgentService,
|
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 inputTokens = 0;
|
||||||
let outputTokens = 0;
|
let outputTokens = 0;
|
||||||
let streamError: Error | null = null;
|
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)
|
// Stream response from Claude (with attachments for multimodal support)
|
||||||
try {
|
try {
|
||||||
|
|
@ -194,6 +208,20 @@ export class ConversationService {
|
||||||
inputTokens = chunk.inputTokens || 0;
|
inputTokens = chunk.inputTokens || 0;
|
||||||
outputTokens = chunk.outputTokens || 0;
|
outputTokens = chunk.outputTokens || 0;
|
||||||
console.warn(`[ConversationService] Stream error, captured partial tokens: in=${inputTokens}, out=${outputTokens}`);
|
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;
|
yield chunk;
|
||||||
|
|
@ -224,6 +252,26 @@ export class ConversationService {
|
||||||
await this.messageRepo.save(assistantMessage);
|
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)
|
// Update conversation statistics (always update tokens, even on error)
|
||||||
conversation.incrementMessageCount('user');
|
conversation.incrementMessageCount('user');
|
||||||
if (fullResponse) {
|
if (fullResponse) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ConversationORM } from '../infrastructure/database/postgres/entities/conversation.orm';
|
import { ConversationORM } from '../infrastructure/database/postgres/entities/conversation.orm';
|
||||||
import { MessageORM } from '../infrastructure/database/postgres/entities/message.orm';
|
import { MessageORM } from '../infrastructure/database/postgres/entities/message.orm';
|
||||||
import { TokenUsageORM } from '../infrastructure/database/postgres/entities/token-usage.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 { ConversationPostgresRepository } from '../adapters/outbound/persistence/conversation-postgres.repository';
|
||||||
import { MessagePostgresRepository } from '../adapters/outbound/persistence/message-postgres.repository';
|
import { MessagePostgresRepository } from '../adapters/outbound/persistence/message-postgres.repository';
|
||||||
import { TokenUsagePostgresRepository } from '../adapters/outbound/persistence/token-usage-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';
|
import { ConversationGateway } from '../adapters/inbound/conversation.gateway';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM])],
|
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM, AgentExecutionORM])],
|
||||||
controllers: [ConversationController, InternalConversationController, AdminConversationController],
|
controllers: [ConversationController, InternalConversationController, AdminConversationController],
|
||||||
providers: [
|
providers: [
|
||||||
ConversationService,
|
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