Files
18xchat/assets/Scripts/chat18x/core/ChatAIService.ts
T
2025-09-22 09:56:46 +08:00

470 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 首先加载 polyfills 以确保兼容性
import "../utils/polyfills";
import {
Chat,
GoogleGenAI,
HarmBlockThreshold,
HarmCategory,
} from "@google/genai";
import { RoleConfigLoader } from "./RoleConfigLoader";
import { ChatHistoryManager } from "../manager/ChatHistoryManager";
import { ApiConfig } from "./ApiConfigLoader";
import { ErrorHandler, ErrorType } from "../utils/ErrorHandler";
import { EmotionAIService } from "./EmotionAIService";
import { ChatService } from "db://assets/Scripts/chat18x/network/services/ChatService";
import { DataManager, DataId } from "../data/DataManager";
import { GirlData } from "../data/GirlData";
import proto from "db://assets/Scripts/proto/proto.pb.js";
import { TipsPanel } from "../ui/panels/TipsPanel";
import LanguageUtils from "../../Main/Common/LanguageUtils";
import { logger } from "db://assets/Scripts/Main/Common/Logger";
/**
* AI聊天服务类
*
* 基于Google Gemini API实现的多角色聊天系统,支持:
* - 多个独立的聊天实例管理
* - 每个角色拥有独立的对话上下文和历史记录
* - 本地聊天历史存储和加载
* - 动态角色配置和切换
*
* @example
* ```typescript
* const chatService = ChatAIService.Instance;
* chatService.setCurrentRole(10001);
* const response = await chatService.sendMessage(10001, "Hello");
* ```
*
* @author AI Chat System
* @version 2.0.0
*/
export class ChatAIService {
private static _instance: ChatAIService;
private ai: GoogleGenAI;
private chatInstances: Map<number, any> = new Map();
private currentRoleId: number | null = null;
private constructor() {
const config = ApiConfig.Instance.getAIConfig();
// 验证配置
if (!ApiConfig.Instance.validateConfig()) {
ErrorHandler.Instance.handleError(
new Error("AI配置验证失败"),
ErrorType.CONFIG_ERROR,
{ config },
true
);
TipsPanel.show(LanguageUtils.getText("chat_error_code_2002"));
throw new Error("AI服务初始化失败:配置无效");
}
try {
this.ai = new GoogleGenAI({
apiKey: config.apiKey,
httpOptions: {
timeout: ApiConfig.Instance.getTimeout(),
baseUrl: ApiConfig.Instance.getBaseUrl(),
},
});
logger.log("-------AI 启动成功-------");
logger.log(this.ai);
this.ai.operations;
} catch (error) {
TipsPanel.show(LanguageUtils.getText("chat_error_code_2001"));
ErrorHandler.Instance.handleError(
error as Error,
ErrorType.API_ERROR,
{ config: { ...config, apiKey: "***" } }, // 隐藏API密钥
true
);
throw error;
}
}
/**
* 获取ChatAIService的单例实例
*
* @returns {ChatAIService} 聊天服务实例
* @static
*/
public static get Instance(): ChatAIService {
if (!this._instance) {
this._instance = new ChatAIService();
}
return this._instance;
}
/**
* 创建或获取指定角色的聊天实例
* @param roleId 角色ID
*/
async createOrGetChat(roleId: number): Promise<Chat> {
if (!this.chatInstances.has(roleId)) {
const systemInstruction = RoleConfigLoader.getRoleInstruction(roleId);
const config = ApiConfig.Instance.getAIConfig();
// 使用 ChatHistoryManager 的统一加载方法(优先本地,无则获取服务端)
let savedHistory = await ChatHistoryManager.Instance.loadChatHistory(
roleId
);
let chat: Chat;
if (savedHistory && savedHistory.length > 0) {
chat = this.ai.chats.create({
model: config.model,
config: {
temperature: config.temperature,
systemInstruction: systemInstruction,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_CIVIC_INTEGRITY,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
],
},
history: savedHistory,
});
logger.log(chat);
logger.log(
`Loaded ${savedHistory.length} history messages for role ${roleId}`
);
} else {
chat = this.ai.chats.create({
model: config.model,
config: {
temperature: config.temperature,
systemInstruction: systemInstruction,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_CIVIC_INTEGRITY,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
],
},
});
logger.log(chat);
logger.log(`Created new chat instance for role ${roleId}`);
}
this.chatInstances.set(roleId, chat);
}
return this.chatInstances.get(roleId);
}
/**
* 设置当前活动的角色ID
* @param roleId 角色ID
*/
public async setCurrentRole(roleId: number): Promise<void> {
this.currentRoleId = roleId;
// 预创建聊天实例
await this.createOrGetChat(roleId);
// 预创建情绪聊天实例
EmotionAIService.Instance.ensureEmotionChatExists(roleId);
}
/**
* 获取当前角色ID
*/
public getCurrentRoleId(): number | null {
return this.currentRoleId;
}
/**
* 向指定角色发送消息并获取AI回复
*
* @param {number} roleId - 角色ID,用于区分不同的聊天实例
* @param {string} message - 用户发送的消息内容
* @returns {Promise<string>} AI的回复消息,如果发生错误返回null
*
* @example
* ```typescript
* const response = await chatService.sendMessage(10001, "你好");
* logger.log(response); // AI的回复
* ```
*/
public async sendMessage(roleId: number, message: string): Promise<string> {
// 输入验证
if (!roleId || roleId <= 0) {
ErrorHandler.Instance.handleValidationError(
"roleId",
"角色ID必须是正整数",
roleId
);
return null;
}
if (!message || message.trim() === "") {
ErrorHandler.Instance.handleValidationError(
"message",
"消息内容不能为空",
message
);
return null;
}
try {
const chat = await this.createOrGetChat(roleId);
const response = await chat.sendMessage({
message: message.trim(),
});
if (response && response.text) {
logger.log(`Response from role ${roleId}:`, response.text);
try {
// 保存用户消息
ChatHistoryManager.Instance.appendMessage(roleId, {
role: "user",
parts: [{ text: message }],
});
// 保存AI回复
ChatHistoryManager.Instance.appendMessage(roleId, {
role: "model",
parts: [{ text: response.text }],
});
// 上报聊天数据到服务器
let msgList: proto.cs.IChatMsg[] = [];
let userData = {
isAi: false,
msg: message,
msgId: new Date().getTime().toString(),
};
msgList.push(userData);
let aiData = {
isAi: true,
msg: response.text,
msgId: (new Date().getTime() + 1).toString(),
};
msgList.push(aiData);
this.reqChatMsg(roleId, msgList);
// 异步更新情绪状态(非阻塞)
EmotionAIService.Instance.updateEmotionFromChat(
roleId,
message,
response.text
).catch((emotionError) => {
logger.warn(
`Failed to update emotion for role ${roleId}:`,
emotionError
);
// 情绪更新失败不影响聊天功能
});
} catch (storageError) {
ErrorHandler.Instance.handleError(
storageError as Error,
ErrorType.STORAGE_ERROR,
{ roleId, message: message.substring(0, 100) },
false
);
// 即使存储失败,也返回AI回复
}
return response.text;
} else {
TipsPanel.show(response.promptFeedback.blockReason);
const warningMsg = `AI返回了空响应 (角色ID: ${roleId})`;
ErrorHandler.Instance.handleError(
new Error(warningMsg),
ErrorType.API_ERROR,
{ roleId, chat, response },
true
);
return null;
}
} catch (error) {
ErrorHandler.Instance.handleApiError(error, "sendMessage", {
roleId,
message: message,
});
TipsPanel.show("api调用失败,请使用vpn并重新启动");
return null;
}
}
/**
* 清除指定角色的聊天历史
* @param roleId 角色ID
*/
public clearChatHistory(roleId: number): void {
if (this.chatInstances.has(roleId)) {
this.chatInstances.delete(roleId);
}
// 清除情绪聊天历史
EmotionAIService.Instance.clearEmotionHistory(roleId);
// 清除本地存储的历史
ChatHistoryManager.Instance.clearHistory(roleId);
logger.log(`Cleared chat history for role ${roleId}`);
}
/**
* 清除所有聊天历史
*/
public clearAllChatHistory(): void {
this.chatInstances.clear();
// 清除所有情绪聊天历史
EmotionAIService.Instance.clearAllEmotionHistory();
// 清除本地存储的所有历史
ChatHistoryManager.Instance.clearAllHistory();
logger.log("Cleared all chat histories");
}
/**
* 获取当前活跃的聊天实例数量
*/
public getActiveChatCount(): number {
return this.chatInstances.size;
}
/**
* 兼容旧接口的Post方法
* @deprecated 请使用sendMessage方法,此方法将在下个版本中移除
*/
public async Post(data: GPTRequest): Promise<string> {
logger.warn("Post方法已废弃,请使用sendMessage方法");
const roleId = this.currentRoleId || 10001; // 默认使用第一个角色
const message = data.messages[0]?.content || "";
return this.sendMessage(roleId, message);
}
/**
* 上报聊天数据到服务器
*/
async reqChatMsg(girlId: number, msgList: proto.cs.IChatMsg[]) {
// 请求数据
const reqData = {
girlId,
msgs: msgList,
};
logger.log("请求上报聊天数据的请求数据:", reqData);
let res = await ChatService.I.reqChatMsg(reqData);
logger.log("请求上报聊天数据的响应数据:", res);
if (res && res.code === proto.cs.EnmRetCode.SUCCESS) {
const resData = res.data;
// 保存数据
const girlData = DataManager.I.getDataById<GirlData>(DataId.Girl);
const category = girlData.getGrilCategoryById(girlId);
girlData.setChatTotalCount(
category.toString(),
girlId,
resData.chatTotalCount
);
girlData.setChatRemainCount(
category.toString(),
girlId,
resData.chatRemainCount
);
}
}
/**
* 获取聊天数据
*/
async reqGetChatMsg(girlId: number, page: number, limit: number) {
// 请求数据
const reqData = {
GirlId: girlId,
page,
limit,
};
logger.log("获取聊天数据的请求数据:", reqData);
let res = await ChatService.I.reqGetChatMsg(reqData);
logger.log("获取聊天数据的响应数据:", res);
if (res && res.code === proto.cs.EnmRetCode.SUCCESS) {
const resData = res.data;
// 保存数据
const girlData = DataManager.I.getDataById<GirlData>(DataId.Girl);
const category = girlData.getGrilCategoryById(girlId);
girlData.setChatRecord(category.toString(), girlId, resData.msgs);
girlData.setChatTotalCount(
category.toString(),
girlId,
resData.chatTotalCount
);
girlData.setChatRemainCount(
category.toString(),
girlId,
resData.chatRemainCount
);
}
}
}
// 请求数据类型定义
export interface GPTRequest {
model: string;
messages: { role: string; content: string }[];
temperature: number;
id: string;
}
// 兼容旧名称(已废弃,建议使用 GPTRequest
/** @deprecated 请使用 GPTRequest */
export type GPTResquest = GPTRequest;
// 响应数据类型定义(当前未使用,预留用于未来API调用统计)
/** @deprecated 当前未使用,考虑移除或实现API统计功能时使用 */
export interface GPTResult {
id: string;
object: string;
created: number;
model: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
choices: {
message: {
role: string;
content: string;
};
finish_reason: string;
index: number;
}[];
}