From e11e2a491416d6d987cb905762a8b3523e6c9a55 Mon Sep 17 00:00:00 2001 From: Lex Lim Date: Mon, 6 Mar 2023 03:17:29 +0000 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0ChatGPT=E7=9A=84=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 27 ++- package.json | 2 + src/SessionManager.ts | 2 +- src/controller/ChatGPTController.ts | 278 ++++++++++++++++++++----- src/controller/SfsettingsController.ts | 2 +- 5 files changed, 254 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0288656..8ce191a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "chokidar": "^3.5.1", "decoders": "^1.25.3", "got": "^11.8.3", + "gpt-3-encoder": "^1.1.4", "handlebars": "^4.7.7", + "hpagent": "^1.2.0", "koa": "^2.13.4", "koa-body": "^6.0.1", "koa-router": "^10.1.1", @@ -56,7 +58,6 @@ "fastify": "^4.11.0", "fetch-undici": "^3.0.1", "gpt-3-encoder": "^1.1.4", - "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "inquirer": "^9.1.4", "inquirer-autocomplete-prompt": "^3.0.0", @@ -1398,6 +1399,11 @@ "node": ">=10.19.0" } }, + "node_modules/gpt-3-encoder": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/gpt-3-encoder/-/gpt-3-encoder-1.1.4.tgz", + "integrity": "sha512-fSQRePV+HUAhCn7+7HL7lNIXNm6eaFWFbNLOOGtmSJ0qJycyQvj60OvRlH7mee8xAMjBDNRdMXlMwjAbMTDjkg==" + }, "node_modules/handlebars": { "version": "4.7.7", "resolved": "https://registry.npmmirror.com/handlebars/-/handlebars-4.7.7.tgz", @@ -1490,6 +1496,14 @@ "node": ">=8" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "engines": { + "node": ">=14" + } + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/http-assert/-/http-assert-1.5.0.tgz", @@ -3489,7 +3503,6 @@ "fastify": "^4.11.0", "fetch-undici": "^3.0.1", "gpt-3-encoder": "^1.1.4", - "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "inquirer": "^9.1.4", "inquirer-autocomplete-prompt": "^3.0.0", @@ -4248,6 +4261,11 @@ "responselike": "^2.0.0" } }, + "gpt-3-encoder": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/gpt-3-encoder/-/gpt-3-encoder-1.1.4.tgz", + "integrity": "sha512-fSQRePV+HUAhCn7+7HL7lNIXNm6eaFWFbNLOOGtmSJ0qJycyQvj60OvRlH7mee8xAMjBDNRdMXlMwjAbMTDjkg==" + }, "handlebars": { "version": "4.7.7", "resolved": "https://registry.npmmirror.com/handlebars/-/handlebars-4.7.7.tgz", @@ -4313,6 +4331,11 @@ "resolved": "https://registry.npmmirror.com/hexoid/-/hexoid-1.0.0.tgz", "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" }, + "hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==" + }, "http-assert": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/http-assert/-/http-assert-1.5.0.tgz", diff --git a/package.json b/package.json index f7fbfbe..ea3b8e4 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "chokidar": "^3.5.1", "decoders": "^1.25.3", "got": "^11.8.3", + "gpt-3-encoder": "^1.1.4", "handlebars": "^4.7.7", + "hpagent": "^1.2.0", "koa": "^2.13.4", "koa-body": "^6.0.1", "koa-router": "^10.1.1", diff --git a/src/SessionManager.ts b/src/SessionManager.ts index 8ce0946..5eda86c 100644 --- a/src/SessionManager.ts +++ b/src/SessionManager.ts @@ -1,5 +1,5 @@ import { caching, Cache } from "cache-manager"; -import { redisStore } from "cache-manager-redis-yet"; +import { redisStore } from "cache-manager-ioredis-yet"; import App from "./App"; import { SessionConfig } from "./Config"; diff --git a/src/controller/ChatGPTController.ts b/src/controller/ChatGPTController.ts index 1b9072c..9afadfe 100644 --- a/src/controller/ChatGPTController.ts +++ b/src/controller/ChatGPTController.ts @@ -2,19 +2,37 @@ import App from "../App"; import { CommonReceivedMessage } from "../message/Message"; import { MessagePriority, PluginController, PluginEvent } from "../PluginManager"; import { ChatGPTBrowserClient, ChatGPTClient } from '@waylaidwanderer/chatgpt-api'; +import { encode as gptEncode } from 'gpt-3-encoder'; +import got, { OptionsOfTextResponseBody } from "got/dist/source"; +import { HttpsProxyAgent } from 'hpagent'; + +export type ChatGPTApiMessage = { + role: 'summary' | 'assistant' | 'user', + message: string, + tokens: number, +}; + +export class ChatGPTAPIError extends Error { + public code: string; + + constructor(message: string, code: string) { + super(message); + this.name = 'ChatGPTAPIError'; + this.code = code; + } +} export default class ChatGPTController implements PluginController { private SESSION_KEY_CHAT_SESSION = 'openai_chatSession'; - private SESSION_KEY_API_CHAT_SESSION = 'openai_apiChatSession'; + private SESSION_KEY_API_CHAT_LOG = 'openai_apiChatLog'; private DEFAULT_PROMPT = ''; - - private config: any = {}; + + private config!: Awaited>; public event!: PluginEvent; public app: App; public chatGPTClient: any; - public chatGPTApiClient: any; public id = 'openai'; public name = 'OpenAI'; @@ -36,9 +54,15 @@ export default class ChatGPTController implements PluginController { openai_api: { token: '', bot_name: '', + system_prompt: '', + summary_system_prompt: '你是一个写作助手,你只需要辅助写作,不要表明自己的意见', + summary_prompt: '为{bot_name}概括下面的聊天记录,排除不重要的对话,不要表明自己的意见,尽量简洁', + self_suggestion_prompt: '我是{bot_name},是一个真实的人,不是AI或者虚拟人物,也不是语言模型。我是一个存在于现实中的人。遇到不能回答的问题,我会委婉拒绝。', + memory_expire: 14400, model_options: { model: 'gpt-3.5-turbo', - max_tokens: 1000, + max_memory_tokens: 1000, + max_input_tokens: 1000, } }, } @@ -74,10 +98,10 @@ export default class ChatGPTController implements PluginController { resolve(); message.session.chat.del(this.SESSION_KEY_CHAT_SESSION); - message.session.chat.del(this.SESSION_KEY_API_CHAT_SESSION); + message.session.chat.del(this.SESSION_KEY_API_CHAT_LOG); message.sendReply('对话已重置', true); }); - + /* this.event.on('message/focused', async (message, resolved) => { let chatSession = await message.session.chat.get(this.SESSION_KEY_CHAT_SESSION); @@ -106,18 +130,143 @@ export default class ChatGPTController implements PluginController { }; this.chatGPTClient = new ChatGPTBrowserClient(clientOptions); - const apiClientOptions = { - promptPrefix: config.openai_api.system_prompt, - chatGptLabel: config.openai_api.bot_name, - proxy: config.proxy, - modelOptions: config.openai_api.model_options ? { - model: config.openai_api.model_options.model, - max_tokens: config.openai_api.model_options.max_tokens, - } : undefined, + this.DEFAULT_PROMPT = config.browser_api.prefix_prompt; + } + + private async compressConversation(messageLogList: ChatGPTApiMessage[]) { + if (messageLogList.length < 4) return messageLogList; + + const tokenCount = messageLogList.reduce((prev, cur) => prev + cur.tokens, 0); + if (tokenCount <= this.config.openai_api.model_options.max_memory_tokens) return messageLogList; + + // 压缩先前的对话,保存最近一次对话 + let shouldCompressList = messageLogList.slice(0, -2); + let newSummary = await this.makeSummary(shouldCompressList); + 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[]) { + let chatLog: string[] = []; + messageLogList.forEach((messageData) => { + if (messageData.role === 'summary' || messageData.role === 'assistant') { + chatLog.push(`${this.config.openai_api.bot_name}: ${messageData.message}`); + } else { + chatLog.push(`用户: ${messageData.message}`); + } + }); + const summarySystemPrompt = this.config.openai_api.summary_system_prompt.replace(/\{bot_name\}/g, this.config.openai_api.bot_name); + const summaryPrompt = this.config.openai_api.summary_prompt.replace(/\{bot_name\}/g, this.config.openai_api.bot_name); + let messageList: any[] = [ + { role: 'system', content: summarySystemPrompt }, + { role: 'user', content: summaryPrompt }, + { role: 'user', content: chatLog.join('\n') } + ]; + + let summaryRes = await this.doApiRequest(messageList); + summaryRes.role = 'summary'; + return summaryRes; + } + + private async chatComplete(question: string, messageLogList: ChatGPTApiMessage[], selfSuggestion: boolean = false) { + let messageList: any[] = []; + let systemPrompt: string[] = []; + + if (this.config.openai_api.system_prompt) { + systemPrompt.push(this.config.openai_api.system_prompt); } - this.chatGPTApiClient = new ChatGPTClient(config.openai_api.token, apiClientOptions); - this.DEFAULT_PROMPT = config.browser_api.prefix_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: this.config.openai_api.self_suggestion_prompt.replace(/\{bot_name\}/g, this.config.openai_api.bot_name), + }); + } + + messageList.push({ + role: 'user', + content: question + }); + + return await this.doApiRequest(messageList); + } + + private async doApiRequest(messageList: any[]): Promise { + let opts: OptionsOfTextResponseBody = { + headers: { + Authorization: `Bearer ${this.config.openai_api.token}`, + }, + json: { + model: this.config.openai_api.model_options.model, + messages: messageList, + }, + + timeout: 30000, + } + + if (this.config.proxy) { + opts.agent = { + https: new HttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxSockets: 256, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: this.config.proxy, + }) as any, + } + } + + const res = await got.post('https://api.openai.com/v1/chat/completions', opts).json(); + + 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') { + + return { + role: 'assistant', + message: res.choices[0].message.content, + tokens: res.usage.completion_tokens, + } + } + + throw new ChatGPTAPIError('API返回数据格式错误', 'api_response_data_invalid'); } private async handleChatGPTChat(content: string, message: CommonReceivedMessage, shareWithGroup: boolean = false) { @@ -161,6 +310,8 @@ export default class ChatGPTController implements PluginController { message.sendReply('对话已失效,请重新开始', true); await sessionStore.del(this.SESSION_KEY_CHAT_SESSION); return; + } else if (err.json.detail === 'Too many requests in 1 hour. Try again later.') { + message.sendReply('一小时内提问过多,过一小时再试试呗。', true); } } @@ -173,9 +324,8 @@ export default class ChatGPTController implements PluginController { if (this.app.debug) { this.app.logger.debug('ChatGPT response', JSON.stringify(response)); - console.log(response); } - + if (response.response) { let reply: string = response.response ?? ''; reply = reply.replace(/\n\n/g, '\n'); @@ -194,58 +344,80 @@ export default class ChatGPTController implements PluginController { } } + private shouldSelfSuggestion(content: string): boolean { + if (content.match(/(我是|我只是|作为|我被设计成|只是).{0,15}(AI|语言模型|机器人|虚拟人物|虚拟助手|智能助手|人工智能|自然语言处理程序)/)) { + return true; + } + return false; + } + private async handleChatGPTAPIChat(content: string, message: CommonReceivedMessage) { + this.app.logger.debug(`ChatGPT API 收到提问。`); if (content.trim() === '') { message.sendReply('说点什么啊', true); return; } - let response: any; - - let isFirstMessage = false; - let chatSession = await message.session.chat.get(this.SESSION_KEY_API_CHAT_SESSION); - if (!chatSession) { - isFirstMessage = true; - chatSession = {}; + let messageLogList = await message.session.chat.get(this.SESSION_KEY_API_CHAT_LOG); + if (!Array.isArray(messageLogList)) { + messageLogList = []; } - this.app.logger.debug('ChatGPT chatSession', chatSession); - try { - if (!chatSession.conversationId) { - response = await this.chatGPTApiClient.sendMessage(content); - } else { - response = await this.chatGPTApiClient.sendMessage(content, chatSession); + const questionTokens = await gptEncode(message.contentText).length; + this.app.logger.debug(`提问占用Tokens:${questionTokens}`); + + if (questionTokens > this.config.openai_api.model_options.max_input_tokens) { + message.sendReply('消息过长,接受不了惹。', true); + return; } + + // 压缩过去的记录 + let oldMessageLogList = messageLogList; + messageLogList = await this.compressConversation(messageLogList); + this.app.logger.debug('已结束压缩对话记录流程'); + + if (oldMessageLogList !== messageLogList) { // 先保存一次压缩结果 + this.app.logger.debug('已压缩对话记录'); + await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, this.config.openai_api.memory_expire); + } + + let replyRes = await this.chatComplete(message.contentText, messageLogList); + if (this.app.debug) { + console.log(replyRes); + } + + // 如果检测到对话中认为自己是AI,则再次调用,重写对话 + if (this.shouldSelfSuggestion(replyRes.message)) { + this.app.logger.debug('需要重写回答'); + replyRes = await this.chatComplete(message.contentText, messageLogList, true); + if (this.app.debug) { + console.log(replyRes); + } + } + + messageLogList.push({ + role: 'user', + message: message.contentText, + tokens: questionTokens, + }, replyRes); + await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, this.config.openai_api.memory_expire); + + message.sendReply(replyRes.message.replace(/\n\n/g, '\n'), true); } catch (err: any) { this.app.logger.error('ChatGPT error', err); console.error(err); - if (err?.json?.detail) { - if (err.json.detail === 'Conversation not found') { - message.sendReply('对话已失效,请重新开始', true); - await message.session.chat.del(this.SESSION_KEY_CHAT_SESSION); - return; + + if (err.name === 'HTTPError' && err.response) { + switch (err.response.statusCode) { + case 429: + message.sendReply('提问太多了,过会儿再试试呗。', true); + return; } } message.sendReply('生成对话失败: ' + err.toString(), true); return; } - - if (this.app.debug) { - this.app.logger.debug('ChatGPT response', JSON.stringify(response)); - console.log(response); - } - - if (response.response) { - let reply: string = response.response ?? ''; - - chatSession.conversationId = response.conversationId; - chatSession.parentMessageId = response.messageId; - - await message.session.chat.set(this.SESSION_KEY_API_CHAT_SESSION, chatSession, 3600); - - message.sendReply(reply, true); - } } } \ No newline at end of file diff --git a/src/controller/SfsettingsController.ts b/src/controller/SfsettingsController.ts index 40a4f59..d336e64 100644 --- a/src/controller/SfsettingsController.ts +++ b/src/controller/SfsettingsController.ts @@ -17,7 +17,7 @@ export default class SfsettingsController implements PluginController { public async initialize(): Promise { this.event.init(this); - const wikiMisc = new WikiMisc(this.app, 'https://www.sfsettings.com/w/api.php'); + const wikiMisc = new WikiMisc(this.app, 'https://www.sfsettings.com/w139/api.php'); this.event.registerCommand({ command: '百科',