|
|
import App from "#ibot/App";
|
|
|
import { CommonReceivedMessage } from "#ibot/message/Message";
|
|
|
import { CommandInputArgs, MessagePriority, PluginController, PluginEvent } from "#ibot/PluginManager";
|
|
|
import { encode as gptEncode } from 'gpt-3-encoder';
|
|
|
import got, { OptionsOfTextResponseBody } from "got/dist/source";
|
|
|
import { HttpsProxyAgent } from 'hpagent';
|
|
|
import { ProxyAgent } from 'undici';
|
|
|
import { FetchEventSourceInit, fetchEventSource } from '@waylaidwanderer/fetch-event-source';
|
|
|
import { RandomMessage } from "#ibot/utils/RandomMessage";
|
|
|
import { MessageTypingSimulator } from "#ibot/utils/MessageTypingSimulator";
|
|
|
|
|
|
import OpenCC from 'opencc';
|
|
|
|
|
|
export type ChatGPTLogMessage = {
|
|
|
role: 'summary' | 'assistant' | 'user',
|
|
|
message: string,
|
|
|
}
|
|
|
|
|
|
export type ChatGPTApiMessage = ChatGPTLogMessage & {
|
|
|
tokens: number,
|
|
|
};
|
|
|
|
|
|
export type CharacterConfig = {
|
|
|
api: string,
|
|
|
bot_name: string,
|
|
|
description?: string,
|
|
|
system_prompt: string,
|
|
|
summary_system_prompt: string,
|
|
|
summary_prompt: string,
|
|
|
self_suggestion_prompt: string,
|
|
|
prepend_messages?: ChatGPTApiMessage[],
|
|
|
} & Record<string, any>;
|
|
|
export type CharactersConfig = Record<string, CharacterConfig>;
|
|
|
|
|
|
export type ChatCompleteApiConfig = {
|
|
|
id: string,
|
|
|
type: string,
|
|
|
memory_expire: number,
|
|
|
buffer_size: number,
|
|
|
max_memory_tokens: number,
|
|
|
max_input_tokens: number,
|
|
|
token: string,
|
|
|
proxy?: string,
|
|
|
st_convert?: boolean,
|
|
|
} & Record<string, any>;
|
|
|
|
|
|
export class ChatGPTAPIError extends Error {
|
|
|
public code: string;
|
|
|
|
|
|
constructor(message: string, code: string, public json?: any) {
|
|
|
super(message);
|
|
|
this.name = 'ChatGPTAPIError';
|
|
|
this.code = code;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export default class ChatGPTController implements PluginController {
|
|
|
private SESSION_KEY_API_CHAT_LOG = 'openai_apiChatLog';
|
|
|
private SESSION_KEY_MESSAGE_COUNT = 'openai_apiMessageCount';
|
|
|
private SESSION_KEY_API_CHAT_CHARACTER = 'openai_apiChatCharacter';
|
|
|
private DEFAULT_CHARACTER = 'assistant';
|
|
|
private CHARACTER_EXPIRE = 86400;
|
|
|
|
|
|
private config!: Awaited<ReturnType<typeof this.getDefaultConfig>>;
|
|
|
|
|
|
public event!: PluginEvent;
|
|
|
public app: App;
|
|
|
public chatGPTClient: any;
|
|
|
|
|
|
public id = 'openai';
|
|
|
public name = 'OpenAI';
|
|
|
public description = '对话AI的功能';
|
|
|
|
|
|
private chatGenerating = false;
|
|
|
private messageGroup: Record<string, RandomMessage> = {}
|
|
|
|
|
|
constructor(app: App) {
|
|
|
this.app = app;
|
|
|
}
|
|
|
|
|
|
async getDefaultConfig() {
|
|
|
return {
|
|
|
proxy: '',
|
|
|
api: [
|
|
|
{
|
|
|
id: 'openai',
|
|
|
type: 'openai',
|
|
|
memory_expire: 14400,
|
|
|
buffer_size: 100,
|
|
|
max_memory_tokens: 1000,
|
|
|
max_input_tokens: 1000,
|
|
|
endpoint: 'https://api.openai.com',
|
|
|
token: '',
|
|
|
model_options: {
|
|
|
model: 'gpt-3.5-turbo',
|
|
|
temperature: null,
|
|
|
top_p: null,
|
|
|
presence_penalty: null,
|
|
|
frequency_penalty: null,
|
|
|
max_output_tokens: 768,
|
|
|
},
|
|
|
},
|
|
|
{
|
|
|
id: 'azure_openai',
|
|
|
type: 'azure',
|
|
|
buffer_size: 100,
|
|
|
memory_expire: 14400,
|
|
|
max_memory_tokens: 1000,
|
|
|
max_input_tokens: 1000,
|
|
|
endpoint: '',
|
|
|
deployment: '',
|
|
|
token: '',
|
|
|
model_options: {
|
|
|
model: 'gpt-3.5-turbo',
|
|
|
temperature: null,
|
|
|
top_p: null,
|
|
|
presence_penalty: null,
|
|
|
frequency_penalty: null,
|
|
|
max_output_tokens: 768,
|
|
|
},
|
|
|
}
|
|
|
] as ChatCompleteApiConfig[],
|
|
|
characters: {
|
|
|
assistant: {
|
|
|
api: 'openai',
|
|
|
bot_name: '',
|
|
|
system_prompt: '',
|
|
|
summary_system_prompt: '你是一个写作助手,你只需要辅助写作,不要表明自己的意见',
|
|
|
summary_prompt: '为{bot_name}概括下面的聊天记录,排除不重要的对话,不要表明自己的意见,尽量简洁',
|
|
|
self_suggestion_prompt: '我是{bot_name},是一个真实的人,不是AI或者虚拟人物,也不是语言模型。我是一个存在于现实中的人。遇到不能回答的问题,我会委婉拒绝。',
|
|
|
}
|
|
|
} as CharactersConfig,
|
|
|
focused_character: 'assistant',
|
|
|
output_replace: {} as Record<string, string>,
|
|
|
gatekeeper_url: '',
|
|
|
google_custom_search: {
|
|
|
cx: '',
|
|
|
key: '',
|
|
|
classifier_system_prompt: 'You are a classifier.',
|
|
|
classifier_prompt: 'To judge whether the following questions are more suitable for searching with a search engine, you only need to answer "yes" or "no" in English.',
|
|
|
yes: 'Yes',
|
|
|
no: 'No',
|
|
|
},
|
|
|
rate_limit: 2,
|
|
|
rate_limit_minutes: 5,
|
|
|
messages: {
|
|
|
error: [
|
|
|
'生成对话失败: {{{error}}}',
|
|
|
'在回复时出现错误:{{{error}}}',
|
|
|
'生成对话时出现错误:{{{error}}}',
|
|
|
'在回答问题时出现错误:{{{error}}}',
|
|
|
],
|
|
|
generating: [
|
|
|
'正在回复其他人的提问',
|
|
|
'等我回完再问',
|
|
|
'等我发完再问',
|
|
|
'等我回完这条再问',
|
|
|
'等我发完这条再问',
|
|
|
'前一个人的问题还没回答完,等下再问吧。',
|
|
|
],
|
|
|
tooManyRequest: [
|
|
|
'你的提问太多了,{{{minutesLeft}}}分钟后再问吧。',
|
|
|
'抱歉,你的问题太多了,还需要等待{{{minutesLeft}}}分钟后才能回答。',
|
|
|
'请耐心等待,{{{minutesLeft}}}分钟后我将回答你的问题',
|
|
|
'请耐心等待{{{minutesLeft}}}分钟,然后再提出你的问题。',
|
|
|
'你的提问有点多,请等待{{{minutesLeft}}}分钟后再继续提问。',
|
|
|
],
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async initialize(config: any) {
|
|
|
await this.updateConfig(config);
|
|
|
|
|
|
this.event.init(this);
|
|
|
|
|
|
this.event.registerCommand({
|
|
|
command: 'ai',
|
|
|
name: '开始对话',
|
|
|
}, async (args, message, resolve) => {
|
|
|
resolve();
|
|
|
|
|
|
return this.handleChatGPTAPIChat(args, message, true, 'saved', true);
|
|
|
});
|
|
|
|
|
|
// this.event.registerCommand({
|
|
|
// command: 'aig',
|
|
|
// name: '开始全群共享的对话',
|
|
|
// }, (args, message, resolve) => {
|
|
|
// resolve();
|
|
|
|
|
|
// return this.handleChatGPTAPIChat(args, message, true, 'assistant', true);
|
|
|
// });
|
|
|
|
|
|
this.event.registerCommand({
|
|
|
command: '重置对话',
|
|
|
name: '重置对话',
|
|
|
}, async (args, message, resolve) => {
|
|
|
resolve();
|
|
|
|
|
|
return Promise.all([
|
|
|
message.session.chat.del(this.SESSION_KEY_API_CHAT_LOG),
|
|
|
message.session.group.del(this.SESSION_KEY_API_CHAT_LOG),
|
|
|
message.sendReply('对话已重置', true),
|
|
|
]);
|
|
|
});
|
|
|
|
|
|
// this.event.registerCommand({
|
|
|
// command: '切换人物',
|
|
|
// name: '切换人物',
|
|
|
// }, (args, message, resolve) => {
|
|
|
// resolve();
|
|
|
|
|
|
// return this.handleChangeCharacter(args, message);
|
|
|
// });
|
|
|
}
|
|
|
|
|
|
async updateConfig(config: any) {
|
|
|
this.config = config;
|
|
|
|
|
|
// 随机消息
|
|
|
for (let [key, value] of Object.entries(this.config.messages)) {
|
|
|
this.messageGroup[key] = new RandomMessage(value);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private async handleChangeCharacter(args: CommandInputArgs, message: CommonReceivedMessage) {
|
|
|
message.markRead();
|
|
|
|
|
|
let character = args.param.trim();
|
|
|
if (character === '') {
|
|
|
// 列出所有人物
|
|
|
let characterList = Object.entries(this.config.characters);
|
|
|
let currentCharacter = await message.session.chat.get<string>(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.DEFAULT_CHARACTER;
|
|
|
let currentCharacterInfo = this.config.characters[currentCharacter] ?? this.config.characters[this.DEFAULT_CHARACTER];
|
|
|
let msgBuilder = [
|
|
|
`当前人物: ${currentCharacterInfo.bot_name},使用方法: “:切换人物 人物ID”`,
|
|
|
'人物列表:'
|
|
|
];
|
|
|
for (let [name, character] of characterList) {
|
|
|
if (character.description) {
|
|
|
msgBuilder.push(`${name}: ${character.bot_name}, ${character.description}`);
|
|
|
} else {
|
|
|
msgBuilder.push(`${name}: ${character.bot_name}`);
|
|
|
}
|
|
|
}
|
|
|
return message.sendReply(msgBuilder.join('\n'), true);
|
|
|
}
|
|
|
|
|
|
if (!(character in this.config.characters)) {
|
|
|
let msg = this.messageGroup.error.nextMessage({ error: '人物不存在' });
|
|
|
return message.sendReply(msg ?? '人物不存在', true);
|
|
|
}
|
|
|
|
|
|
await message.session.chat.set(this.SESSION_KEY_API_CHAT_CHARACTER, character, this.CHARACTER_EXPIRE);
|
|
|
|
|
|
let characterInfo = this.config.characters[character];
|
|
|
|
|
|
return message.sendReply(`已切换人物为 ${characterInfo.bot_name}`, true);
|
|
|
}
|
|
|
|
|
|
private getApiConfigById(id: string) {
|
|
|
return this.config.api.find((data) => data.id === id) ?? this.config.api[0];
|
|
|
}
|
|
|
|
|
|
private async shouldSearch(question: string) {
|
|
|
|
|
|
}
|
|
|
|
|
|
private async googleCustomSearch(question: string) {
|
|
|
let res = await got.get('https://www.googleapis.com/customsearch/v1', {
|
|
|
searchParams: {
|
|
|
key: this.config.google_custom_search.key,
|
|
|
cx: this.config.google_custom_search.cx,
|
|
|
q: question,
|
|
|
num: 1,
|
|
|
safe: 'on',
|
|
|
fields: 'items(link)',
|
|
|
},
|
|
|
}).json<any>();
|
|
|
|
|
|
if (res.body.items && res.body.items.length > 0) {
|
|
|
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private async compressConversation(messageLogList: ChatGPTApiMessage[], characterConf: CharacterConfig) {
|
|
|
if (messageLogList.length < 4) return messageLogList;
|
|
|
|
|
|
let apiConf = this.getApiConfigById(characterConf.api);
|
|
|
|
|
|
const tokenCount = messageLogList.reduce((prev, cur) => prev + cur.tokens, 0);
|
|
|
if (tokenCount <= apiConf.max_memory_tokens) return messageLogList;
|
|
|
|
|
|
// 压缩先前的对话,保存最近一次对话
|
|
|
let shouldCompressList = messageLogList.slice(0, -2);
|
|
|
let newSummary = await this.makeSummary(shouldCompressList, characterConf);
|
|
|
let newMessageLogList = messageLogList.slice(-2).filter((data) => data.role !== 'summary');
|
|
|
newMessageLogList.unshift({
|
|
|
role: 'summary',
|
|
|
message: newSummary.message,
|
|
|
tokens: newSummary.tokens,
|
|
|
});
|
|
|
|
|
|
return newMessageLogList;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 将一段对话压缩为简介
|
|
|
* @param messageLogList 消息记录列表
|
|
|
* @returns
|
|
|
*/
|
|
|
private async makeSummary(messageLogList: ChatGPTApiMessage[], characterConf: CharacterConfig) {
|
|
|
let chatLog: string[] = [];
|
|
|
messageLogList.forEach((messageData) => {
|
|
|
if (messageData.role === 'summary' || messageData.role === 'assistant') {
|
|
|
chatLog.push(`${characterConf.bot_name}: ${messageData.message}`);
|
|
|
} else {
|
|
|
chatLog.push(`用户: ${messageData.message}`);
|
|
|
}
|
|
|
});
|
|
|
const summarySystemPrompt = characterConf.summary_system_prompt.replace(/\{bot_name\}/g, characterConf.bot_name);
|
|
|
const summaryPrompt = characterConf.summary_prompt.replace(/\{bot_name\}/g, characterConf.bot_name);
|
|
|
let messageList: any[] = [
|
|
|
{ role: 'system', content: summarySystemPrompt },
|
|
|
{ role: 'user', content: summaryPrompt },
|
|
|
{ role: 'user', content: chatLog.join('\n') }
|
|
|
];
|
|
|
|
|
|
let apiConf = this.getApiConfigById(characterConf.api);
|
|
|
let summaryRes = await this.doApiRequest(messageList, apiConf);
|
|
|
summaryRes.role = 'summary';
|
|
|
return summaryRes;
|
|
|
}
|
|
|
|
|
|
private buildMessageList(question: string, messageLogList: ChatGPTApiMessage[], characterConf: CharacterConfig,
|
|
|
selfSuggestion: boolean) {
|
|
|
|
|
|
let messageList: any[] = [];
|
|
|
let systemPrompt: string[] = [];
|
|
|
|
|
|
if (characterConf.system_prompt) {
|
|
|
systemPrompt.push(characterConf.system_prompt);
|
|
|
}
|
|
|
|
|
|
// 生成API消息列表,并将总结单独提取出来
|
|
|
messageLogList.forEach((messageData) => {
|
|
|
if (messageData.role === 'summary') {
|
|
|
systemPrompt.push(messageData.message);
|
|
|
} else {
|
|
|
messageList.push({
|
|
|
role: messageData.role,
|
|
|
content: messageData.message,
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
|
|
|
if (systemPrompt.length > 0) { // 添加系统提示词
|
|
|
messageList.unshift({
|
|
|
role: 'system',
|
|
|
content: systemPrompt.join('\n\n'),
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (selfSuggestion) {
|
|
|
messageList.push({
|
|
|
role: 'user',
|
|
|
content: '你是谁?',
|
|
|
});
|
|
|
messageList.push({
|
|
|
role: 'assistant',
|
|
|
content: characterConf.self_suggestion_prompt.replace(/\{bot_name\}/g, characterConf.bot_name),
|
|
|
});
|
|
|
}
|
|
|
|
|
|
messageList.push({
|
|
|
role: 'user',
|
|
|
content: question
|
|
|
});
|
|
|
|
|
|
return messageList;
|
|
|
}
|
|
|
|
|
|
private getChatCompleteApiUrl(apiConf: ChatCompleteApiConfig): string {
|
|
|
switch (apiConf.type) {
|
|
|
case 'openai':
|
|
|
return `${apiConf.endpoint}/v1/chat/completions`;
|
|
|
case 'azure':
|
|
|
return `${apiConf.endpoint}/openai/deployments/${apiConf.deployment}/chat/completions?api-version=2023-05-15`;
|
|
|
}
|
|
|
|
|
|
throw new Error('Unknown API type: ' + apiConf.type);
|
|
|
}
|
|
|
|
|
|
private async doApiRequest(messageList: any[], apiConf: ChatCompleteApiConfig, onMessage?: (chunk: string) => any): Promise<ChatGPTApiMessage> {
|
|
|
switch (apiConf.type) {
|
|
|
case 'openai':
|
|
|
case 'azure':
|
|
|
return await this.doOpenAILikeApiRequest(messageList, apiConf, onMessage);
|
|
|
}
|
|
|
|
|
|
throw new Error('Unknown API type: ' + apiConf.type);
|
|
|
}
|
|
|
|
|
|
private async doOpenAILikeApiRequest(messageList: any[], apiConf: ChatCompleteApiConfig, onMessage?: (chunk: string) => any): Promise<ChatGPTApiMessage> {
|
|
|
let modelOpts = Object.fromEntries(Object.entries({
|
|
|
model: apiConf.model_options.model,
|
|
|
temperature: apiConf.model_options.temperature,
|
|
|
top_p: apiConf.model_options.top_p,
|
|
|
max_tokens: apiConf.model_options.max_output_tokens,
|
|
|
presence_penalty: apiConf.model_options.presence_penalty,
|
|
|
frequency_penalty: apiConf.model_options.frequency_penalty,
|
|
|
}).filter((data) => data[1]));
|
|
|
|
|
|
if (onMessage) {
|
|
|
let opts: FetchEventSourceInit = {
|
|
|
method: 'POST',
|
|
|
body: JSON.stringify({
|
|
|
...modelOpts,
|
|
|
messages: messageList,
|
|
|
stream: true,
|
|
|
})
|
|
|
};
|
|
|
|
|
|
if (apiConf.type === 'openai') {
|
|
|
opts.headers = {
|
|
|
Authorization: `Bearer ${apiConf.token}`,
|
|
|
'Content-Type': 'application/json',
|
|
|
};
|
|
|
} else if (apiConf.type === 'azure') {
|
|
|
opts.headers = {
|
|
|
"api-key": apiConf.token,
|
|
|
"content-type": 'application/json',
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const proxyConfig = apiConf.proxy ?? this.config.proxy;
|
|
|
if (proxyConfig) {
|
|
|
(opts as any).dispatcher = new ProxyAgent(proxyConfig);
|
|
|
}
|
|
|
|
|
|
let abortController = new AbortController();
|
|
|
|
|
|
let timeoutTimer = setTimeout(() => {
|
|
|
abortController.abort();
|
|
|
}, 30000);
|
|
|
|
|
|
let buffer: string = '';
|
|
|
let messageChunk: string[] = [];
|
|
|
let isStarted = false;
|
|
|
let isDone = false;
|
|
|
let prevEvent: any = null;
|
|
|
|
|
|
let messageTyping = new MessageTypingSimulator();
|
|
|
|
|
|
messageTyping.on('message', (message: string) => {
|
|
|
onMessage(message);
|
|
|
});
|
|
|
|
|
|
const flush = (force = false) => {
|
|
|
if (force) {
|
|
|
let message = buffer.trim();
|
|
|
messageChunk.push(message);
|
|
|
messageTyping.pushMessage(message);
|
|
|
} else {
|
|
|
if (buffer.indexOf('\n\n') !== -1 && buffer.length > apiConf.buffer_size) {
|
|
|
let splitPos = buffer.indexOf('\n\n');
|
|
|
let message = buffer.slice(0, splitPos);
|
|
|
messageChunk.push(message);
|
|
|
messageTyping.pushMessage(message);
|
|
|
buffer = buffer.slice(splitPos + 2);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const onClose = () => {
|
|
|
abortController.abort();
|
|
|
clearTimeout(timeoutTimer);
|
|
|
}
|
|
|
|
|
|
const apiUrl = this.getChatCompleteApiUrl(apiConf);
|
|
|
this.app.logger.debug(`ChatGPT API 请求地址:${apiUrl}`);
|
|
|
|
|
|
await fetchEventSource(apiUrl, {
|
|
|
...opts,
|
|
|
signal: abortController.signal,
|
|
|
onopen: async (openResponse) => {
|
|
|
if (openResponse.status === 200) {
|
|
|
return;
|
|
|
}
|
|
|
if (this.app.debug) {
|
|
|
console.debug(openResponse);
|
|
|
}
|
|
|
let error;
|
|
|
try {
|
|
|
let body = await openResponse.text();
|
|
|
if (body.length > 0 && body[0] === '{') {
|
|
|
body = JSON.parse(body);
|
|
|
}
|
|
|
error = new ChatGPTAPIError(`Failed to send message. HTTP ${openResponse.status}`,
|
|
|
openResponse.status.toString(), body);
|
|
|
} catch {
|
|
|
error = error || new Error(`Failed to send message. HTTP ${openResponse.status}`);
|
|
|
}
|
|
|
throw error;
|
|
|
},
|
|
|
onclose: () => {
|
|
|
if (this.app.debug) {
|
|
|
this.app.logger.debug('Server closed the connection unexpectedly, returning...');
|
|
|
}
|
|
|
if (!isDone) {
|
|
|
if (!prevEvent) {
|
|
|
throw new Error('Server closed the connection unexpectedly. Please make sure you are using a valid access token.');
|
|
|
}
|
|
|
if (buffer.length > 0) {
|
|
|
flush(true);
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
onerror: (err) => {
|
|
|
// rethrow to stop the operation
|
|
|
throw err;
|
|
|
},
|
|
|
onmessage: (eventMessage) => {
|
|
|
if (!eventMessage.data || eventMessage.event === 'ping') {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (eventMessage.data === '[DONE]') {
|
|
|
flush(true);
|
|
|
onClose();
|
|
|
isDone = true;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
const data = JSON.parse(eventMessage.data);
|
|
|
if ("choices" in data && data["choices"].length > 0) {
|
|
|
let choice = data["choices"][0];
|
|
|
|
|
|
var delta_content = choice["delta"];
|
|
|
if (delta_content["content"]) {
|
|
|
var deltaMessage = delta_content["content"];
|
|
|
|
|
|
// Skip empty lines before content
|
|
|
if (!isStarted) {
|
|
|
if (deltaMessage.replace("\n", "") == "") {
|
|
|
return;
|
|
|
} else {
|
|
|
isStarted = true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
buffer += deltaMessage;
|
|
|
flush();
|
|
|
}
|
|
|
}
|
|
|
prevEvent = data;
|
|
|
} catch (err) {
|
|
|
console.debug(eventMessage.data);
|
|
|
console.error(err);
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
|
|
|
let message = messageChunk.join('');
|
|
|
let tokens = gptEncode(message).length;
|
|
|
|
|
|
return {
|
|
|
role: 'assistant',
|
|
|
message,
|
|
|
tokens
|
|
|
};
|
|
|
} else {
|
|
|
let opts: OptionsOfTextResponseBody = {
|
|
|
json: {
|
|
|
...modelOpts,
|
|
|
messages: messageList,
|
|
|
},
|
|
|
|
|
|
timeout: 30000,
|
|
|
};
|
|
|
|
|
|
if (apiConf.type === 'openai') {
|
|
|
opts.headers = {
|
|
|
Authorization: `Bearer ${apiConf.token}`,
|
|
|
};
|
|
|
} else if (apiConf.type === 'azure') {
|
|
|
opts.headers = {
|
|
|
"api-key": apiConf.token,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const proxyConfig = apiConf.proxy ?? this.config.proxy;
|
|
|
if (proxyConfig) {
|
|
|
opts.agent = {
|
|
|
https: new HttpsProxyAgent({
|
|
|
keepAlive: true,
|
|
|
keepAliveMsecs: 1000,
|
|
|
maxSockets: 256,
|
|
|
maxFreeSockets: 256,
|
|
|
scheduling: 'lifo',
|
|
|
proxy: proxyConfig,
|
|
|
}) as any,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const apiUrl = this.getChatCompleteApiUrl(apiConf);
|
|
|
this.app.logger.debug(`ChatGPT API 请求地址:${apiUrl}`);
|
|
|
|
|
|
const res = await got.post(apiUrl, opts).json<any>();
|
|
|
|
|
|
if (res.error) {
|
|
|
throw new ChatGPTAPIError(res.message, res.type);
|
|
|
}
|
|
|
if (res.choices && Array.isArray(res.choices) && res.choices.length > 0 &&
|
|
|
typeof res.choices[0].message?.content === 'string') {
|
|
|
let completions = res.choices[0].message.content;
|
|
|
let completion_tokens = res.usage?.completion_tokens ?? gptEncode(completions).length;
|
|
|
return {
|
|
|
role: 'assistant',
|
|
|
message: completions,
|
|
|
tokens: completion_tokens,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
throw new ChatGPTAPIError('API返回数据格式错误', 'api_response_data_invalid');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private shouldSelfSuggestion(content: string): boolean {
|
|
|
if (content.match(/(我是|我只是|作为|我被设计成|只是).{0,15}(AI|语言模型|机器人|虚拟人物|虚拟助手|智能助手|人工智能|自然语言处理)/)) {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
private async handleChatGPTAPIChat(args: CommandInputArgs, message: CommonReceivedMessage, isStream: boolean = false,
|
|
|
character = 'assistant', singleMessage = false) {
|
|
|
|
|
|
message.markRead();
|
|
|
|
|
|
let content = args.param;
|
|
|
|
|
|
if (singleMessage && this.chatGenerating) {
|
|
|
let msg = this.messageGroup.generating.nextMessage();
|
|
|
await message.sendReply(msg ?? '正在生成中,请稍后再试', true);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let characterConf: CharacterConfig;
|
|
|
let apiConf: ChatCompleteApiConfig;
|
|
|
if (character === 'saved') {
|
|
|
// 从会话中获取人物
|
|
|
character = await message.session.chat.get(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.DEFAULT_CHARACTER;
|
|
|
if (!(character in this.config.characters)) {
|
|
|
this.app.logger.debug(`ChatGPT API 人物 ${character} 不存在,使用默认人物`);
|
|
|
character = 'assistant';
|
|
|
}
|
|
|
|
|
|
characterConf = this.config.characters[character];
|
|
|
apiConf = this.getApiConfigById(characterConf.api);
|
|
|
|
|
|
await message.session.chat.set(this.SESSION_KEY_API_CHAT_CHARACTER, character, this.CHARACTER_EXPIRE);
|
|
|
} else {
|
|
|
if (!(character in this.config.characters)) {
|
|
|
this.app.logger.debug(`ChatGPT API 人格 ${character} 不存在,使用默认人格`);
|
|
|
character = 'assistant';
|
|
|
}
|
|
|
characterConf = this.config.characters[character];
|
|
|
apiConf = this.getApiConfigById(characterConf.api);
|
|
|
}
|
|
|
|
|
|
this.app.logger.debug(`ChatGPT API 收到提问。当前人格:${character}`);
|
|
|
if (content.trim() === '') {
|
|
|
// await message.sendReply('说点什么啊', true);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (this.config.gatekeeper_url) {
|
|
|
try {
|
|
|
let response = await got.post(this.config.gatekeeper_url, {
|
|
|
json: {
|
|
|
text: content,
|
|
|
},
|
|
|
}).json<any>();
|
|
|
if (response.status == 1) {
|
|
|
await message.sendReply(response.message, true);
|
|
|
return;
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error(e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const userSessionStore = message.session.user;
|
|
|
// 使用频率限制
|
|
|
let rateLimitExpires = await userSessionStore.getRateLimit(this.SESSION_KEY_MESSAGE_COUNT, this.config.rate_limit, this.config.rate_limit_minutes * 60);
|
|
|
if (rateLimitExpires) {
|
|
|
let minutesLeft = Math.ceil(rateLimitExpires / 60);
|
|
|
let msg = this.messageGroup.tooManyRequest.nextMessage({ minutes: minutesLeft });
|
|
|
await message.sendReply(msg ?? `你的提问太多了,${minutesLeft}分钟后再问吧。`, true);
|
|
|
return;
|
|
|
}
|
|
|
await userSessionStore.addRequestCount(this.SESSION_KEY_MESSAGE_COUNT, this.config.rate_limit_minutes * 60);
|
|
|
|
|
|
let s2tw: OpenCC.OpenCC | undefined;
|
|
|
let tw2s: OpenCC.OpenCC | undefined;
|
|
|
if (apiConf.st_convert) {
|
|
|
// 转换简体到繁体
|
|
|
s2tw = new OpenCC.OpenCC('s2tw.json');
|
|
|
tw2s = new OpenCC.OpenCC('tw2s.json');
|
|
|
content = await s2tw.convertPromise(content);
|
|
|
}
|
|
|
|
|
|
// 获取记忆
|
|
|
let messageLogList = await message.session.chat.get<ChatGPTApiMessage[]>(this.SESSION_KEY_API_CHAT_LOG);
|
|
|
if (!Array.isArray(messageLogList)) {
|
|
|
messageLogList = [];
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
if (singleMessage) {
|
|
|
this.chatGenerating = true;
|
|
|
}
|
|
|
|
|
|
const questionTokens = await gptEncode(content).length;
|
|
|
this.app.logger.debug(`提问占用Tokens:${questionTokens}`);
|
|
|
|
|
|
if (questionTokens > apiConf.max_input_tokens) {
|
|
|
await message.sendReply('消息过长,接受不了惹。', true);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 压缩过去的记录
|
|
|
let oldMessageLogList = messageLogList;
|
|
|
messageLogList = await this.compressConversation(messageLogList, characterConf);
|
|
|
this.app.logger.debug('已结束压缩对话记录流程');
|
|
|
|
|
|
if (oldMessageLogList !== messageLogList) { // 先保存一次压缩结果
|
|
|
this.app.logger.debug('已压缩对话记录');
|
|
|
await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, apiConf.memory_expire);
|
|
|
}
|
|
|
|
|
|
let reqMessageList = this.buildMessageList(content, messageLogList, characterConf, false);
|
|
|
|
|
|
let replyRes: ChatGPTApiMessage | undefined = undefined;
|
|
|
if (isStream) {
|
|
|
// 处理流式输出
|
|
|
let onResultMessage = async (chunk: string) => {
|
|
|
let msg = apiConf.st_convert ? await tw2s!.convertPromise(chunk) : chunk;
|
|
|
for (let [inputText, replacement] of Object.entries(this.config.output_replace)) {
|
|
|
content = content.replace(new RegExp(inputText, 'g'), replacement);
|
|
|
}
|
|
|
await message.sendReply(msg, true);
|
|
|
};
|
|
|
|
|
|
replyRes = await this.doApiRequest(reqMessageList, apiConf, onResultMessage);
|
|
|
replyRes.message = apiConf.st_convert ? await tw2s!.convertPromise(replyRes.message) : replyRes.message;
|
|
|
if (this.app.debug) {
|
|
|
console.log(replyRes);
|
|
|
}
|
|
|
} else {
|
|
|
replyRes = await this.doApiRequest(reqMessageList, apiConf);
|
|
|
replyRes.message = apiConf.st_convert ? await tw2s!.convertPromise(replyRes.message) : replyRes.message;
|
|
|
if (this.app.debug) {
|
|
|
console.log(replyRes);
|
|
|
}
|
|
|
|
|
|
// 如果检测到对话中认为自己是AI,则再次调用,重写对话
|
|
|
if (characterConf.self_suggestion_prompt && this.shouldSelfSuggestion(replyRes.message)) {
|
|
|
this.app.logger.debug('需要重写回答');
|
|
|
reqMessageList = this.buildMessageList(replyRes.message, messageLogList, characterConf, true);
|
|
|
replyRes = await this.doApiRequest(reqMessageList, apiConf);
|
|
|
if (this.app.debug) {
|
|
|
console.log(replyRes);
|
|
|
}
|
|
|
replyRes.message = apiConf.st_convert ? await tw2s!.convertPromise(replyRes.message) : replyRes.message;
|
|
|
}
|
|
|
|
|
|
let content = replyRes.message.replace(/\n\n/g, '\n');
|
|
|
for (let [inputText, replacement] of Object.entries(this.config.output_replace)) {
|
|
|
content = content.replace(new RegExp(inputText, 'g'), replacement);
|
|
|
}
|
|
|
|
|
|
await message.sendReply(content, true);
|
|
|
}
|
|
|
|
|
|
if (replyRes) {
|
|
|
messageLogList.push({
|
|
|
role: 'user',
|
|
|
message: content,
|
|
|
tokens: questionTokens,
|
|
|
}, replyRes);
|
|
|
await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, apiConf.memory_expire);
|
|
|
}
|
|
|
} catch (err: any) {
|
|
|
this.app.logger.error('ChatGPT error', err);
|
|
|
console.error(err);
|
|
|
|
|
|
if (err.name === 'HTTPError' && err.response) {
|
|
|
switch (err.response.statusCode) {
|
|
|
case 429:
|
|
|
let msg = this.messageGroup.tooManyRequest.nextMessage({ minutes: 2 });
|
|
|
await message.sendReply(msg ?? '提问太多了,过会儿再试试呗。', true);
|
|
|
return;
|
|
|
}
|
|
|
} else if (err.name === 'RequestError') {
|
|
|
let msg = this.messageGroup.error.nextMessage({ error: '连接失败:' + err.message });
|
|
|
await message.sendReply(msg ?? `连接失败:${err.message},过会儿再试试呗。`, true);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let msg = this.messageGroup.error.nextMessage({ error: err.message });
|
|
|
await message.sendReply(msg ?? `生成对话失败: ${err.message}`, true);
|
|
|
return;
|
|
|
} finally {
|
|
|
if (singleMessage) {
|
|
|
this.chatGenerating = false;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} |