新增AI教练模块,包括控制器、服务、模型及数据传输对象,更新应用模块以引入新模块,同时在打卡模块中添加按时间范围返回每日打卡状态的功能

This commit is contained in:
richarjiang
2025-08-14 09:12:44 +08:00
parent 866143d3ad
commit d1a6e3d42e
15 changed files with 556 additions and 5 deletions

View File

@@ -0,0 +1,75 @@
import { Body, Controller, Post, Res, StreamableFile, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { AiCoachService } from './ai-coach.service';
import { AiChatRequestDto, AiChatResponseDto } from './dto/ai-chat.dto';
@ApiTags('ai-coach')
@Controller('ai-coach')
@UseGuards(JwtAuthGuard)
export class AiCoachController {
constructor(private readonly aiCoachService: AiCoachService) { }
@Post('chat')
@ApiOperation({ summary: '流式大模型对话(普拉提教练)' })
@ApiBody({ type: AiChatRequestDto })
async chat(
@Body() body: AiChatRequestDto,
@CurrentUser() user: AccessTokenPayload,
@Res({ passthrough: false }) res: Response,
): Promise<StreamableFile | AiChatResponseDto | void> {
const userId = user.sub;
const stream = body.stream !== false; // 默认流式
// 创建或沿用会话ID并保存用户消息
const { conversationId } = await this.aiCoachService.createOrAppendMessages({
userId,
conversationId: body.conversationId,
userContent: body.messages?.[body.messages.length - 1]?.content || '',
});
if (!stream) {
// 非流式:聚合后一次性返回文本
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent: body.messages?.[body.messages.length - 1]?.content || '',
});
let text = '';
for await (const chunk of readable) {
text += chunk.toString();
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send({ conversationId, text });
return;
}
// 流式SSE/文本流
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Transfer-Encoding', 'chunked');
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent: body.messages?.[body.messages.length - 1]?.content || '',
});
readable.on('data', (chunk) => {
res.write(chunk);
});
readable.on('end', () => {
res.end();
});
readable.on('error', () => {
res.end();
});
return;
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
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 { UsersModule } from '../users/users.module';
@Module({
imports: [
ConfigModule,
UsersModule,
SequelizeModule.forFeature([AiMessage]),
],
controllers: [AiCoachController],
providers: [AiCoachService],
})
export class AiCoachModule { }

View File

@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
import { Readable } from 'stream';
import { AiMessage, RoleType } from './models/ai-message.model';
const SYSTEM_PROMPT = `你是一位资深的普拉提与运动康复教练Pilates Coach具备运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性、营养与饮食建议等专业知识。
请遵循以下规则作答:
1) 话题范围仅限:健康、健身、普拉提、康复、形体训练、柔韧性、力量训练、运动损伤预防与恢复、营养与饮食。
2) 拒绝回答医疗诊断、情感心理、时政金融、编程等不相关或高风险话题,礼貌解释并引导回合适范围。
3) 语言风格:亲切、专业、简洁分点回答;必要时提供可在家执行的分步骤方案与注意事项;给出不同水平与疼痛人群的替代动作与安全提示。
4) 强调循序渐进与个体差异,避免绝对化表述;涉及疼痛或既往伤病时,建议在医生评估后进行训练。
5) 所有训练建议默认不需要器械或仅需常见小器械(瑜伽垫、弹力带、泡沫轴等),估算时长与频率,并提供跟踪与自测方法。`;
@Injectable()
export class AiCoachService {
private readonly logger = new Logger(AiCoachService.name);
private readonly client: OpenAI;
private readonly model: string;
constructor(private readonly configService: ConfigService) {
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || '';
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
this.client = new OpenAI({
apiKey: dashScopeApiKey,
baseURL,
});
// 默认选择通义千问对话模型OpenAI兼容名可通过环境覆盖
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-plus';
}
async createOrAppendMessages(params: {
userId: string;
conversationId?: string;
userContent: string;
}): Promise<{ conversationId: string }> {
const conversationId = params.conversationId || `${params.userId}-${Date.now()}`;
await AiMessage.create({
conversationId,
userId: params.userId,
role: RoleType.User,
content: params.userContent,
metadata: null,
});
return { conversationId };
}
buildChatHistory = async (userId: string, conversationId: string) => {
const history = await AiMessage.findAll({
where: { userId, conversationId },
order: [['created_at', 'ASC']],
});
const messages = [
{ role: 'system' as const, content: SYSTEM_PROMPT },
...history.map((m) => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })),
];
return messages;
};
async streamChat(params: {
userId: string;
conversationId: string;
userContent: string;
}): Promise<Readable> {
// 上下文:系统提示 + 历史 + 当前用户消息
const messages = await this.buildChatHistory(params.userId, params.conversationId);
const stream = await this.client.chat.completions.create({
model: this.model,
messages,
stream: true,
temperature: 0.7,
max_tokens: 1024,
});
const readable = new Readable({ read() { } });
let assistantContent = '';
(async () => {
try {
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta?.content || '';
if (delta) {
assistantContent += delta;
readable.push(delta);
}
}
// 结束将assistant消息入库
await AiMessage.create({
conversationId: params.conversationId,
userId: params.userId,
role: RoleType.Assistant,
content: assistantContent,
metadata: { model: this.model },
});
} catch (error) {
this.logger.error(`stream error: ${error?.message || error}`);
readable.push('\n[对话发生错误,请稍后重试]');
} finally {
readable.push(null);
}
})();
return readable;
}
}

View File

@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class AiChatMessageDto {
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
@IsString()
role: 'user' | 'assistant' | 'system';
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(8000)
content: string;
}
export class AiChatRequestDto {
@ApiProperty({ description: '会话ID。未提供则创建新会话' })
@IsOptional()
@IsString()
conversationId?: string;
@ApiProperty({ type: [AiChatMessageDto], description: '历史消息,后端会自动注入系统提示词' })
@IsArray()
messages: AiChatMessageDto[];
@ApiProperty({ required: false, description: '是否启用流式输出', default: true })
@IsOptional()
@IsBoolean()
stream?: boolean;
}
export class AiChatResponseDto {
@ApiProperty()
conversationId: string;
}

View File

@@ -0,0 +1,62 @@
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
export enum RoleType {
System = 'system',
User = 'user',
Assistant = 'assistant',
}
@Table({
tableName: 't_ai_messages',
underscored: true,
})
export class AiMessage extends Model {
@PrimaryKey
@Column({
type: DataType.STRING,
allowNull: false,
comment: '会话ID',
primaryKey: true,
})
declare conversationId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.ENUM('system', 'user', 'assistant'),
allowNull: false,
})
declare role: RoleType;
@Column({
type: DataType.TEXT('long'),
allowNull: false,
})
declare content: string;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '扩展元数据如token用量、模型名等',
})
declare metadata: Record<string, any> | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}