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; } } } }