feat: add conversation management — sessionId + X-Hermes-Session-Id + CRUD
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 37s

This commit is contained in:
WangDL 2026-05-22 10:43:18 +08:00
parent 3b42a8618a
commit f20bdc0d7a
11 changed files with 174 additions and 17 deletions

View File

@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE `AdminConversation` (
`id` VARCHAR(191) NOT NULL,
`adminUserId` VARCHAR(191) NOT NULL,
`title` VARCHAR(200) NOT NULL DEFAULT '新对话',
`hermesSessionId` VARCHAR(64) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
`deletedAt` DATETIME(3) NULL,
UNIQUE INDEX `AdminConversation_hermesSessionId_key`(`hermesSessionId`),
INDEX `AdminConversation_adminUserId_idx`(`adminUserId`),
INDEX `AdminConversation_hermesSessionId_idx`(`hermesSessionId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `AdminConversation` ADD CONSTRAINT `AdminConversation_adminUserId_fkey` FOREIGN KEY (`adminUserId`) REFERENCES `AdminUser`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -732,6 +732,7 @@ model AdminUser {
deletedAt DateTime?
sessions AdminSession[]
conversations AdminConversation[]
auditLogs AdminAuditLog[]
@@index([email])
@ -793,3 +794,18 @@ model MembershipPlan {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AdminConversation {
id String @id @default(cuid())
adminUserId String
title String @default("新对话") @db.VarChar(200)
hermesSessionId String @unique @db.VarChar(64)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
adminUser AdminUser @relation(fields: [adminUserId], references: [id])
@@index([adminUserId])
@@index([hermesSessionId])
}

View File

@ -15,6 +15,7 @@ import { AuthModule } from './modules/auth/auth.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
import { AdminUsersModule } from './modules/admin-users/admin-users.module';
import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module';
import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module';
import { AdminAuditLogModule } from './modules/admin-audit-log/admin-audit-log.module';
import { UsersModule } from './modules/users/users.module';
@ -88,6 +89,7 @@ import appleConfig from './config/apple.config';
AdminAuthModule,
AdminDashboardModule,
AdminUsersModule,
AdminConversationModule,
AdminAiChatModule,
AdminAuditLogModule,
UsersModule,

View File

@ -1,5 +1,5 @@
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Post, Get, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Post, Get, Body, Req, UseGuards } from '@nestjs/common';
import { AdminAiChatService } from './admin-ai-chat.service';
import { AiChatDto } from './dto/ai-chat.dto';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
@ -17,8 +17,8 @@ export class AdminAiChatController {
@AdminRoles('SUPER_ADMIN' as AdminRole)
@ApiBearerAuth()
@ApiOperation({ summary: 'AI 对话(仅超级管理员)' })
async chat(@Body() dto: AiChatDto) {
return this.aiChatService.chat(dto);
async chat(@Body() dto: AiChatDto, @Req() req: any) {
return this.aiChatService.chat(dto, req.adminUser.id);
}
@Get('dashboard')

View File

@ -3,8 +3,10 @@ import { AdminAiChatController } from './admin-ai-chat.controller';
import { AdminAiChatService } from './admin-ai-chat.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { AdminConversationModule } from '../admin-conversation/admin-conversation.module';
@Module({
imports: [AdminConversationModule],
controllers: [AdminAiChatController],
providers: [AdminAiChatService, AdminAuthGuard, AdminRolesGuard],
})

View File

@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import type { AiChatDto } from './dto/ai-chat.dto';
import { AdminConversationService } from '../admin-conversation/admin-conversation.service';
const HERMES_API_URL = 'http://10.2.0.7:8642/v1/chat/completions';
const HERMES_API_KEY = 'zhixi-hermes-key-2026';
@ -8,20 +9,38 @@ const HERMES_API_KEY = 'zhixi-hermes-key-2026';
export class AdminAiChatService {
private readonly logger = new Logger(AdminAiChatService.name);
constructor() {}
constructor(private readonly conversationService: AdminConversationService) {}
async chat(dto: AiChatDto) {
return await this.callHermes(dto.messages);
async chat(dto: AiChatDto, adminUserId: string) {
const sessionId = dto.conversationId
? await this.conversationService.getSessionId(dto.conversationId, adminUserId)
: null;
// Auto-create conversation if none provided
const conversationId = dto.conversationId
?? (await this.conversationService.create(adminUserId)).id;
const result = await this.callHermes(dto.messages, sessionId);
return { ...result, conversationId };
}
private async callHermes(messages: Array<{ role: string; content: string }>) {
private async callHermes(
messages: Array<{ role: string; content: string }>,
sessionId: string | null,
) {
const start = Date.now();
const resp = await fetch(HERMES_API_URL, {
method: 'POST',
headers: {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + HERMES_API_KEY,
},
};
if (sessionId) {
headers['X-Hermes-Session-Id'] = sessionId;
}
const resp = await fetch(HERMES_API_URL, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'hermes-agent',
messages,

View File

@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsIn, MinLength } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsIn, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
@ -21,7 +21,8 @@ export class AiChatDto {
@Type(() => ChatMessageDto)
messages: ChatMessageDto[];
@ApiProperty({ required: false, default: false })
@ApiProperty({ required: false })
@IsOptional()
stream?: boolean;
@IsString()
conversationId?: string;
}

View File

@ -0,0 +1,35 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AdminConversationService } from './admin-conversation.service';
import { CreateConversationDto, UpdateConversationDto } from './dto';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
@ApiTags('admin-conversation')
@Controller('admin-api/conversations')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
export class AdminConversationController {
constructor(private readonly svc: AdminConversationService) {}
@Get()
async list(@Req() req: any) {
return this.svc.list(req.adminUser.id);
}
@Post()
async create(@Req() req: any, @Body() dto: CreateConversationDto) {
return this.svc.create(req.adminUser.id, dto.title);
}
@Patch(':id')
async update(@Req() req: any, @Param('id') id: string, @Body() dto: UpdateConversationDto) {
await this.svc.update(id, req.adminUser.id, dto.title!);
return { success: true };
}
@Delete(':id')
async delete(@Req() req: any, @Param('id') id: string) {
await this.svc.delete(id, req.adminUser.id);
return { success: true };
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminConversationController } from './admin-conversation.controller';
import { AdminConversationService } from './admin-conversation.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
@Module({
controllers: [AdminConversationController],
providers: [AdminConversationService, PrismaService],
exports: [AdminConversationService],
})
export class AdminConversationModule {}

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { randomUUID } from 'crypto';
@Injectable()
export class AdminConversationService {
constructor(private readonly prisma: PrismaService) {}
async list(adminUserId: string) {
return this.prisma.adminConversation.findMany({
where: { adminUserId, deletedAt: null },
orderBy: { updatedAt: 'desc' },
select: { id: true, title: true, createdAt: true, updatedAt: true },
});
}
async create(adminUserId: string, title?: string) {
const hermesSessionId = randomUUID();
return this.prisma.adminConversation.create({
data: { adminUserId, hermesSessionId, title: title || '新对话' },
select: { id: true, title: true, hermesSessionId: true, createdAt: true },
});
}
async update(id: string, adminUserId: string, title: string) {
return this.prisma.adminConversation.updateMany({
where: { id, adminUserId, deletedAt: null },
data: { title },
});
}
async delete(id: string, adminUserId: string) {
return this.prisma.adminConversation.updateMany({
where: { id, adminUserId, deletedAt: null },
data: { deletedAt: new Date() },
});
}
async getSessionId(conversationId: string, adminUserId: string) {
const conv = await this.prisma.adminConversation.findFirst({
where: { id: conversationId, adminUserId, deletedAt: null },
select: { id: true, hermesSessionId: true },
});
return conv?.hermesSessionId ?? null;
}
}

View File

@ -0,0 +1,7 @@
export class CreateConversationDto {
title?: string;
}
export class UpdateConversationDto {
title?: string;
}