feat(admin): add conversation management with device tracking display
## Backend (conversation-service) - Add AdminConversationController with JWT auth for admin API - Endpoints: list conversations, by user, detail, messages, statistics - Support filtering by status, userId, date range, conversion - Add JWT_SECRET environment variable to docker-compose.yml - Add jsonwebtoken dependency for admin token verification ## Frontend (admin-client) ### New Features: - Add conversations feature module with: - API layer (conversations.api.ts) - React Query hooks (useConversations.ts) - ConversationsPage with full management UI ### User Management Enhancement: - Add "最近咨询记录" section in user detail drawer - Display device info for each conversation: - IP address with region - User-Agent (parsed to browser/OS) - Device fingerprint - Show conversation status, conversion status, message count ### Navigation: - Add "对话管理" menu item with MessageOutlined icon - Add /conversations route ## Files Added: - admin-conversation.controller.ts (backend admin API) - conversations feature folder (frontend) - infrastructure/conversations.api.ts - application/useConversations.ts - presentation/pages/ConversationsPage.tsx Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6a3a2130bf
commit
931055b51f
|
|
@ -290,6 +290,7 @@ services:
|
|||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
|
||||
KNOWLEDGE_SERVICE_URL: http://knowledge-service:3003
|
||||
CORS_ORIGINS: https://iconsulting.szaiai.com,http://localhost:5173
|
||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key}
|
||||
ports:
|
||||
- "3004:3004"
|
||||
healthcheck:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { KnowledgePage } from './features/knowledge/presentation/pages/Knowledge
|
|||
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
||||
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
|
||||
import { UsersPage } from './features/users';
|
||||
import { ConversationsPage } from './features/conversations';
|
||||
import { SettingsPage } from './features/settings';
|
||||
|
||||
function App() {
|
||||
|
|
@ -31,6 +32,7 @@ function App() {
|
|||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="conversations" element={<ConversationsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from './useConversations';
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
conversationsApi,
|
||||
ConversationQueryParams,
|
||||
ConversationDto,
|
||||
ConversationListItem,
|
||||
MessageDto,
|
||||
PaginatedConversations,
|
||||
ConversationStatistics,
|
||||
} from '../infrastructure/conversations.api';
|
||||
|
||||
// Conversation list query
|
||||
export function useConversations(params: ConversationQueryParams) {
|
||||
return useQuery<PaginatedConversations>({
|
||||
queryKey: ['conversations', 'list', params],
|
||||
queryFn: () => conversationsApi.listConversations(params),
|
||||
});
|
||||
}
|
||||
|
||||
// Conversations by user query
|
||||
export function useUserConversations(userId: string, limit = 10, enabled = true) {
|
||||
return useQuery<ConversationListItem[]>({
|
||||
queryKey: ['conversations', 'by-user', userId, limit],
|
||||
queryFn: () => conversationsApi.getConversationsByUser(userId, limit),
|
||||
enabled: enabled && !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Conversation detail query
|
||||
export function useConversationDetail(id: string, enabled = true) {
|
||||
return useQuery<ConversationDto>({
|
||||
queryKey: ['conversations', 'detail', id],
|
||||
queryFn: () => conversationsApi.getConversation(id),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Conversation messages query
|
||||
export function useConversationMessages(id: string, enabled = true) {
|
||||
return useQuery<MessageDto[]>({
|
||||
queryKey: ['conversations', 'messages', id],
|
||||
queryFn: () => conversationsApi.getConversationMessages(id),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Conversation statistics query
|
||||
export function useConversationStatistics() {
|
||||
return useQuery<ConversationStatistics>({
|
||||
queryKey: ['conversations', 'statistics'],
|
||||
queryFn: () => conversationsApi.getStatistics(),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
ConversationDto,
|
||||
ConversationListItem,
|
||||
ConversationStatus,
|
||||
DeviceInfo,
|
||||
MessageDto,
|
||||
PaginatedConversations,
|
||||
ConversationStatistics,
|
||||
ConversationQueryParams,
|
||||
} from '../infrastructure/conversations.api';
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './application';
|
||||
export * from './infrastructure';
|
||||
export * from './presentation/pages';
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import api from '../../../shared/utils/api';
|
||||
|
||||
// ==================== DTOs ====================
|
||||
|
||||
export type ConversationStatus = 'ACTIVE' | 'ENDED' | 'ARCHIVED';
|
||||
|
||||
export interface DeviceInfo {
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
fingerprint?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface ConversationDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
status: ConversationStatus;
|
||||
title: string | null;
|
||||
summary: string | null;
|
||||
category: string | null;
|
||||
messageCount: number;
|
||||
userMessageCount: number;
|
||||
assistantMessageCount: number;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
rating: number | null;
|
||||
feedback: string | null;
|
||||
hasConverted: boolean;
|
||||
consultingStage: string | null;
|
||||
consultingState?: Record<string, unknown> | null;
|
||||
collectedInfo?: Record<string, unknown> | null;
|
||||
recommendedPrograms?: string[] | null;
|
||||
conversionPath?: string | null;
|
||||
deviceInfo: DeviceInfo | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
endedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConversationListItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
status: ConversationStatus;
|
||||
title: string | null;
|
||||
messageCount: number;
|
||||
hasConverted: boolean;
|
||||
consultingStage: string | null;
|
||||
deviceInfo: DeviceInfo | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
endedAt: string | null;
|
||||
}
|
||||
|
||||
export interface MessageDto {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
type: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedConversations {
|
||||
items: ConversationDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ConversationStatistics {
|
||||
total: number;
|
||||
active: number;
|
||||
ended: number;
|
||||
converted: number;
|
||||
todayCount: number;
|
||||
conversionRate: string;
|
||||
}
|
||||
|
||||
export interface ConversationQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ConversationStatus;
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
hasConverted?: boolean;
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'messageCount';
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
|
||||
export const conversationsApi = {
|
||||
// List conversations with pagination
|
||||
listConversations: async (params: ConversationQueryParams): Promise<PaginatedConversations> => {
|
||||
const response = await api.get('/conversations/admin/list', { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get conversations by user ID
|
||||
getConversationsByUser: async (userId: string, limit = 10): Promise<ConversationListItem[]> => {
|
||||
const response = await api.get(`/conversations/admin/by-user/${userId}`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get conversation detail
|
||||
getConversation: async (id: string): Promise<ConversationDto> => {
|
||||
const response = await api.get(`/conversations/admin/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get conversation messages
|
||||
getConversationMessages: async (id: string): Promise<MessageDto[]> => {
|
||||
const response = await api.get(`/conversations/admin/${id}/messages`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get statistics
|
||||
getStatistics: async (): Promise<ConversationStatistics> => {
|
||||
const response = await api.get('/conversations/admin/statistics/overview');
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './conversations.api';
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Select,
|
||||
DatePicker,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Typography,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Spin,
|
||||
List,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
MessageOutlined,
|
||||
CheckCircleOutlined,
|
||||
GlobalOutlined,
|
||||
LaptopOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
useConversations,
|
||||
useConversationStatistics,
|
||||
useConversationDetail,
|
||||
useConversationMessages,
|
||||
type ConversationDto,
|
||||
type ConversationStatus,
|
||||
type ConversationQueryParams,
|
||||
type MessageDto,
|
||||
} from '../../application';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const STATUS_COLORS: Record<ConversationStatus, string> = {
|
||||
ACTIVE: 'green',
|
||||
ENDED: 'default',
|
||||
ARCHIVED: 'gray',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<ConversationStatus, string> = {
|
||||
ACTIVE: '进行中',
|
||||
ENDED: '已结束',
|
||||
ARCHIVED: '已归档',
|
||||
};
|
||||
|
||||
export function ConversationsPage() {
|
||||
const [filters, setFilters] = useState<ConversationQueryParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
});
|
||||
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
|
||||
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: conversationsData, isLoading: loadingConversations } = useConversations(filters);
|
||||
const { data: stats, isLoading: loadingStats } = useConversationStatistics();
|
||||
const { data: conversationDetail, isLoading: loadingDetail } = useConversationDetail(
|
||||
selectedConversationId || '',
|
||||
!!selectedConversationId && detailDrawerOpen
|
||||
);
|
||||
const { data: messages, isLoading: loadingMessages } = useConversationMessages(
|
||||
selectedConversationId || '',
|
||||
!!selectedConversationId && detailDrawerOpen
|
||||
);
|
||||
|
||||
const showConversationDetail = (conv: ConversationDto) => {
|
||||
setSelectedConversationId(conv.id);
|
||||
setDetailDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => {
|
||||
setDateRange(dates);
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
startDate: dates[0]!.format('YYYY-MM-DD'),
|
||||
endDate: dates[1]!.format('YYYY-MM-DD'),
|
||||
page: 1,
|
||||
}));
|
||||
} else {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
page: 1,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: { current?: number; pageSize?: number }) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current || 1,
|
||||
pageSize: pagination.pageSize || 20,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ConversationDto> = [
|
||||
{
|
||||
title: '对话',
|
||||
key: 'conversation',
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div className="font-medium">{record.title || '未命名对话'}</div>
|
||||
<div className="text-xs text-gray-500">ID: {record.id.slice(0, 8)}...</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 120,
|
||||
render: (userId) => (
|
||||
<Tooltip title={userId}>
|
||||
<code className="text-xs">{userId.slice(0, 8)}...</code>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: ConversationStatus) => (
|
||||
<Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '消息数',
|
||||
dataIndex: 'messageCount',
|
||||
key: 'messageCount',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '转化',
|
||||
dataIndex: 'hasConverted',
|
||||
key: 'hasConverted',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (converted) =>
|
||||
converted ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
) : (
|
||||
<span className="text-gray-300">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '访问IP',
|
||||
key: 'ip',
|
||||
width: 140,
|
||||
render: (_, record) => record.deviceInfo?.ip || '-',
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
key: 'device',
|
||||
width: 140,
|
||||
render: (_, record) => {
|
||||
if (!record.deviceInfo?.userAgent) return '-';
|
||||
return (
|
||||
<Tooltip title={record.deviceInfo.userAgent}>
|
||||
<span>{parseUserAgent(record.deviceInfo.userAgent)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_, record) => <a onClick={() => showConversationDetail(record)}>详情</a>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">对话管理</Title>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总对话数"
|
||||
value={stats?.total ?? 0}
|
||||
prefix={<MessageOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="今日对话"
|
||||
value={stats?.todayCount ?? 0}
|
||||
prefix={<BarChartOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="已转化"
|
||||
value={stats?.converted ?? 0}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
转化率: {stats?.conversionRate ?? '0'}%
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="进行中"
|
||||
value={stats?.active ?? 0}
|
||||
prefix={<MessageOutlined />}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-4">
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, status: value, page: 1 }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '进行中' },
|
||||
{ value: 'ENDED', label: '已结束' },
|
||||
{ value: 'ARCHIVED', label: '已归档' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="转化状态"
|
||||
allowClear
|
||||
value={filters.hasConverted}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, hasConverted: value, page: 1 }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: true, label: '已转化' },
|
||||
{ value: false, label: '未转化' },
|
||||
]}
|
||||
/>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={handleDateRangeChange}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Select
|
||||
value={filters.sortBy}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, sortBy: value }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'createdAt', label: '创建时间' },
|
||||
{ value: 'updatedAt', label: '更新时间' },
|
||||
{ value: 'messageCount', label: '消息数' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filters.sortOrder}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, sortOrder: value }))}
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'DESC', label: '降序' },
|
||||
{ value: 'ASC', label: '升序' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Conversations Table */}
|
||||
<Card>
|
||||
<Spin spinning={loadingConversations}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={conversationsData?.items || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: conversationsData?.page || 1,
|
||||
pageSize: conversationsData?.pageSize || 20,
|
||||
total: conversationsData?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Conversation Detail Drawer */}
|
||||
<Drawer
|
||||
title="对话详情"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={detailDrawerOpen}
|
||||
onClose={() => {
|
||||
setDetailDrawerOpen(false);
|
||||
setSelectedConversationId(null);
|
||||
}}
|
||||
>
|
||||
<Spin spinning={loadingDetail}>
|
||||
{conversationDetail && (
|
||||
<div>
|
||||
{/* Basic Info */}
|
||||
<Descriptions bordered column={2} size="small" className="mb-4">
|
||||
<Descriptions.Item label="对话ID" span={2}>
|
||||
<code className="text-xs">{conversationDetail.id}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标题" span={2}>
|
||||
{conversationDetail.title || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户ID">
|
||||
<code className="text-xs">{conversationDetail.userId.slice(0, 12)}...</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLORS[conversationDetail.status]}>
|
||||
{STATUS_LABELS[conversationDetail.status]}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="消息数">
|
||||
{conversationDetail.messageCount}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="转化">
|
||||
{conversationDetail.hasConverted ? (
|
||||
<Tag color="green">已转化</Tag>
|
||||
) : (
|
||||
<Tag>未转化</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{dayjs(conversationDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">
|
||||
{conversationDetail.endedAt
|
||||
? dayjs(conversationDetail.endedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* Device Info */}
|
||||
{conversationDetail.deviceInfo && (
|
||||
<>
|
||||
<Title level={5}>
|
||||
<Space>
|
||||
<LaptopOutlined />
|
||||
设备信息
|
||||
</Space>
|
||||
</Title>
|
||||
<Descriptions bordered column={1} size="small" className="mb-4">
|
||||
<Descriptions.Item label="访问IP">
|
||||
<Space>
|
||||
<GlobalOutlined />
|
||||
{conversationDetail.deviceInfo.ip || '-'}
|
||||
{conversationDetail.deviceInfo.region && (
|
||||
<Tag>{conversationDetail.deviceInfo.region}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="User-Agent">
|
||||
<Tooltip title={conversationDetail.deviceInfo.userAgent}>
|
||||
<span className="text-xs">
|
||||
{conversationDetail.deviceInfo.userAgent
|
||||
? parseUserAgent(conversationDetail.deviceInfo.userAgent)
|
||||
: '-'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备指纹">
|
||||
<code className="text-xs">
|
||||
{conversationDetail.deviceInfo.fingerprint || '-'}
|
||||
</code>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Token Usage */}
|
||||
<Title level={5}>
|
||||
<Space>
|
||||
<BarChartOutlined />
|
||||
Token 使用
|
||||
</Space>
|
||||
</Title>
|
||||
<Row gutter={16} className="mb-4">
|
||||
<Col span={12}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="输入 Tokens"
|
||||
value={conversationDetail.totalInputTokens}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="输出 Tokens"
|
||||
value={conversationDetail.totalOutputTokens}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Messages */}
|
||||
<Title level={5}>
|
||||
<Space>
|
||||
<MessageOutlined />
|
||||
对话内容
|
||||
</Space>
|
||||
</Title>
|
||||
<Spin spinning={loadingMessages}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={messages || []}
|
||||
style={{ maxHeight: 400, overflow: 'auto' }}
|
||||
renderItem={(msg: MessageDto) => (
|
||||
<List.Item
|
||||
className={msg.role === 'user' ? 'bg-blue-50' : 'bg-gray-50'}
|
||||
style={{ borderRadius: 4, marginBottom: 8 }}
|
||||
>
|
||||
<div className="w-full px-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Tag color={msg.role === 'user' ? 'blue' : 'green'}>
|
||||
{msg.role === 'user' ? '用户' : '助手'}
|
||||
</Tag>
|
||||
<span className="text-xs text-gray-400">
|
||||
{dayjs(msg.createdAt).format('HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to parse User-Agent into readable format
|
||||
function parseUserAgent(ua: string): string {
|
||||
if (!ua) return '-';
|
||||
|
||||
let browser = 'Unknown';
|
||||
let os = 'Unknown';
|
||||
|
||||
if (ua.includes('Chrome')) browser = 'Chrome';
|
||||
else if (ua.includes('Firefox')) browser = 'Firefox';
|
||||
else if (ua.includes('Safari')) browser = 'Safari';
|
||||
else if (ua.includes('Edge')) browser = 'Edge';
|
||||
|
||||
if (ua.includes('Windows')) os = 'Windows';
|
||||
else if (ua.includes('Mac')) os = 'macOS';
|
||||
else if (ua.includes('Linux')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
||||
|
||||
return `${browser} / ${os}`;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './ConversationsPage';
|
||||
|
|
@ -14,12 +14,19 @@ import {
|
|||
Descriptions,
|
||||
Spin,
|
||||
Avatar,
|
||||
Divider,
|
||||
List,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
UserAddOutlined,
|
||||
SearchOutlined,
|
||||
MessageOutlined,
|
||||
GlobalOutlined,
|
||||
LaptopOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -30,6 +37,7 @@ import {
|
|||
type UserDto,
|
||||
type UserType,
|
||||
} from '../../application';
|
||||
import { useUserConversations, type ConversationListItem } from '../../../conversations/application';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Search } = Input;
|
||||
|
|
@ -71,6 +79,11 @@ export function UsersPage() {
|
|||
selectedUserId || '',
|
||||
!!selectedUserId && detailDrawerOpen
|
||||
);
|
||||
const { data: userConversations, isLoading: loadingConversations } = useUserConversations(
|
||||
selectedUserId || '',
|
||||
10,
|
||||
!!selectedUserId && detailDrawerOpen
|
||||
);
|
||||
|
||||
const showUserDetail = (user: UserDto) => {
|
||||
setSelectedUserId(user.id);
|
||||
|
|
@ -269,7 +282,7 @@ export function UsersPage() {
|
|||
<Drawer
|
||||
title="用户详情"
|
||||
placement="right"
|
||||
width={480}
|
||||
width={520}
|
||||
open={detailDrawerOpen}
|
||||
onClose={() => {
|
||||
setDetailDrawerOpen(false);
|
||||
|
|
@ -319,6 +332,84 @@ export function UsersPage() {
|
|||
{dayjs(userDetail.lastActiveAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* Recent Conversations */}
|
||||
<Divider orientation="left">
|
||||
<Space>
|
||||
<MessageOutlined />
|
||||
最近咨询记录
|
||||
</Space>
|
||||
</Divider>
|
||||
|
||||
<Spin spinning={loadingConversations}>
|
||||
{userConversations && userConversations.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={userConversations}
|
||||
renderItem={(conv: ConversationListItem) => (
|
||||
<List.Item>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{conv.title || '未命名对话'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{dayjs(conv.createdAt).format('YYYY-MM-DD HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
<Space size={4}>
|
||||
<Tag color={conv.status === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{conv.status === 'ACTIVE' ? '进行中' : '已结束'}
|
||||
</Tag>
|
||||
{conv.hasConverted && (
|
||||
<Tooltip title="已转化">
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Device Info */}
|
||||
{conv.deviceInfo && (
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
{conv.deviceInfo.ip && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GlobalOutlined />
|
||||
<span>IP: {conv.deviceInfo.ip}</span>
|
||||
{conv.deviceInfo.region && (
|
||||
<span className="text-gray-300">({conv.deviceInfo.region})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{conv.deviceInfo.userAgent && (
|
||||
<Tooltip title={conv.deviceInfo.userAgent}>
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<LaptopOutlined />
|
||||
<span className="truncate">{parseUserAgent(conv.deviceInfo.userAgent)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{conv.deviceInfo.fingerprint && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>指纹: </span>
|
||||
<code className="text-xs">{conv.deviceInfo.fingerprint.slice(0, 12)}...</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
消息数: {conv.messageCount}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-4">暂无咨询记录</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
|
@ -326,3 +417,25 @@ export function UsersPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to parse User-Agent into readable format
|
||||
function parseUserAgent(ua: string): string {
|
||||
if (!ua) return '-';
|
||||
|
||||
// Simple parsing - extract browser and OS
|
||||
let browser = 'Unknown';
|
||||
let os = 'Unknown';
|
||||
|
||||
if (ua.includes('Chrome')) browser = 'Chrome';
|
||||
else if (ua.includes('Firefox')) browser = 'Firefox';
|
||||
else if (ua.includes('Safari')) browser = 'Safari';
|
||||
else if (ua.includes('Edge')) browser = 'Edge';
|
||||
|
||||
if (ua.includes('Windows')) os = 'Windows';
|
||||
else if (ua.includes('Mac')) os = 'macOS';
|
||||
else if (ua.includes('Linux')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
||||
|
||||
return `${browser} / ${os}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
FileTextOutlined,
|
||||
AuditOutlined,
|
||||
LineChartOutlined,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
|
|
@ -64,6 +65,11 @@ const menuItems: MenuProps['items'] = [
|
|||
icon: <UserOutlined />,
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/conversations',
|
||||
icon: <MessageOutlined />,
|
||||
label: '对话管理',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@
|
|||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"typeorm": "^0.3.19",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"jsonwebtoken": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Param,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { ConversationORM } from '../../infrastructure/database/postgres/entities/conversation.orm';
|
||||
import { MessageORM } from '../../infrastructure/database/postgres/entities/message.orm';
|
||||
|
||||
interface AdminPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员对话 API - 供 admin-client 使用
|
||||
* 需要管理员 JWT 认证
|
||||
*/
|
||||
@Controller('conversations/admin')
|
||||
export class AdminConversationController {
|
||||
constructor(
|
||||
@InjectRepository(ConversationORM)
|
||||
private conversationRepo: Repository<ConversationORM>,
|
||||
@InjectRepository(MessageORM)
|
||||
private messageRepo: Repository<MessageORM>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 验证管理员 Token
|
||||
*/
|
||||
private verifyAdmin(authorization: string): AdminPayload {
|
||||
const token = authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = process.env.JWT_SECRET || 'your-jwt-secret-key';
|
||||
const payload = jwt.verify(token, secret) as AdminPayload;
|
||||
return payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有对话列表(分页)
|
||||
*/
|
||||
@Get('list')
|
||||
async listConversations(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('hasConverted') hasConverted?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const pageNum = parseInt(page || '1');
|
||||
const pageSizeNum = parseInt(pageSize || '20');
|
||||
|
||||
const queryBuilder = this.conversationRepo.createQueryBuilder('c');
|
||||
|
||||
// 应用筛选条件
|
||||
if (status) {
|
||||
queryBuilder.andWhere('c.status = :status', { status });
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
queryBuilder.andWhere('c.user_id = :userId', { userId });
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
queryBuilder.andWhere('c.created_at >= :startDate', {
|
||||
startDate: new Date(startDate),
|
||||
});
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
queryBuilder.andWhere('c.created_at <= :endDate', {
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasConverted !== undefined) {
|
||||
queryBuilder.andWhere('c.has_converted = :hasConverted', {
|
||||
hasConverted: hasConverted === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
// 排序
|
||||
const sortColumnMap: Record<string, string> = {
|
||||
createdAt: 'c.created_at',
|
||||
updatedAt: 'c.updated_at',
|
||||
messageCount: 'c.message_count',
|
||||
};
|
||||
const validSortBy = sortBy && sortColumnMap[sortBy] ? sortBy : 'createdAt';
|
||||
const validSortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
queryBuilder.orderBy(sortColumnMap[validSortBy], validSortOrder);
|
||||
|
||||
// 获取总数
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
// 分页
|
||||
queryBuilder.skip((pageNum - 1) * pageSizeNum).take(pageSizeNum);
|
||||
|
||||
const conversations = await queryBuilder.getMany();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: conversations.map((c) => ({
|
||||
id: c.id,
|
||||
userId: c.userId,
|
||||
status: c.status,
|
||||
title: c.title,
|
||||
summary: c.summary,
|
||||
category: c.category,
|
||||
messageCount: c.messageCount,
|
||||
userMessageCount: c.userMessageCount,
|
||||
assistantMessageCount: c.assistantMessageCount,
|
||||
totalInputTokens: c.totalInputTokens,
|
||||
totalOutputTokens: c.totalOutputTokens,
|
||||
rating: c.rating,
|
||||
feedback: c.feedback,
|
||||
hasConverted: c.hasConverted,
|
||||
consultingStage: c.consultingStage,
|
||||
deviceInfo: c.deviceInfo,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
endedAt: c.endedAt,
|
||||
})),
|
||||
total,
|
||||
page: pageNum,
|
||||
pageSize: pageSizeNum,
|
||||
totalPages: Math.ceil(total / pageSizeNum),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的对话列表
|
||||
*/
|
||||
@Get('by-user/:userId')
|
||||
async getConversationsByUser(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('userId') userId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const limitNum = parseInt(limit || '10');
|
||||
|
||||
const conversations = await this.conversationRepo.find({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limitNum,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: conversations.map((c) => ({
|
||||
id: c.id,
|
||||
userId: c.userId,
|
||||
status: c.status,
|
||||
title: c.title,
|
||||
messageCount: c.messageCount,
|
||||
hasConverted: c.hasConverted,
|
||||
consultingStage: c.consultingStage,
|
||||
deviceInfo: c.deviceInfo,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
endedAt: c.endedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个对话详情
|
||||
*/
|
||||
@Get(':id')
|
||||
async getConversation(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const conversation = await this.conversationRepo.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Conversation not found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: conversation.id,
|
||||
userId: conversation.userId,
|
||||
status: conversation.status,
|
||||
title: conversation.title,
|
||||
summary: conversation.summary,
|
||||
category: conversation.category,
|
||||
messageCount: conversation.messageCount,
|
||||
userMessageCount: conversation.userMessageCount,
|
||||
assistantMessageCount: conversation.assistantMessageCount,
|
||||
totalInputTokens: conversation.totalInputTokens,
|
||||
totalOutputTokens: conversation.totalOutputTokens,
|
||||
rating: conversation.rating,
|
||||
feedback: conversation.feedback,
|
||||
hasConverted: conversation.hasConverted,
|
||||
consultingStage: conversation.consultingStage,
|
||||
consultingState: conversation.consultingState,
|
||||
collectedInfo: conversation.collectedInfo,
|
||||
recommendedPrograms: conversation.recommendedPrograms,
|
||||
conversionPath: conversation.conversionPath,
|
||||
deviceInfo: conversation.deviceInfo,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
endedAt: conversation.endedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话的消息
|
||||
*/
|
||||
@Get(':id/messages')
|
||||
async getConversationMessages(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') conversationId: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const messages = await this.messageRepo.find({
|
||||
where: { conversationId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: messages.map((m) => ({
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
role: m.role,
|
||||
type: m.type,
|
||||
content: m.content,
|
||||
metadata: m.metadata,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话统计
|
||||
*/
|
||||
@Get('statistics/overview')
|
||||
async getStatistics(@Headers('authorization') auth: string) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const total = await this.conversationRepo.count();
|
||||
const active = await this.conversationRepo.count({ where: { status: 'ACTIVE' } });
|
||||
const ended = await this.conversationRepo.count({ where: { status: 'ENDED' } });
|
||||
const converted = await this.conversationRepo.count({ where: { hasConverted: true } });
|
||||
|
||||
// 今日对话
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayCount = await this.conversationRepo
|
||||
.createQueryBuilder('c')
|
||||
.where('c.created_at >= :today', { today })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
active,
|
||||
ended,
|
||||
converted,
|
||||
todayCount,
|
||||
conversionRate: total > 0 ? ((converted / total) * 100).toFixed(1) : '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './conversation.controller';
|
||||
export * from './conversation.gateway';
|
||||
export * from './internal.controller';
|
||||
export * from './admin-conversation.controller';
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ import { TOKEN_USAGE_REPOSITORY } from '../domain/repositories/token-usage.repos
|
|||
import { ConversationService } from '../application/services/conversation.service';
|
||||
import { ConversationController } from '../adapters/inbound/conversation.controller';
|
||||
import { InternalConversationController } from '../adapters/inbound/internal.controller';
|
||||
import { AdminConversationController } from '../adapters/inbound/admin-conversation.controller';
|
||||
import { ConversationGateway } from '../adapters/inbound/conversation.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM])],
|
||||
controllers: [ConversationController, InternalConversationController],
|
||||
controllers: [ConversationController, InternalConversationController, AdminConversationController],
|
||||
providers: [
|
||||
ConversationService,
|
||||
ConversationGateway,
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ importers:
|
|||
ioredis:
|
||||
specifier: ^5.3.0
|
||||
version: 5.9.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.3
|
||||
kafkajs:
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
|
|
@ -169,6 +172,9 @@ importers:
|
|||
'@types/jest':
|
||||
specifier: ^29.5.0
|
||||
version: 29.5.14
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.10
|
||||
'@types/node':
|
||||
specifier: ^20.10.0
|
||||
version: 20.19.27
|
||||
|
|
|
|||
Loading…
Reference in New Issue