新增AI教练模块,包括控制器、服务、模型及数据传输对象,更新应用模块以引入新模块,同时在打卡模块中添加按时间范围返回每日打卡状态的功能
This commit is contained in:
110
src/ai-coach/ai-coach.service.ts
Normal file
110
src/ai-coach/ai-coach.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user