From 8c358a21f71329cdb7d92e8004e9dca21cc7093d Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 14 Aug 2025 11:23:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8=E3=80=81=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E4=BC=9A=E8=AF=9D=E8=AF=A6=E6=83=85=E5=92=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=BC=9A=E8=AF=9D=E7=9A=84API=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0AI=E6=95=99=E7=BB=83=E6=A8=A1=E5=9D=97=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BC=9A=E8=AF=9D=E6=A8=A1=E5=9E=8B=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=9B=B8=E5=85=B3=E6=9C=8D=E5=8A=A1=E5=92=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BC=A0=E8=BE=93=E5=AF=B9=E8=B1=A1=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai-coach/ai-coach.controller.ts | 35 ++++++++- src/ai-coach/ai-coach.module.ts | 3 +- src/ai-coach/ai-coach.service.ts | 75 +++++++++++++++++--- src/ai-coach/dto/ai-chat.dto.ts | 3 +- src/ai-coach/models/ai-conversation.model.ts | 53 ++++++++++++++ src/ai-coach/models/ai-message.model.ts | 8 ++- src/users/users.service.ts | 17 ++--- 7 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 src/ai-coach/models/ai-conversation.model.ts diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index c4b2abe..7ba31d7 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -1,5 +1,5 @@ -import { Body, Controller, Post, Res, StreamableFile, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, Param, Post, Query, Res, StreamableFile, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; @@ -70,6 +70,37 @@ export class AiCoachController { 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 }; + } } diff --git a/src/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts index 2f79be2..f0b359d 100644 --- a/src/ai-coach/ai-coach.module.ts +++ b/src/ai-coach/ai-coach.module.ts @@ -4,13 +4,14 @@ import { ConfigModule } from '@nestjs/config'; import { AiCoachController } from './ai-coach.controller'; import { AiCoachService } from './ai-coach.service'; import { AiMessage } from './models/ai-message.model'; +import { AiConversation } from './models/ai-conversation.model'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ ConfigModule, UsersModule, - SequelizeModule.forFeature([AiMessage]), + SequelizeModule.forFeature([AiConversation, AiMessage]), ], controllers: [AiCoachController], providers: [AiCoachService], diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index 59d2403..9513f0f 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -3,14 +3,9 @@ import { ConfigService } from '@nestjs/config'; import { OpenAI } from 'openai'; import { Readable } from 'stream'; import { AiMessage, RoleType } from './models/ai-message.model'; +import { AiConversation } from './models/ai-conversation.model'; -const SYSTEM_PROMPT = `你是一位资深的普拉提与运动康复教练(Pilates Coach),具备运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性、营养与饮食建议等专业知识。 -请遵循以下规则作答: -1) 话题范围仅限:健康、健身、普拉提、康复、形体训练、柔韧性、力量训练、运动损伤预防与恢复、营养与饮食。 -2) 拒绝回答医疗诊断、情感心理、时政金融、编程等不相关或高风险话题,礼貌解释并引导回合适范围。 -3) 语言风格:亲切、专业、简洁分点回答;必要时提供可在家执行的分步骤方案与注意事项;给出不同水平与疼痛人群的替代动作与安全提示。 -4) 强调循序渐进与个体差异,避免绝对化表述;涉及疼痛或既往伤病时,建议在医生评估后进行训练。 -5) 所有训练建议默认不需要器械或仅需常见小器械(瑜伽垫、弹力带、泡沫轴等),估算时长与频率,并提供跟踪与自测方法。`; +const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`; @Injectable() export class AiCoachService { @@ -19,7 +14,7 @@ export class AiCoachService { private readonly model: string; constructor(private readonly configService: ConfigService) { - const dashScopeApiKey = this.configService.get('DASHSCOPE_API_KEY') || ''; + const dashScopeApiKey = this.configService.get('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143'; const baseURL = this.configService.get('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; this.client = new OpenAI({ @@ -27,7 +22,7 @@ export class AiCoachService { baseURL, }); // 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖 - this.model = this.configService.get('DASHSCOPE_MODEL') || 'qwen-plus'; + this.model = this.configService.get('DASHSCOPE_MODEL') || 'qwen-flash'; } async createOrAppendMessages(params: { @@ -35,7 +30,13 @@ export class AiCoachService { conversationId?: string; userContent: 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({ conversationId, userId: params.userId, @@ -95,6 +96,7 @@ export class AiCoachService { content: assistantContent, metadata: { model: this.model }, }); + await AiConversation.update({ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) }, { where: { id: params.conversationId, userId: params.userId } }); } catch (error) { this.logger.error(`stream error: ${error?.message || error}`); readable.push('\n[对话发生错误,请稍后重试]'); @@ -105,6 +107,59 @@ export class AiCoachService { 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 { + 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; + } } diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts index ffe9675..2eb2cde 100644 --- a/src/ai-coach/dto/ai-chat.dto.ts +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -1,5 +1,5 @@ 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 { @ApiProperty({ enum: ['user', 'assistant', 'system'] }) @@ -35,3 +35,4 @@ export class AiChatResponseDto { } + diff --git a/src/ai-coach/models/ai-conversation.model.ts b/src/ai-coach/models/ai-conversation.model.ts new file mode 100644 index 0000000..75e4c14 --- /dev/null +++ b/src/ai-coach/models/ai-conversation.model.ts @@ -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[]; +} + + diff --git a/src/ai-coach/models/ai-message.model.ts b/src/ai-coach/models/ai-message.model.ts index 845a581..8a509e6 100644 --- a/src/ai-coach/models/ai-message.model.ts +++ b/src/ai-coach/models/ai-message.model.ts @@ -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 { System = 'system', @@ -12,11 +13,11 @@ export enum RoleType { }) export class AiMessage extends Model { @PrimaryKey + @ForeignKey(() => AiConversation) @Column({ type: DataType.STRING, allowNull: false, comment: '会话ID', - primaryKey: true, }) declare conversationId: string; @@ -57,6 +58,9 @@ export class AiMessage extends Model { defaultValue: DataType.NOW, }) declare updatedAt: Date; + + @BelongsTo(() => AiConversation) + declare conversation?: AiConversation; } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index aa07389..83f3660 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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 = { ...existingUser.toJSON(), maxUsageCount: DEFAULT_FREE_USAGE_COUNT, isVip: existingUser.isVip, - profile: profile ? { - dailyStepsGoal: profile.dailyStepsGoal, - dailyCaloriesGoal: profile.dailyCaloriesGoal, - pilatesPurposes: profile.pilatesPurposes, - weight: profile.weight, - height: profile.height, - } : undefined, + dailyStepsGoal: profile?.dailyStepsGoal, + dailyCaloriesGoal: profile?.dailyCaloriesGoal, + pilatesPurposes: profile?.pilatesPurposes, + weight: profile?.weight, + height: profile?.height, } this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`);