新增会话管理功能,包括获取会话列表、获取会话详情和删除会话的API,更新AI教练模块以支持会话模型,调整相关服务和数据传输对象。
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Post, Res, StreamableFile, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Query, Res, StreamableFile, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
@@ -70,6 +70,37 @@ export class AiCoachController {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('conversations')
|
||||||
|
@ApiOperation({ summary: '获取会话列表' })
|
||||||
|
async listConversations(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Query() query: { page?: number, pageSize?: number },
|
||||||
|
) {
|
||||||
|
return this.aiCoachService.listConversations(user.sub, { page: query.page, pageSize: query.pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('conversations/:conversationId')
|
||||||
|
@ApiOperation({ summary: '获取会话详情(包含消息)' })
|
||||||
|
@ApiParam({ name: 'conversationId' })
|
||||||
|
async getConversationDetail(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('conversationId') conversationId: string,
|
||||||
|
) {
|
||||||
|
const data = await this.aiCoachService.getConversationDetail(user.sub, conversationId);
|
||||||
|
return data || { code: 404, message: '会话不存在' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('conversations/:conversationId')
|
||||||
|
@ApiOperation({ summary: '删除会话(级联删除消息)' })
|
||||||
|
@ApiParam({ name: 'conversationId' })
|
||||||
|
async deleteConversation(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('conversationId') conversationId: string,
|
||||||
|
) {
|
||||||
|
const ok = await this.aiCoachService.deleteConversation(user.sub, conversationId);
|
||||||
|
return { success: ok };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { AiCoachController } from './ai-coach.controller';
|
import { AiCoachController } from './ai-coach.controller';
|
||||||
import { AiCoachService } from './ai-coach.service';
|
import { AiCoachService } from './ai-coach.service';
|
||||||
import { AiMessage } from './models/ai-message.model';
|
import { AiMessage } from './models/ai-message.model';
|
||||||
|
import { AiConversation } from './models/ai-conversation.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SequelizeModule.forFeature([AiMessage]),
|
SequelizeModule.forFeature([AiConversation, AiMessage]),
|
||||||
],
|
],
|
||||||
controllers: [AiCoachController],
|
controllers: [AiCoachController],
|
||||||
providers: [AiCoachService],
|
providers: [AiCoachService],
|
||||||
|
|||||||
@@ -3,14 +3,9 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { OpenAI } from 'openai';
|
import { OpenAI } from 'openai';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { AiMessage, RoleType } from './models/ai-message.model';
|
import { AiMessage, RoleType } from './models/ai-message.model';
|
||||||
|
import { AiConversation } from './models/ai-conversation.model';
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `你是一位资深的普拉提与运动康复教练(Pilates Coach),具备运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性、营养与饮食建议等专业知识。
|
const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`;
|
||||||
请遵循以下规则作答:
|
|
||||||
1) 话题范围仅限:健康、健身、普拉提、康复、形体训练、柔韧性、力量训练、运动损伤预防与恢复、营养与饮食。
|
|
||||||
2) 拒绝回答医疗诊断、情感心理、时政金融、编程等不相关或高风险话题,礼貌解释并引导回合适范围。
|
|
||||||
3) 语言风格:亲切、专业、简洁分点回答;必要时提供可在家执行的分步骤方案与注意事项;给出不同水平与疼痛人群的替代动作与安全提示。
|
|
||||||
4) 强调循序渐进与个体差异,避免绝对化表述;涉及疼痛或既往伤病时,建议在医生评估后进行训练。
|
|
||||||
5) 所有训练建议默认不需要器械或仅需常见小器械(瑜伽垫、弹力带、泡沫轴等),估算时长与频率,并提供跟踪与自测方法。`;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiCoachService {
|
export class AiCoachService {
|
||||||
@@ -19,7 +14,7 @@ export class AiCoachService {
|
|||||||
private readonly model: string;
|
private readonly model: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || '';
|
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
||||||
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||||
|
|
||||||
this.client = new OpenAI({
|
this.client = new OpenAI({
|
||||||
@@ -27,7 +22,7 @@ export class AiCoachService {
|
|||||||
baseURL,
|
baseURL,
|
||||||
});
|
});
|
||||||
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
||||||
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-plus';
|
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrAppendMessages(params: {
|
async createOrAppendMessages(params: {
|
||||||
@@ -35,7 +30,13 @@ export class AiCoachService {
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
userContent: string;
|
userContent: string;
|
||||||
}): Promise<{ conversationId: string }> {
|
}): Promise<{ conversationId: string }> {
|
||||||
const conversationId = params.conversationId || `${params.userId}-${Date.now()}`;
|
let conversationId = params.conversationId;
|
||||||
|
if (!conversationId) {
|
||||||
|
conversationId = `${params.userId}-${Date.now()}`;
|
||||||
|
await AiConversation.create({ id: conversationId, userId: params.userId, title: null, lastMessageAt: new Date() });
|
||||||
|
} else {
|
||||||
|
await AiConversation.upsert({ id: conversationId, userId: params.userId, lastMessageAt: new Date() });
|
||||||
|
}
|
||||||
await AiMessage.create({
|
await AiMessage.create({
|
||||||
conversationId,
|
conversationId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
@@ -95,6 +96,7 @@ export class AiCoachService {
|
|||||||
content: assistantContent,
|
content: assistantContent,
|
||||||
metadata: { model: this.model },
|
metadata: { model: this.model },
|
||||||
});
|
});
|
||||||
|
await AiConversation.update({ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) }, { where: { id: params.conversationId, userId: params.userId } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`stream error: ${error?.message || error}`);
|
this.logger.error(`stream error: ${error?.message || error}`);
|
||||||
readable.push('\n[对话发生错误,请稍后重试]');
|
readable.push('\n[对话发生错误,请稍后重试]');
|
||||||
@@ -105,6 +107,59 @@ export class AiCoachService {
|
|||||||
|
|
||||||
return readable;
|
return readable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deriveTitleIfEmpty(assistantReply: string): string | null {
|
||||||
|
if (!assistantReply) return null;
|
||||||
|
const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || '';
|
||||||
|
return firstLine.slice(0, 50) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listConversations(userId: string, params: { page?: number; pageSize?: number }) {
|
||||||
|
const page = Math.max(1, params.page || 1);
|
||||||
|
const pageSize = Math.min(50, Math.max(1, params.pageSize || 20));
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const { rows, count } = await AiConversation.findAndCountAll({
|
||||||
|
where: { userId },
|
||||||
|
order: [['last_message_at', 'DESC']],
|
||||||
|
offset,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: count,
|
||||||
|
items: rows.map((c) => ({
|
||||||
|
conversationId: c.id,
|
||||||
|
title: c.title,
|
||||||
|
lastMessageAt: c.lastMessageAt,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationDetail(userId: string, conversationId: string) {
|
||||||
|
const conv = await AiConversation.findOne({ where: { id: conversationId, userId } });
|
||||||
|
if (!conv) return null;
|
||||||
|
const messages = await AiMessage.findAll({
|
||||||
|
where: { userId, conversationId },
|
||||||
|
order: [['created_at', 'ASC']],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
conversationId: conv.id,
|
||||||
|
title: conv.title,
|
||||||
|
lastMessageAt: conv.lastMessageAt,
|
||||||
|
createdAt: conv.createdAt,
|
||||||
|
messages: messages.map((m) => ({ role: m.role, content: m.content, createdAt: m.createdAt })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConversation(userId: string, conversationId: string): Promise<boolean> {
|
||||||
|
const conv = await AiConversation.findOne({ where: { id: conversationId, userId } });
|
||||||
|
if (!conv) return false;
|
||||||
|
await AiMessage.destroy({ where: { userId, conversationId } });
|
||||||
|
await AiConversation.destroy({ where: { id: conversationId, userId } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, IsInt, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
export class AiChatMessageDto {
|
export class AiChatMessageDto {
|
||||||
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
|
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
|
||||||
@@ -35,3 +35,4 @@ export class AiChatResponseDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
53
src/ai-coach/models/ai-conversation.model.ts
Normal file
53
src/ai-coach/models/ai-conversation.model.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Column, DataType, HasMany, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||||
|
import { AiMessage } from './ai-message.model';
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_ai_conversations',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class AiConversation extends Model {
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
})
|
||||||
|
declare id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '用户ID',
|
||||||
|
})
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '会话标题(可选)',
|
||||||
|
})
|
||||||
|
declare title: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '最后一条消息时间',
|
||||||
|
})
|
||||||
|
declare lastMessageAt: Date | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
})
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
})
|
||||||
|
declare updatedAt: Date;
|
||||||
|
|
||||||
|
@HasMany(() => AiMessage)
|
||||||
|
declare messages?: AiMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
import { BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||||
|
import { AiConversation } from './ai-conversation.model';
|
||||||
|
|
||||||
export enum RoleType {
|
export enum RoleType {
|
||||||
System = 'system',
|
System = 'system',
|
||||||
@@ -12,11 +13,11 @@ export enum RoleType {
|
|||||||
})
|
})
|
||||||
export class AiMessage extends Model {
|
export class AiMessage extends Model {
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
|
@ForeignKey(() => AiConversation)
|
||||||
@Column({
|
@Column({
|
||||||
type: DataType.STRING,
|
type: DataType.STRING,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
comment: '会话ID',
|
comment: '会话ID',
|
||||||
primaryKey: true,
|
|
||||||
})
|
})
|
||||||
declare conversationId: string;
|
declare conversationId: string;
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ export class AiMessage extends Model {
|
|||||||
defaultValue: DataType.NOW,
|
defaultValue: DataType.NOW,
|
||||||
})
|
})
|
||||||
declare updatedAt: Date;
|
declare updatedAt: Date;
|
||||||
|
|
||||||
|
@BelongsTo(() => AiConversation)
|
||||||
|
declare conversation?: AiConversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,18 +80,19 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await this.userProfileModel.findByPk(existingUser.id);
|
const [profile] = await this.userProfileModel.findOrCreate({
|
||||||
|
where: { userId: existingUser.id },
|
||||||
|
defaults: { userId: existingUser.id },
|
||||||
|
});
|
||||||
const returnData = {
|
const returnData = {
|
||||||
...existingUser.toJSON(),
|
...existingUser.toJSON(),
|
||||||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||||||
isVip: existingUser.isVip,
|
isVip: existingUser.isVip,
|
||||||
profile: profile ? {
|
dailyStepsGoal: profile?.dailyStepsGoal,
|
||||||
dailyStepsGoal: profile.dailyStepsGoal,
|
dailyCaloriesGoal: profile?.dailyCaloriesGoal,
|
||||||
dailyCaloriesGoal: profile.dailyCaloriesGoal,
|
pilatesPurposes: profile?.pilatesPurposes,
|
||||||
pilatesPurposes: profile.pilatesPurposes,
|
weight: profile?.weight,
|
||||||
weight: profile.weight,
|
height: profile?.height,
|
||||||
height: profile.height,
|
|
||||||
} : undefined,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`);
|
this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user