feat(client): 新增会话管理与消息落库能力
补齐客户端会话生命周期接口(创建、列表、消息分页、改名、删除),并在流式 chat 中强制绑定 sessionId 与落库消息,确保会话标题和历史可追踪,同时统一 Swagger 文档为 DTO 驱动以减少重复维护。 Made-with: Cursor
This commit is contained in:
@@ -197,33 +197,57 @@ chat-one-service/
|
||||
### 4.2 客户端 AI Chat 接口(流式)
|
||||
|
||||
- `POST /api/client/v1/chat/completions/stream`
|
||||
- 说明:统一接口,后台自动路由平台
|
||||
- 说明:统一接口,后台自动路由平台;支持会话续聊
|
||||
- 入参示例:
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4o-mini-like",
|
||||
"model": "qwen-plus",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "你是助手" },
|
||||
{ "role": "user", "content": "介绍一下NestJS" }
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"platform": "auto"
|
||||
"platform": "auto",
|
||||
"sessionId": "1"
|
||||
}
|
||||
```
|
||||
- 备注:
|
||||
- `sessionId` 必填,需先调用创建会话接口;
|
||||
- `meta` 会返回 `sessionId`(字符串),前端可缓存用于后续续聊。
|
||||
- `done` 前会先落库用户消息与会话标题,便于列表即时刷新。
|
||||
- 返回:`text/event-stream`(SSE)
|
||||
|
||||
- `POST /api/client/v1/chat/completions/stream/:platform`
|
||||
- 说明:指定平台(`qwen | volc | deepseek`)
|
||||
- 其余参数同上
|
||||
### 4.3 客户端会话接口
|
||||
|
||||
### 4.3 建议统一 SSE 事件格式
|
||||
- `POST /api/client/v1/chat/sessions`
|
||||
- 说明:创建空会话(推荐在首次发送前调用)
|
||||
- 入参:`{ title? }`
|
||||
- 出参:`{ id, userId, title, createdAt, updatedAt }`
|
||||
|
||||
- `GET /api/client/v1/chat/sessions?limit=&offset=`
|
||||
- 说明:会话列表(按 `updatedAt desc`)
|
||||
- 出参:`{ items, total, limit, offset }`
|
||||
|
||||
- `GET /api/client/v1/chat/sessions/:sessionId/messages?limit=&offset=`
|
||||
- 说明:分页查询会话消息
|
||||
- 出参:`{ sessionId, items, total, limit, offset }`
|
||||
|
||||
- `DELETE /api/client/v1/chat/sessions/:sessionId`
|
||||
- 说明:删除会话并级联删除该会话下全部消息
|
||||
- 出参:删除成功后返回被删除会话信息
|
||||
|
||||
- `PATCH /api/client/v1/chat/sessions/:sessionId/title`
|
||||
- 说明:修改会话标题(可传空字符串清空)
|
||||
- 入参:`{ title }`
|
||||
- 出参:更新后的会话信息
|
||||
|
||||
### 4.4 建议统一 SSE 事件格式
|
||||
|
||||
```text
|
||||
event: meta
|
||||
data: {"requestId":"xxx","platform":"qwen","model":"qwen-turbo"}
|
||||
data: {"requestId":"xxx","platform":"qwen","model":"qwen-plus","sessionId":"1"}
|
||||
|
||||
event: delta
|
||||
data: {"content":"你好"}
|
||||
data: {"delta":"你好"}
|
||||
|
||||
event: usage
|
||||
data: {"promptTokens":120,"completionTokens":80,"totalTokens":200}
|
||||
@@ -235,7 +259,7 @@ event: error
|
||||
data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"}
|
||||
```
|
||||
|
||||
### 4.4 管理端认证接口(邮箱)
|
||||
### 4.5 管理端认证接口(邮箱)
|
||||
|
||||
- `POST /api/admin/v1/auth/login`
|
||||
- 入参:`{ email, password }`
|
||||
@@ -244,21 +268,21 @@ data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"}
|
||||
- `POST /api/admin/v1/auth/refresh`
|
||||
- `POST /api/admin/v1/auth/logout`
|
||||
|
||||
### 4.5 管理端用户管理
|
||||
### 4.6 管理端用户管理
|
||||
|
||||
- `GET /api/admin/v1/users`
|
||||
- `GET /api/admin/v1/users/:id`
|
||||
- `PATCH /api/admin/v1/users/:id/status`(启用/禁用)
|
||||
- `PATCH /api/admin/v1/users/:id/role`
|
||||
|
||||
### 4.6 管理端平台管理
|
||||
### 4.7 管理端平台管理
|
||||
|
||||
- `GET /api/admin/v1/platforms`
|
||||
- `POST /api/admin/v1/platforms`
|
||||
- `PATCH /api/admin/v1/platforms/:id`
|
||||
- `PATCH /api/admin/v1/platforms/:id/health-check`
|
||||
|
||||
### 4.7 管理端统计
|
||||
### 4.8 管理端统计
|
||||
|
||||
- `GET /api/admin/v1/stats/overview?startDate=&endDate=`
|
||||
- `GET /api/admin/v1/stats/platforms`
|
||||
@@ -434,13 +458,14 @@ REDIS_KEY_PREFIX_ADMIN=chatone:admin
|
||||
- `enabled` (bool)
|
||||
- unique(platform_id, biz_model)
|
||||
|
||||
### 8.3 会话与消息(可选落库)
|
||||
### 8.3 会话与消息(已落库)
|
||||
|
||||
- `chat_sessions`
|
||||
- `id`
|
||||
- `user_id` (fk)
|
||||
- `title`
|
||||
- `created_at`, `updated_at`
|
||||
- index: `user_id`
|
||||
|
||||
- `chat_messages`
|
||||
- `id`
|
||||
@@ -450,6 +475,8 @@ REDIS_KEY_PREFIX_ADMIN=chatone:admin
|
||||
- `token_count` (int)
|
||||
- `provider` (varchar)
|
||||
- `created_at`
|
||||
- index: `session_id`
|
||||
- `session_id` 外键 `onDelete: Cascade`
|
||||
|
||||
### 8.4 请求审计与统计
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "chat_sessions" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"title" VARCHAR(200),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "chat_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"session_id" BIGINT NOT NULL,
|
||||
"role" VARCHAR(20) NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"token_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"provider" VARCHAR(64),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "chat_sessions_user_id_idx" ON "chat_sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "chat_messages_session_id_idx" ON "chat_messages"("session_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chat_sessions" ADD CONSTRAINT "chat_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "chat_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -15,5 +15,34 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
sessions ChatSession[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model ChatSession {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String? @db.VarChar(200)
|
||||
messages ChatMessage[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId])
|
||||
@@map("chat_sessions")
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sessionId BigInt @map("session_id")
|
||||
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
role String @db.VarChar(20)
|
||||
content String @db.Text
|
||||
tokenCount Int @default(0) @map("token_count")
|
||||
provider String? @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([sessionId])
|
||||
@@map("chat_messages")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Body, Controller, Inject, Post } from '@nestjs/common';
|
||||
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ClientAuthService } from './client-auth.service';
|
||||
import {
|
||||
ClientLoginResponseDto,
|
||||
ClientRefreshResponseDto,
|
||||
ClientSendSmsResponseDto,
|
||||
} from './dto/client-auth-response.dto';
|
||||
import {
|
||||
ClientLoginDto,
|
||||
ClientRefreshDto,
|
||||
@@ -17,90 +22,24 @@ export class ClientAuthController {
|
||||
|
||||
@Post('sms/send')
|
||||
@ApiOperation({ summary: '发送短信验证码(MVP 为 mock)' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: '发送短信验证码请求体',
|
||||
properties: {
|
||||
phone: { type: 'string', description: '手机号', example: '13800000000' },
|
||||
scene: { type: 'string', description: '业务场景', example: 'login' },
|
||||
},
|
||||
required: ['phone', 'scene'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: '发送成功(MVP mock)',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requestId: { type: 'string', example: 'sms_1710000000000' },
|
||||
phone: { type: 'string', example: '13800000000' },
|
||||
scene: { type: 'string', example: 'login' },
|
||||
expireIn: { type: 'number', example: 300 },
|
||||
testCode: { type: 'string', example: '123456' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiBody({ type: ClientSendSmsDto })
|
||||
@ApiOkResponse({ description: '发送成功(MVP mock)', type: ClientSendSmsResponseDto })
|
||||
async sendSms(@Body() body: ClientSendSmsDto) {
|
||||
return this.clientAuthService.sendSmsCode(body.phone, body.scene);
|
||||
}
|
||||
|
||||
@Post('sms/login')
|
||||
@ApiOperation({ summary: '短信验证码登录(MVP 固定验证码 123456)' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: '短信验证码登录请求体',
|
||||
properties: {
|
||||
phone: { type: 'string', description: '手机号', example: '13800000000' },
|
||||
code: { type: 'string', description: '短信验证码', example: '123456' },
|
||||
},
|
||||
required: ['phone', 'code'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: '登录成功返回 token',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: { type: 'string' },
|
||||
refreshToken: { type: 'string' },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'u_13800000000' },
|
||||
phone: { type: 'string', example: '13800000000' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiBody({ type: ClientLoginDto })
|
||||
@ApiOkResponse({ description: '登录成功返回 token', type: ClientLoginResponseDto })
|
||||
async smsLogin(@Body() body: ClientLoginDto) {
|
||||
return this.clientAuthService.loginBySms(body.phone, body.code);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: '刷新客户端 access token' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: 'refresh token 刷新请求体',
|
||||
properties: {
|
||||
refreshToken: { type: 'string', description: '刷新令牌' },
|
||||
},
|
||||
required: ['refreshToken'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: '刷新成功返回新 token',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: { type: 'string' },
|
||||
refreshToken: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiBody({ type: ClientRefreshDto })
|
||||
@ApiOkResponse({ description: '刷新成功返回新 token', type: ClientRefreshResponseDto })
|
||||
async refresh(@Body() body: ClientRefreshDto) {
|
||||
return this.clientAuthService.refreshToken(body.refreshToken);
|
||||
}
|
||||
|
||||
58
src/apps/client-app/auth/dto/client-auth-response.dto.ts
Normal file
58
src/apps/client-app/auth/dto/client-auth-response.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ClientSendSmsResponseDto {
|
||||
@ApiProperty({ type: String, example: 'sms_1710000000000' })
|
||||
requestId!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: '13800000000' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: 'login' })
|
||||
scene!: string;
|
||||
|
||||
@ApiProperty({ type: String, description: '短信渠道标识' })
|
||||
provider!: string;
|
||||
|
||||
@ApiProperty({ type: Number, example: 300, description: '验证码有效期(秒)' })
|
||||
expireIn!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: '非生产环境返回,便于联调',
|
||||
example: '123456',
|
||||
})
|
||||
testCode?: string;
|
||||
}
|
||||
|
||||
export class ClientAuthUserDto {
|
||||
@ApiProperty({ type: String, example: '1', description: '用户 ID(数字字符串)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: '13800000000' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: 'Chat0000' })
|
||||
nickname!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: 'https://example.com/a.png' })
|
||||
avatarUrl!: string;
|
||||
}
|
||||
|
||||
export class ClientLoginResponseDto {
|
||||
@ApiProperty({ type: String })
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty({ type: () => ClientAuthUserDto })
|
||||
user!: ClientAuthUserDto;
|
||||
}
|
||||
|
||||
export class ClientRefreshResponseDto {
|
||||
@ApiProperty({ type: String })
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ClientSendSmsDto {
|
||||
@ApiProperty({ type: String, description: '手机号', example: '13800000000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ type: String, description: '业务场景', example: 'login' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scene!: string;
|
||||
}
|
||||
|
||||
export class ClientLoginDto {
|
||||
@ApiProperty({ type: String, description: '手机号', example: '13800000000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ type: String, description: '短信验证码', example: '123456' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class ClientRefreshDto {
|
||||
@ApiProperty({ type: String, description: '刷新令牌' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
|
||||
260
src/apps/client-app/chat/application/chat-session.service.ts
Normal file
260
src/apps/client-app/chat/application/chat-session.service.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '@prisma/prisma.service';
|
||||
import {
|
||||
ChatMessage,
|
||||
ProviderUsage,
|
||||
StreamChatRequest,
|
||||
} from '@shared/ai-gateway/types/chat.types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
function parseBigIntId(value: string, field: string): bigint {
|
||||
try {
|
||||
return BigInt(value.trim());
|
||||
} catch {
|
||||
throw new BadRequestException(`${field} 格式无效`);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatSessionService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async createSession(userId: bigint, title?: string | null) {
|
||||
const row = await this.prisma.chatSession.create({
|
||||
data: {
|
||||
userId,
|
||||
title: title?.trim() || null,
|
||||
},
|
||||
});
|
||||
return this.mapSession(row);
|
||||
}
|
||||
|
||||
async listSessions(userId: bigint, limit: number, offset: number) {
|
||||
const take = Math.min(100, Math.max(1, limit));
|
||||
const skip = Math.max(0, offset);
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.chatSession.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take,
|
||||
skip,
|
||||
}),
|
||||
this.prisma.chatSession.count({ where: { userId } }),
|
||||
]);
|
||||
return {
|
||||
items: rows.map((r) => this.mapSession(r)),
|
||||
total,
|
||||
limit: take,
|
||||
offset: skip,
|
||||
};
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
userId: bigint,
|
||||
sessionIdStr: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) {
|
||||
const sessionId = parseBigIntId(sessionIdStr, 'sessionId');
|
||||
await this.assertSessionOwned(userId, sessionId);
|
||||
const take = Math.min(100, Math.max(1, limit));
|
||||
const skip = Math.max(0, offset);
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.chatMessage.findMany({
|
||||
where: { sessionId },
|
||||
orderBy: { id: 'asc' },
|
||||
take,
|
||||
skip,
|
||||
}),
|
||||
this.prisma.chatMessage.count({ where: { sessionId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
sessionId: String(sessionId),
|
||||
items: rows.map((m) => ({
|
||||
id: String(m.id),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
tokenCount: m.tokenCount,
|
||||
provider: m.provider,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
limit: take,
|
||||
offset: skip,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSession(userId: bigint, sessionIdStr: string) {
|
||||
const sessionId = parseBigIntId(sessionIdStr, 'sessionId');
|
||||
await this.assertSessionOwned(userId, sessionId);
|
||||
const deleted = await this.prisma.chatSession.delete({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
return this.mapSession(deleted);
|
||||
}
|
||||
|
||||
async updateSessionTitle(userId: bigint, sessionIdStr: string, title: string) {
|
||||
const sessionId = parseBigIntId(sessionIdStr, 'sessionId');
|
||||
await this.assertSessionOwned(userId, sessionId);
|
||||
const updated = await this.prisma.chatSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { title: title.trim() || null },
|
||||
});
|
||||
return this.mapSession(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验会话;stream 场景必须传入 sessionId
|
||||
*/
|
||||
async resolveSessionForStream(
|
||||
userId: bigint,
|
||||
body: StreamChatRequest,
|
||||
): Promise<bigint> {
|
||||
const raw = body.sessionId?.trim();
|
||||
if (!raw) {
|
||||
throw new BadRequestException('sessionId 必填,请先创建会话');
|
||||
}
|
||||
const sessionId = parseBigIntId(raw, 'sessionId');
|
||||
await this.assertSessionOwned(userId, sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async persistRoundtrip(
|
||||
sessionId: bigint,
|
||||
messages: ChatMessage[],
|
||||
assistantText: string,
|
||||
providerCode: string,
|
||||
usage: ProviderUsage,
|
||||
) {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
||||
if (lastUser?.content) {
|
||||
await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: lastUser.content,
|
||||
tokenCount: 0,
|
||||
provider: null,
|
||||
},
|
||||
});
|
||||
const session = await this.prisma.chatSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { title: true },
|
||||
});
|
||||
if (session && !session.title) {
|
||||
const t = lastUser.content.trim().slice(0, 80);
|
||||
if (t) {
|
||||
await this.prisma.chatSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { title: t },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: assistantText,
|
||||
tokenCount: usage.completionTokens ?? 0,
|
||||
provider: providerCode,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.$executeRaw(
|
||||
Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为了让前端在 SSE 结束后立即看到会话标题,先持久化用户消息与标题。
|
||||
*/
|
||||
async persistUserMessageAndTitle(sessionId: bigint, messages: ChatMessage[]) {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
||||
if (!lastUser?.content) return;
|
||||
|
||||
await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: lastUser.content,
|
||||
tokenCount: 0,
|
||||
provider: null,
|
||||
},
|
||||
});
|
||||
|
||||
const title = lastUser.content.trim().slice(0, 80);
|
||||
if (title) {
|
||||
await this.prisma.chatSession.updateMany({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [{ title: null }, { title: '' }],
|
||||
},
|
||||
data: { title },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw(
|
||||
Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async persistAssistantMessage(
|
||||
sessionId: bigint,
|
||||
assistantText: string,
|
||||
providerCode: string,
|
||||
usage: ProviderUsage,
|
||||
) {
|
||||
await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: assistantText,
|
||||
tokenCount: usage.completionTokens ?? 0,
|
||||
provider: providerCode,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.$executeRaw(
|
||||
Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertSessionOwned(userId: bigint, sessionId: bigint) {
|
||||
const row = await this.prisma.chatSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!row) {
|
||||
throw new NotFoundException('会话不存在');
|
||||
}
|
||||
if (row.userId !== userId) {
|
||||
throw new ForbiddenException('无权访问该会话');
|
||||
}
|
||||
}
|
||||
|
||||
private mapSession(row: {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
title: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
title: row.title ?? '',
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './controllers/chat.controller';
|
||||
import { ChatSessionsController } from './controllers/chat-sessions.controller';
|
||||
import { ChatSessionService } from './application/chat-session.service';
|
||||
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||
import { QwenProvider } from '@shared/ai-gateway/providers/qwen.provider';
|
||||
import { DeepseekProvider } from '@shared/ai-gateway/providers/deepseek.provider';
|
||||
import { VolcProvider } from '@shared/ai-gateway/providers/volc.provider';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ProviderRouterService, QwenProvider, DeepseekProvider, VolcProvider],
|
||||
controllers: [ChatController, ChatSessionsController],
|
||||
providers: [
|
||||
ChatSessionService,
|
||||
ProviderRouterService,
|
||||
QwenProvider,
|
||||
DeepseekProvider,
|
||||
VolcProvider,
|
||||
],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
|
||||
143
src/apps/client-app/chat/controllers/chat-sessions.controller.ts
Normal file
143
src/apps/client-app/chat/controllers/chat-sessions.controller.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Inject,
|
||||
Patch,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
||||
import { ChatSessionService } from '../application/chat-session.service';
|
||||
import { PaginationQueryDto } from '../dto/chat-session-query.dto';
|
||||
import {
|
||||
ChatMessageListResponseDto,
|
||||
ChatSessionListResponseDto,
|
||||
ChatSessionRowDto,
|
||||
} from '../dto/chat-session-response.dto';
|
||||
import { CreateChatSessionDto } from '../dto/create-chat-session.dto';
|
||||
import { UpdateChatSessionTitleDto } from '../dto/update-chat-session-title.dto';
|
||||
|
||||
type ClientJwtRequest = FastifyRequest & { user: { userId: string } };
|
||||
|
||||
@ApiTags('Client Chat')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('client/v1/chat/sessions')
|
||||
@UseGuards(ClientJwtAuthGuard)
|
||||
export class ChatSessionsController {
|
||||
constructor(
|
||||
@Inject(ChatSessionService)
|
||||
private readonly chatSessions: ChatSessionService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: '创建空会话' })
|
||||
@ApiBody({ type: CreateChatSessionDto })
|
||||
@ApiOkResponse({ type: ChatSessionRowDto })
|
||||
async create(@Req() req: ClientJwtRequest, @Body() body: CreateChatSessionDto) {
|
||||
const userId = BigInt(req.user.userId);
|
||||
return this.chatSessions.createSession(userId, body.title);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: '会话列表' })
|
||||
@ApiOkResponse({ type: ChatSessionListResponseDto })
|
||||
async list(@Req() req: ClientJwtRequest, @Query() query: PaginationQueryDto) {
|
||||
const userId = BigInt(req.user.userId);
|
||||
const limit = Math.min(100, Math.max(1, query.limit ?? 20));
|
||||
const offset = Math.max(0, query.offset ?? 0);
|
||||
return this.chatSessions.listSessions(userId, limit, offset);
|
||||
}
|
||||
|
||||
@Get(':sessionId/messages')
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: '会话消息分页' })
|
||||
@ApiParam({
|
||||
name: 'sessionId',
|
||||
description: '会话 ID(数字字符串)',
|
||||
example: '1',
|
||||
})
|
||||
@ApiOkResponse({ type: ChatMessageListResponseDto })
|
||||
async messages(
|
||||
@Req() req: ClientJwtRequest,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Query() query: PaginationQueryDto,
|
||||
) {
|
||||
const userId = BigInt(req.user.userId);
|
||||
const limit = Math.min(100, Math.max(1, query.limit ?? 50));
|
||||
const offset = Math.max(0, query.offset ?? 0);
|
||||
return this.chatSessions.listMessages(userId, sessionId, limit, offset);
|
||||
}
|
||||
|
||||
@Delete(':sessionId')
|
||||
@ApiOperation({ summary: '删除会话(级联删除消息)' })
|
||||
@ApiParam({
|
||||
name: 'sessionId',
|
||||
description: '会话 ID(数字字符串)',
|
||||
example: '1',
|
||||
})
|
||||
@ApiOkResponse({ type: ChatSessionRowDto })
|
||||
async remove(
|
||||
@Req() req: ClientJwtRequest,
|
||||
@Param('sessionId') sessionId: string,
|
||||
) {
|
||||
const userId = BigInt(req.user.userId);
|
||||
return this.chatSessions.deleteSession(userId, sessionId);
|
||||
}
|
||||
|
||||
@Patch(':sessionId/title')
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: '修改会话标题' })
|
||||
@ApiParam({
|
||||
name: 'sessionId',
|
||||
description: '会话 ID(数字字符串)',
|
||||
example: '1',
|
||||
})
|
||||
@ApiBody({ type: UpdateChatSessionTitleDto })
|
||||
@ApiOkResponse({ type: ChatSessionRowDto })
|
||||
async updateTitle(
|
||||
@Req() req: ClientJwtRequest,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: UpdateChatSessionTitleDto,
|
||||
) {
|
||||
const userId = BigInt(req.user.userId);
|
||||
return this.chatSessions.updateSessionTitle(userId, sessionId, body.title);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { Body, Controller, Inject, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||
import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
||||
import { ChatSessionService } from '../application/chat-session.service';
|
||||
import { StreamChatBodyDto } from '../dto/stream-chat.dto';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -21,13 +33,19 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
type ClientJwtRequest = FastifyRequest & { user: { userId: string } };
|
||||
|
||||
@ApiTags('Client Chat')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('client/v1/chat')
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ProviderRouterService)
|
||||
private readonly router: ProviderRouterService,
|
||||
@Inject(ChatSessionService)
|
||||
private readonly chatSessions: ChatSessionService,
|
||||
) {}
|
||||
|
||||
@Post('completions/stream')
|
||||
@@ -36,62 +54,41 @@ export class ChatController {
|
||||
@ApiConsumes('application/json')
|
||||
@ApiProduces('text/event-stream')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: '统一 chat 请求体,支持指定平台或自动路由',
|
||||
properties: {
|
||||
model: {
|
||||
type: 'string',
|
||||
description: '模型名(不传时 provider 使用默认模型)',
|
||||
example: 'qwen-plus',
|
||||
},
|
||||
platform: {
|
||||
type: 'string',
|
||||
description: '目标平台(auto 或不传表示自动路由)',
|
||||
enum: ['auto', 'qwen', 'deepseek', 'volc'],
|
||||
example: 'qwen',
|
||||
},
|
||||
messages: {
|
||||
type: 'array',
|
||||
description: '聊天消息列表',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
role: {
|
||||
type: 'string',
|
||||
description: '消息角色',
|
||||
enum: ['system', 'user', 'assistant'],
|
||||
example: 'user',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '消息文本内容',
|
||||
example: '你是谁',
|
||||
},
|
||||
},
|
||||
required: ['role', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['messages'],
|
||||
},
|
||||
type: StreamChatBodyDto,
|
||||
description: '统一 chat 请求体;需先手动创建会话并传入 sessionId',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'SSE 流式响应:meta -> delta -> usage -> done',
|
||||
schema: {
|
||||
type: 'string',
|
||||
example:
|
||||
'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus"}\\n\\n' +
|
||||
'event: delta\\ndata: {"delta":"你好"}\\n\\n' +
|
||||
'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' +
|
||||
'event: done\\ndata: {"finishReason":"stop"}\\n\\n',
|
||||
description:
|
||||
'SSE 流式响应:event 序列为 meta → delta(多次)→ usage → done',
|
||||
content: {
|
||||
'text/event-stream': {
|
||||
schema: {
|
||||
type: 'string',
|
||||
example:
|
||||
'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus","sessionId":"1"}\\n\\n' +
|
||||
'event: delta\\ndata: {"delta":"你好"}\\n\\n' +
|
||||
'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' +
|
||||
'event: done\\ndata: {"finishReason":"stop"}\\n\\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
)
|
||||
async streamChat(
|
||||
@Body() body: StreamChatRequest,
|
||||
@Body() body: StreamChatBodyDto,
|
||||
@Req() req: ClientJwtRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const response = await this.router.routeAndStream(body);
|
||||
const userId = BigInt(req.user.userId);
|
||||
const sessionId = await this.chatSessions.resolveSessionForStream(
|
||||
userId,
|
||||
body,
|
||||
);
|
||||
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
@@ -99,22 +96,51 @@ export class ChatController {
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const response = await this.router.routeAndStream(body);
|
||||
|
||||
reply.raw.write(
|
||||
formatSse('meta', {
|
||||
requestId: response.requestId,
|
||||
platform: response.providerCode,
|
||||
model: response.model,
|
||||
sessionId: String(sessionId),
|
||||
}),
|
||||
);
|
||||
|
||||
let assistantText = '';
|
||||
for (const chunk of response.chunks) {
|
||||
assistantText += chunk.content;
|
||||
reply.raw.write(formatSse('delta', { delta: chunk.content }));
|
||||
await sleep(120);
|
||||
}
|
||||
|
||||
// 在 done 之前完成用户消息与标题落库,确保前端紧接着查列表能看到最新标题。
|
||||
try {
|
||||
await this.chatSessions.persistUserMessageAndTitle(sessionId, body.messages);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, sessionId: String(sessionId) },
|
||||
'persist user message/title failed',
|
||||
);
|
||||
}
|
||||
|
||||
reply.raw.write(formatSse('usage', response.usage));
|
||||
reply.raw.write(formatSse('done', { finishReason: 'stop' }));
|
||||
reply.raw.end();
|
||||
|
||||
try {
|
||||
await this.chatSessions.persistAssistantMessage(
|
||||
sessionId,
|
||||
assistantText,
|
||||
response.providerCode,
|
||||
response.usage,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, sessionId: String(sessionId) },
|
||||
'persist chat roundtrip failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/apps/client-app/chat/dto/chat-session-query.dto.ts
Normal file
32
src/apps/client-app/chat/dto/chat-session-query.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
/** 会话列表、消息列表共用的分页查询(limit 默认值在控制器中按路由区分) */
|
||||
export class PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description: '分页条数;会话列表默认 20,消息列表默认 50',
|
||||
example: 20,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
minimum: 0,
|
||||
description: '偏移量',
|
||||
example: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
80
src/apps/client-app/chat/dto/chat-session-response.dto.ts
Normal file
80
src/apps/client-app/chat/dto/chat-session-response.dto.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ChatSessionRowDto {
|
||||
@ApiProperty({ type: String, example: '1', description: '会话 ID(数字字符串)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: '1', description: '用户 ID(数字字符串)' })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: '标题(可能为空字符串)',
|
||||
example: '你好',
|
||||
})
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ type: String, description: '创建时间 ISO8601' })
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ type: String, description: '更新时间 ISO8601' })
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
export class ChatSessionListResponseDto {
|
||||
@ApiProperty({ type: () => ChatSessionRowDto, isArray: true })
|
||||
items!: ChatSessionRowDto[];
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
limit!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
offset!: number;
|
||||
}
|
||||
|
||||
export class ChatMessageRowDto {
|
||||
@ApiProperty({ type: String, example: '1', description: '消息 ID(数字字符串)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: 'user' })
|
||||
role!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
content!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
description: 'completion token 数(用户消息为 0)',
|
||||
})
|
||||
tokenCount!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: '大模型渠道(用户消息一般为 null)',
|
||||
})
|
||||
provider?: string | null;
|
||||
|
||||
@ApiProperty({ type: String, description: '创建时间 ISO8601' })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class ChatMessageListResponseDto {
|
||||
@ApiProperty({ type: String, example: '1' })
|
||||
sessionId!: string;
|
||||
|
||||
@ApiProperty({ type: () => ChatMessageRowDto, isArray: true })
|
||||
items!: ChatMessageRowDto[];
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
limit!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
offset!: number;
|
||||
}
|
||||
10
src/apps/client-app/chat/dto/create-chat-session.dto.ts
Normal file
10
src/apps/client-app/chat/dto/create-chat-session.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateChatSessionDto {
|
||||
@ApiPropertyOptional({ type: String, description: '会话标题', maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
}
|
||||
@@ -3,24 +3,38 @@ import { Type } from 'class-transformer';
|
||||
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||
|
||||
class ChatMessageDto {
|
||||
@ApiProperty({ enum: ['system', 'user', 'assistant'] })
|
||||
export class ChatMessageDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
enum: ['system', 'user', 'assistant'],
|
||||
description: '消息角色',
|
||||
example: 'user',
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(['system', 'user', 'assistant'])
|
||||
role!: 'system' | 'user' | 'assistant';
|
||||
|
||||
@ApiProperty({ description: '消息内容', example: '你是谁' })
|
||||
@ApiProperty({ type: String, description: '消息内容', example: '你是谁' })
|
||||
@IsString()
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class StreamChatBodyDto implements StreamChatRequest {
|
||||
@ApiPropertyOptional({ description: '模型名', example: 'qwen-plus' })
|
||||
@ApiPropertyOptional({ type: String, description: '模型名', example: 'qwen-plus' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
model?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: '会话 ID(数字字符串);需先调用创建会话接口获取',
|
||||
example: '1',
|
||||
})
|
||||
@IsString()
|
||||
sessionId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: '指定平台,不传或 auto 由路由自动选择',
|
||||
enum: ['auto', 'qwen', 'deepseek', 'volc'],
|
||||
example: 'qwen',
|
||||
@@ -29,7 +43,11 @@ export class StreamChatBodyDto implements StreamChatRequest {
|
||||
@IsString()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ type: [ChatMessageDto] })
|
||||
@ApiProperty({
|
||||
type: () => ChatMessageDto,
|
||||
isArray: true,
|
||||
description: '聊天消息列表(至少一条用户消息)',
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateChatSessionTitleDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: '会话标题(可传空字符串清空)',
|
||||
maxLength: 200,
|
||||
example: '产品需求讨论',
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface ChatMessage {
|
||||
export interface StreamChatRequest {
|
||||
model?: string;
|
||||
platform?: string; // qwen | deepseek | volc | auto | demo
|
||||
/** 已有会话 ID;不传则本次对话新建会话并落库 */
|
||||
sessionId?: string;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user