diff --git a/REDIS b/REDIS new file mode 100644 index 0000000..e69de29 diff --git a/Redis.md b/Redis.md new file mode 100644 index 0000000..199e7d5 --- /dev/null +++ b/Redis.md @@ -0,0 +1,8 @@ +## 本插件使用的一些redis键值说明 + +### 官方(API3/浏览器模式) +* CHATGPT:QQ_CONVERSATION:123456789 qq号为123456789的人当前正在使用哪个对话 +* CHATGPT:CONVERSATION_LAST_MESSAGE_ID:26001339-9043-435d-8394-c553a3109fdf id为`26001339-9043-435d-8394-c553a3109fdf`的对话,最后一条用户发出的消息的id +* CHATGPT:CONVERSATION_LAST_MESSAGE_PROMPT:26001339-9043-435d-8394-c553a3109fdf id为`26001339-9043-435d-8394-c553a3109fdf`的对话,最后一条用户发出的消息的内容(问题) +* CHATGPT:CONVERSATION_CREATE_TIME:26001339-9043-435d-8394-c553a3109fdf id为`26001339-9043-435d-8394-c553a3109fdf`的对话创建时间 +* CHATGPT:CONVERSATION_LENGTH:26001339-9043-435d-8394-c553a3109fdf id为`26001339-9043-435d-8394-c553a3109fdf`的对话当前长度(不计分支) diff --git a/apps/chat.js b/apps/chat.js index 518b83c..0983cf0 100644 --- a/apps/chat.js +++ b/apps/chat.js @@ -1,32 +1,19 @@ import plugin from '../../../lib/plugins/plugin.js' import _ from 'lodash' import { Config } from '../config/index.js' -import showdown from 'showdown' import mjAPI from 'mathjax-node' -import { uuid } from 'oicq/lib/common.js' +import { v4 as uuid } from 'uuid' import delay from 'delay' import { ChatGPTAPI } from 'chatgpt' import { ChatGPTClient, BingAIClient } from '@waylaidwanderer/chatgpt-api' -import { getMessageById, makeForwardMsg, tryTimes, upsertMessage, pTimeout } from '../utils/common.js' +import { getMessageById, makeForwardMsg, tryTimes, upsertMessage } from '../utils/common.js' import { ChatGPTPuppeteer } from '../utils/browser.js' import { KeyvFile } from 'keyv-file' import { OfficialChatGPTClient } from '../utils/message.js' import fetch from 'node-fetch' -// import puppeteer from '../utils/browser.js' -// import showdownKatex from 'showdown-katex' +import { getConversations, getLatestMessageIdByConversationId } from '../utils/conversation.js' const blockWords = Config.blockWords -const converter = new showdown.Converter({ - extensions: [ - // showdownKatex({ - // delimiters: [ - // { left: '$$', right: '$$', display: false }, - // { left: '$', right: '$', display: false, inline: true }, - // { left: '\\(', right: '\\)', display: false }, - // { left: '\\[', right: '\\]', display: true } - // ] - // }) - ] -}) + /** * 每个对话保留的时长。单个对话内ai是保留上下文的。超时后销毁对话,再次对话创建新的对话。 * 单位:秒 @@ -42,7 +29,7 @@ mjAPI.config({ mjAPI.start() export class chatgpt extends plugin { - constructor() { + constructor () { let toggleMode = Config.toggleMode super({ /** 功能名称 */ @@ -62,7 +49,7 @@ export class chatgpt extends plugin { }, { reg: '#chatgpt对话列表', - fnc: 'getConversations', + fnc: 'getAllConversations', permission: 'master' }, { @@ -95,6 +82,10 @@ export class chatgpt extends plugin { reg: '#OpenAI(剩余)?(余额|额度)', fnc: 'totalAvailable', permission: 'master' + }, + { + reg: '^#chatgpt切换对话', + fnc: 'attachConversation' } ] @@ -107,7 +98,7 @@ export class chatgpt extends plugin { * @param e * @returns {Promise} */ - async getConversations(e) { + async getConversations (e) { let keys = await redis.keys('CHATGPT:CONVERSATIONS:*') if (!keys || keys.length === 0) { await this.reply('当前没有人正在与机器人对话', true) @@ -129,7 +120,7 @@ export class chatgpt extends plugin { * @param e * @returns {Promise} */ - async destroyConversations(e) { + async destroyConversations (e) { let ats = e.message.filter(m => m.type === 'at') if (ats.length === 0) { let c = await redis.get(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`) @@ -153,7 +144,7 @@ export class chatgpt extends plugin { } } - async help(e) { + async help (e) { let response = 'chatgpt-plugin使用帮助文字版\n' + '@我+聊天内容: 发起对话与AI进行聊天\n' + '#chatgpt对话列表: 查看当前发起的对话\n' + @@ -163,7 +154,7 @@ export class chatgpt extends plugin { await this.reply(response) } - async switch2Picture(e) { + async switch2Picture (e) { let userSetting = await redis.get(`CHATGPT:USER:${e.sender.user_id}`) if (!userSetting) { userSetting = { usePicture: true } @@ -175,7 +166,7 @@ export class chatgpt extends plugin { await this.reply('ChatGPT回复已转换为图片模式') } - async switch2Text(e) { + async switch2Text (e) { let userSetting = await redis.get(`CHATGPT:USER:${e.sender.user_id}`) if (!userSetting) { userSetting = { usePicture: false } @@ -191,7 +182,7 @@ export class chatgpt extends plugin { * #chatgpt * @param e oicq传递的事件参数e */ - async chatgpt(e) { + async chatgpt (e) { let prompt if (this.toggleMode === 'at') { if (!e.msg || e.msg.startsWith('#')) { @@ -208,7 +199,7 @@ export class chatgpt extends plugin { } } const use = await redis.get('CHATGPT:USE') - if (use != 'bing') { + if (use !== 'bing') { let randomId = uuid() // 队列队尾插入,开始排队 await redis.rPush('CHATGPT:CHAT_QUEUE', [randomId]) @@ -240,47 +231,80 @@ export class chatgpt extends plugin { // } catch (e) { // await this.reply('chatgpt初始化出错:' + e.msg, true) // } - let previousConversation = await redis.get(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`) + let previousConversation let conversation = {} - if (!previousConversation) { - let ctime = new Date() - previousConversation = { - sender: e.sender, - ctime, - utime: ctime, - num: 0 + if (use === 'api3') { + // api3 支持对话穿插,因此不按照qq号来进行判断了 + let conversationId = await redis.get(`CHATGPT:QQ_CONVERSATION:${e.sender.user_id}`) + if (conversationId) { + let lastMessageId = await redis.get(`CHATGPT:CONVERSATION_LAST_MESSAGE_ID:${conversationId}`) + if (!lastMessageId) { + lastMessageId = await getLatestMessageIdByConversationId(conversationId) + } + // let lastMessagePrompt = await redis.get(`CHATGPT:CONVERSATION_LAST_MESSAGE_PROMPT:${conversationId}`) + // let conversationCreateTime = await redis.get(`CHATGPT:CONVERSATION_CREATE_TIME:${conversationId}`) + // let conversationLength = await redis.get(`CHATGPT:CONVERSATION_LENGTH:${conversationId}`) + conversation = { + conversationId, + parentMessageId: lastMessageId + } + if (Config.debug) { + logger.mark({ previousConversation }) + } + } else { + let ctime = new Date() + previousConversation = { + sender: e.sender, + ctime, + utime: ctime, + num: 0 + } } - // await redis.set(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`, JSON.stringify(previousConversation), { EX: CONVERSATION_PRESERVE_TIME }) } else { - previousConversation = JSON.parse(previousConversation) - conversation = { - conversationId: previousConversation.conversation.conversationId, - parentMessageId: previousConversation.conversation.parentMessageId, - clientId: previousConversation.clientId, - invocationId: previousConversation.invocationId, - conversationSignature: previousConversation.conversationSignature + previousConversation = await redis.get(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`) + if (!previousConversation) { + let ctime = new Date() + previousConversation = { + sender: e.sender, + ctime, + utime: ctime, + num: 0 + } + // await redis.set(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`, JSON.stringify(previousConversation), { EX: CONVERSATION_PRESERVE_TIME }) + } else { + previousConversation = JSON.parse(previousConversation) + conversation = { + conversationId: previousConversation.conversation.conversationId, + parentMessageId: previousConversation.conversation.parentMessageId, + clientId: previousConversation.clientId, + invocationId: previousConversation.invocationId, + conversationSignature: previousConversation.conversationSignature + } } } + try { if (Config.debug) { logger.mark(conversation) } - let chatMessage = await this.sendMessage(prompt, conversation, use) - previousConversation.conversation = { - conversationId: chatMessage.conversationId + let chatMessage = await this.sendMessage(prompt, conversation, use, e) + if (use !== 'api3') { + previousConversation.conversation = { + conversationId: chatMessage.conversationId + } + if (use === 'bing') { + previousConversation.clientId = chatMessage.clientId + previousConversation.invocationId = chatMessage.invocationId + previousConversation.conversationSignature = chatMessage.conversationSignature + } else { + // 或许这样切换回来不会404? + previousConversation.conversation.parentMessageId = chatMessage.id + } + console.log(chatMessage) + previousConversation.num = previousConversation.num + 1 + await redis.set(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`, JSON.stringify(previousConversation), CONVERSATION_PRESERVE_TIME > 0 ? { EX: CONVERSATION_PRESERVE_TIME } : {}) } - if (use === 'bing') { - previousConversation.clientId = chatMessage.clientId - previousConversation.invocationId = chatMessage.invocationId - previousConversation.conversationSignature = chatMessage.conversationSignature - } else { - // 或许这样切换回来不会404? - previousConversation.conversation.parentMessageId = chatMessage.id - } - console.log(chatMessage) let response = chatMessage?.text - previousConversation.num = previousConversation.num + 1 - await redis.set(`CHATGPT:CONVERSATIONS:${e.sender.user_id}`, JSON.stringify(previousConversation), CONVERSATION_PRESERVE_TIME > 0 ? { EX: CONVERSATION_PRESERVE_TIME } : {}) // 检索是否有屏蔽词 const blockWord = blockWords.find(word => response.toLowerCase().includes(word.toLowerCase())) if (blockWord) { @@ -297,14 +321,14 @@ export class chatgpt extends plugin { } if (userSetting.usePicture) { let endTokens = ['.', '。', '……', '!', '!', ']', ')', ')', '】', '?', '?', '~', '"', "'"] - let maxTries = 3 + let maxTries = use === 'api3' ? 3 : 0 while (maxTries >= 0 && !endTokens.find(token => response.trimEnd().endsWith(token))) { maxTries-- // while (!response.trimEnd().endsWith('.') && !response.trimEnd().endsWith('。') && !response.trimEnd().endsWith('……') && // !response.trimEnd().endsWith('!') && !response.trimEnd().endsWith('!') && !response.trimEnd().endsWith(']') && !response.trimEnd().endsWith('】') // ) { await this.reply('内容有点多,我正在奋笔疾书,请再等一会', true, { recallMsg: 5 }) - let responseAppend = await this.sendMessage('Continue', conversation, use) + let responseAppend = await this.sendMessage('Continue', conversation, use, e) previousConversation.conversation = { conversationId: responseAppend.conversationId, parentMessageId: responseAppend.id @@ -328,23 +352,22 @@ export class chatgpt extends plugin { // logger.info(response) // markdown转为html // todo部分数学公式可能还有问题 - let converted = response //converter.makeHtml(response) + let converted = response // converter.makeHtml(response) /** 最后回复消息 */ - await e.runtime.render('chatgpt-plugin', use != 'bing' ? 'content/ChatGPT/index' : 'content/Bing/index', { content: converted, prompt, senderName: e.sender.nickname }) + await e.runtime.render('chatgpt-plugin', use !== 'bing' ? 'content/ChatGPT/index' : 'content/Bing/index', { content: converted, prompt, senderName: e.sender.nickname }) } else { let quotemessage = [] if (chatMessage?.quote) { chatMessage.quote.forEach(function (item, index) { - if (item.trim() != '') { + if (item.trim() !== '') { quotemessage.push(item) } }) } - if (response.length > 1000 ) { + if (Config.autoUsePicture && response.length > Config.autoUsePictureThreshold) { // 文字过多时自动切换到图片模式输出 - let converted = response - await e.runtime.render('chatgpt-plugin', use != 'bing' ? 'content/ChatGPT/index' : 'content/Bing/index', { content: converted, prompt, quote: quotemessage, senderName: e.sender.nickname }) + await e.runtime.render('chatgpt-plugin', use !== 'bing' ? 'content/ChatGPT/index' : 'content/Bing/index', { content: response, prompt, quote: quotemessage, senderName: e.sender.nickname }) } else { await this.reply(`${response}`, e.isGroup) if (quotemessage.length > 0) { @@ -370,163 +393,169 @@ export class chatgpt extends plugin { } } - async sendMessage(prompt, conversation = {}, use) { + async sendMessage (prompt, conversation = {}, use, e) { if (!conversation) { conversation = { timeoutMs: Config.defaultTimeoutMs } } - // console.log(use) - if (use === 'browser') { - return await this.chatgptBrowserBased(prompt, conversation) - } else if (use === 'apiReverse') { - const currentDate = new Date().toISOString().split('T')[0] - let promptPrefix = `You are ${Config.assistantLabel}, a large language model trained by OpenAI. ${Config.promptPrefixOverride || defaultPropmtPrefix} + if (Config.debug) { + logger.mark(`using ${use} mode`) + } + switch (use) { + case 'browser': { + return await this.chatgptBrowserBased(prompt, conversation) + } + case 'apiReverse': { + const currentDate = new Date().toISOString().split('T')[0] + let promptPrefix = `You are ${Config.assistantLabel}, a large language model trained by OpenAI. ${Config.promptPrefixOverride || defaultPropmtPrefix} Current date: ${currentDate}` - const clientOptions = { - // (Optional) Support for a reverse proxy for the completions endpoint (private API server). - // Warning: This will expose your `openaiApiKey` to a third-party. Consider the risks before using this. - reverseProxyUrl: Config.reverseProxy || 'https://chatgpt.pawan.krd/api/completions', - // (Optional) Parameters as described in https://platform.openai.com/docs/api-reference/completions - modelOptions: { - // You can override the model name and any other parameters here. - model: Config.plus ? 'text-davinci-002-render-paid' : 'text-davinci-002-render' - }, - // (Optional) Set custom instructions instead of "You are ChatGPT...". - promptPrefix, - // (Optional) Set a custom name for the user - // userLabel: 'User', - // (Optional) Set a custom name for ChatGPT - chatGptLabel: Config.assistantLabel, - // (Optional) Set to true to enable `console.debug()` logging - debug: Config.debug - } - const cacheOptions = { - // Options for the Keyv cache, see https://www.npmjs.com/package/keyv - // This is used for storing conversations, and supports additional drivers (conversations are stored in memory by default) - // For example, to use a JSON file (`npm i keyv-file`) as a database: - store: new KeyvFile({ filename: 'cache.json' }) - } - let accessToken = await redis.get('CHATGPT:TOKEN') - if (!accessToken) { - throw new Error('未绑定ChatGPT AccessToken,请使用#chatgpt设置token命令绑定token') - } - // console.log(accessToken) - this.chatGPTApi = new ChatGPTClient(accessToken, clientOptions, cacheOptions) - let response = await tryTimes(async () => await this.chatGPTApi.sendMessage(prompt, conversation || {}), 1) - return { - text: response.response, - conversationId: response.conversationId, - id: response.messageId, - parentMessageId: conversation?.parentMessageId - } - } else if (use === 'bing') { - let bingToken = await redis.get('CHATGPT:BING_TOKEN') - if (!bingToken) { - throw new Error('未绑定Bing Cookie,请使用#chatgpt设置必应token命令绑定Bing Cookie') - } - let cookie = undefined - if (bingToken?.indexOf('=') > -1) { - cookie = bingToken - } - const bingAIClient = new BingAIClient({ - userToken: bingToken, // "_U" cookie from bing.com - cookie, - debug: Config.debug - }) - let response - let reply = '' - try { - /* bingAIClient中设置了无响应2分钟超时,应该不用单独处理了,后续看情况可以删掉这些代码 - const responseP = new Promise( - async (resolve, reject) => { - let bingResponse = await bingAIClient.sendMessage(prompt, conversation || {},(token) => { - reply += token - }) - return resolve(bingResponse) - }) - response = await pTimeout(responseP, { - milliseconds: Config.bingTimeoutMs, - message: reply != '' ? `${reply}\n不行了,我的大脑过载了,处理不过来了!` : '必应的小脑子不好使了,不知道怎么回答!' - }) - */ - response = await bingAIClient.sendMessage(prompt, conversation || {}, (token) => { - reply += token - }) - if (response.details.adaptiveCards?.[0]?.body?.[0]?.text?.trim()) { - if (response.response === undefined) { - response.response = response.details.adaptiveCards?.[0]?.body?.[0]?.text?.trim() - } - response.response = response.response.replace(/\[\^[0-9]+\^\]/g, (str) => { - return str.replace(/[/^]/g, '') - }) - response.quote = response.details.adaptiveCards?.[0]?.body?.[0]?.text?.replace(/\[\^[0-9]+\^\]/g, '').replace(response.response, '').split('\n') + const clientOptions = { + // (Optional) Support for a reverse proxy for the completions endpoint (private API server). + // Warning: This will expose your `openaiApiKey` to a third-party. Consider the risks before using this. + reverseProxyUrl: Config.reverseProxy || 'https://chatgpt.pawan.krd/api/completions', + // (Optional) Parameters as described in https://platform.openai.com/docs/api-reference/completions + modelOptions: { + // You can override the model name and any other parameters here. + model: Config.plus ? 'text-davinci-002-render-paid' : 'text-davinci-002-render' + }, + // (Optional) Set custom instructions instead of "You are ChatGPT...". + promptPrefix, + // (Optional) Set a custom name for the user + // userLabel: 'User', + // (Optional) Set a custom name for ChatGPT + chatGptLabel: Config.assistantLabel, + // (Optional) Set to true to enable `console.debug()` logging + debug: Config.debug } - } catch (error) { - const code = error?.data?.code || 503 - if (code === 503) { - logger.error(error) + const cacheOptions = { + // Options for the Keyv cache, see https://www.npmjs.com/package/keyv + // This is used for storing conversations, and supports additional drivers (conversations are stored in memory by default) + // For example, to use a JSON file (`npm i keyv-file`) as a database: + store: new KeyvFile({ filename: 'cache.json' }) } - console.error(error) - const message = error?.message || error?.data?.message || '与Bing通信时出错.' + let accessToken = await redis.get('CHATGPT:TOKEN') + if (!accessToken) { + throw new Error('未绑定ChatGPT AccessToken,请使用#chatgpt设置token命令绑定token') + } + // console.log(accessToken) + this.chatGPTApi = new ChatGPTClient(accessToken, clientOptions, cacheOptions) + let response = await tryTimes(async () => await this.chatGPTApi.sendMessage(prompt, conversation || {}), 1) return { - text: message === 'Timed out waiting for response. Try enabling debug mode to see more information.' ? (reply != '' ? `${reply}\n不行了,我的大脑过载了,处理不过来了!` : '必应的小脑瓜不好使了,不知道怎么回答!') : message + text: response.response, + conversationId: response.conversationId, + id: response.messageId, + parentMessageId: conversation?.parentMessageId } } - return { - text: response.response, - quote: response.quote, - conversationId: response.conversationId, - clientId: response.clientId, - invocationId: response.invocationId, - conversationSignature: response.conversationSignature + case 'bing': { + let bingToken = await redis.get('CHATGPT:BING_TOKEN') + if (!bingToken) { + throw new Error('未绑定Bing Cookie,请使用#chatgpt设置必应token命令绑定Bing Cookie') + } + let cookie + if (bingToken?.indexOf('=') > -1) { + cookie = bingToken + } + const bingAIClient = new BingAIClient({ + userToken: bingToken, // "_U" cookie from bing.com + cookie, + debug: Config.debug + }) + let response + let reply = '' + try { + response = await bingAIClient.sendMessage(prompt, conversation || {}, (token) => { + reply += token + }) + if (response.details.adaptiveCards?.[0]?.body?.[0]?.text?.trim()) { + if (response.response === undefined) { + response.response = response.details.adaptiveCards?.[0]?.body?.[0]?.text?.trim() + } + response.response = response.response.replace(/\[\^[0-9]+\^\]/g, (str) => { + return str.replace(/[/^]/g, '') + }) + response.quote = response.details.adaptiveCards?.[0]?.body?.[0]?.text?.replace(/\[\^[0-9]+\^\]/g, '').replace(response.response, '').split('\n') + } + } catch (error) { + const code = error?.data?.code || 503 + if (code === 503) { + logger.error(error) + } + console.error(error) + const message = error?.message || error?.data?.message || '与Bing通信时出错.' + return { + text: message === 'Timed out waiting for response. Try enabling debug mode to see more information.' ? (reply != '' ? `${reply}\n不行了,我的大脑过载了,处理不过来了!` : '必应的小脑瓜不好使了,不知道怎么回答!') : message + } + } + return { + text: response.response, + quote: response.quote, + conversationId: response.conversationId, + clientId: response.clientId, + invocationId: response.invocationId, + conversationSignature: response.conversationSignature + } } - } else if (use === 'api3') { - // official without cloudflare - let accessToken = await redis.get('CHATGPT:TOKEN') - if (!accessToken) { - throw new Error('未绑定ChatGPT AccessToken,请使用#chatgpt设置token命令绑定token') + case 'api3': { + // official without cloudflare + let accessToken = await redis.get('CHATGPT:TOKEN') + if (!accessToken) { + throw new Error('未绑定ChatGPT AccessToken,请使用#chatgpt设置token命令绑定token') + } + this.chatGPTApi = new OfficialChatGPTClient({ + accessToken, + apiReverseUrl: Config.api, + timeoutMs: 120000 + }) + let sendMessageResult = await this.chatGPTApi.sendMessage(prompt, conversation) + // 更新最后一条prompt + await redis.set(`CHATGPT:CONVERSATION_LAST_MESSAGE_PROMPT:${sendMessageResult.conversationId}`, prompt) + // 更新最后一条messageId + await redis.set(`CHATGPT:CONVERSATION_LAST_MESSAGE_ID:${sendMessageResult.conversationId}`, sendMessageResult.id) + await redis.set(`CHATGPT:QQ_CONVERSATION:${e.sender.user_id}`, sendMessageResult.conversationId) + if (!conversation.conversationId) { + // 如果是对话的创建者 + await redis.set(`CHATGPT:CONVERSATION_CREATER_ID:${sendMessageResult.conversationId}`, e.sender.user_id) + await redis.set(`CHATGPT:CONVERSATION_CREATER_NICK_NAME:${sendMessageResult.conversationId}`, e.sender.card) + } + return sendMessageResult } - this.chatGPTApi = new OfficialChatGPTClient({ - accessToken, - apiReverseUrl: Config.api, - timeoutMs: 120000 - }) - return await this.chatGPTApi.sendMessage(prompt, conversation) - } else { - let completionParams = {} - if (Config.model) { - completionParams.model = Config.model - } - this.chatGPTApi = new ChatGPTAPI({ - apiKey: Config.apiKey, - debug: false, - upsertMessage, - getMessageById, - completionParams, - assistantLabel: Config.assistantLabel, - fetch - }) - const currentDate = new Date().toISOString().split('T')[0] - let promptPrefix = `You are ${Config.assistantLabel}, a large language model trained by OpenAI. ${Config.promptPrefixOverride || defaultPropmtPrefix} + default: { + let completionParams = {} + if (Config.model) { + completionParams.model = Config.model + } + this.chatGPTApi = new ChatGPTAPI({ + apiKey: Config.apiKey, + debug: false, + upsertMessage, + getMessageById, + completionParams, + assistantLabel: Config.assistantLabel, + fetch + }) + const currentDate = new Date().toISOString().split('T')[0] + let promptPrefix = `You are ${Config.assistantLabel}, a large language model trained by OpenAI. ${Config.promptPrefixOverride || defaultPropmtPrefix} Current date: ${currentDate}` - let option = { - timeoutMs: 120000, - promptPrefix + let option = { + timeoutMs: 120000, + promptPrefix + } + if (conversation) { + option = Object.assign(option, conversation) + } + return await tryTimes(async () => await this.chatGPTApi.sendMessage(prompt, option), 5) } - if (conversation) { - option = Object.assign(option, conversation) - } - return await tryTimes(async () => await this.chatGPTApi.sendMessage(prompt, option), 5) } } - async emptyQueue(e) { + async emptyQueue (e) { await redis.lTrim('CHATGPT:CHAT_QUEUE', 1, 0) await this.reply('已清空当前等待队列') } - async removeQueueFirst(e) { + async removeQueueFirst (e) { let uid = await redis.lPop('CHATGPT:CHAT_QUEUE', 0) if (!uid) { await this.reply('当前等待队列为空') @@ -535,13 +564,55 @@ export class chatgpt extends plugin { } } - async totalAvailable(e) { + async getAllConversations (e) { + const use = await redis.get('CHATGPT:USE') + if (use === 'api3') { + let conversations = await getConversations(e.sender.user_id) + if (Config.debug) { + logger.mark('all conversations: ', conversations) + } + // let conversationsFirst10 = conversations.slice(0, 10) + await e.runtime.render('chatgpt-plugin', 'conversation/chatgpt', { conversations }) + let text = '对话列表\n' + text += '对话id | 对话发起者 \n' + conversations.forEach(c => { + text += c.id + '|' + (c.creater || '未知') + '\n' + }) + text += '您可以通过使用命令#chatgpt切换对话+对话id来加入指定对话' + await this.reply(text) + } else { + return await this.getConversations(e) + } + } + + async attachConversation (e) { + const use = await redis.get('CHATGPT:USE') + if (use !== 'api3') { + await this.reply('该功能目前仅支持API3模式') + } else { + let conversationId = _.trimStart(e.msg.trimStart(), '#chatgpt切换对话').trim() + if (!conversationId) { + await this.reply('无效对话id,请在#chatgpt切换对话后面加上对话id') + return false + } + // todo 验证这个对话是否存在且有效 + // await getLatestMessageIdByConversationId(conversationId) + await redis.set(`CHATGPT:QQ_CONVERSATION:${e.sender.user_id}`, conversationId) + await this.reply('切换成功') + } + } + + async totalAvailable (e) { + if (!Config.apiKey) { + this.reply('当前未配置OpenAI API key,请在插件配置文件config/index.js中配置。若使用免费的API3则无需关心计费。') + return false + } // 查询OpenAI Plus剩余试用额度 fetch('https://api.openai.com/dashboard/billing/credit_grants', { method: 'GET', headers: { 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + Config.apiKey, + Authorization: 'Bearer ' + Config.apiKey } }) .then(response => response.json()) @@ -560,7 +631,7 @@ export class chatgpt extends plugin { * @param prompt 问题 * @param conversation 对话 */ - async chatgptBrowserBased(prompt, conversation) { + async chatgptBrowserBased (prompt, conversation) { let option = { markdown: true } if (Config['2captchaToken']) { option.captchaToken = Config['2captchaToken'] diff --git a/apps/help.js b/apps/help.js index b87017e..7029743 100644 --- a/apps/help.js +++ b/apps/help.js @@ -34,7 +34,12 @@ let helpData = [ { icon: 'list', title: '#chatgpt对话列表', - desc: '查询当前哪些人正在与机器人聊天' + desc: '查询当前哪些人正在与机器人聊天.目前API3模式下支持切换对话' + }, + { + icon: 'switch', + title: '#chatgpt切换对话+对话id', + desc: '目前仅API3模式下可用,切换到指定的对话中' }, { icon: 'destroy', diff --git a/apps/management.js b/apps/management.js index 414716d..3d3369c 100644 --- a/apps/management.js +++ b/apps/management.js @@ -1,5 +1,5 @@ import plugin from '../../../lib/plugins/plugin.js' -import {Config} from "../config/index.js"; +import { Config } from '../config/index.js' import { BingAIClient } from '@waylaidwanderer/chatgpt-api' export class ChatgptManagement extends plugin { @@ -99,7 +99,7 @@ export class ChatgptManagement extends plugin { this.finish('saveToken') return } - let cookie = undefined + let cookie if (token?.indexOf('=') > -1) { cookie = token } @@ -111,7 +111,7 @@ export class ChatgptManagement extends plugin { // 异步就好了,不卡着这个context了 bingAIClient.createNewConversation().then(async res => { if (res.clientId) { - logger.info("bing token 有效") + logger.info('bing token 有效') } else { logger.error('bing token 无效', res) await this.reply(`经检测,Bing Token无效。来自Bing的错误提示:${res.result?.message}`) @@ -141,8 +141,13 @@ export class ChatgptManagement extends plugin { } async useOpenAIAPIBasedSolution (e) { - await redis.set('CHATGPT:USE', 'api') - await this.reply('已切换到基于OpenAI API的解决方案,如果已经对话过建议执行`#结束对话`避免引起404错误') + let use = await redis.get('CHATGPT:USE') + if (use !== 'api') { + await redis.set('CHATGPT:USE', 'api') + await this.reply('已切换到基于OpenAI API的解决方案,如果已经对话过建议执行`#结束对话`避免引起404错误') + } else { + await this.reply('当前已经是API模式了') + } } async useReversedAPIBasedSolution (e) { @@ -151,13 +156,33 @@ export class ChatgptManagement extends plugin { } async useReversedAPIBasedSolution2 (e) { - await redis.set('CHATGPT:USE', 'api3') - await this.reply('已切换到基于第三方Reversed ConversastionAPI的解决方案,如果已经对话过建议执行`#结束对话`避免引起404错误') + let use = await redis.get('CHATGPT:USE') + if (use !== 'api3') { + await redis.set('CHATGPT:USE', 'api3') + await this.reply('已切换到基于第三方Reversed Conversastion API(API3)的解决方案') + } else { + await this.reply('当前已经是API3模式了') + } } async useReversedBingSolution (e) { - await redis.set('CHATGPT:USE', 'bing') - await this.reply('已切换到基于微软新必应的解决方案,如果已经对话过务必执行`#结束对话`避免引起404错误') + let use = await redis.get('CHATGPT:USE') + if (use !== 'bing') { + await redis.set('CHATGPT:USE', 'bing') + // 结束所有人的对话 + const keys = await redis.keys('CHATGPT:CONVERSATIONS:*') + if (keys.length) { + const response = await redis.del(keys) + if (Config.debug) { + console.log('Deleted keys:', response) + } + } else { + console.log('No keys matched the pattern') + } + await this.reply('已切换到基于微软新必应的解决方案,如果已经对话过务必执行`#结束对话`避免引起404错误') + } else { + await this.reply('当前已经是必应Bing模式了') + } } async modeHelp () { diff --git a/config/index.js b/config/index.js index 4eff37c..7e8bebd 100644 --- a/config/index.js +++ b/config/index.js @@ -10,6 +10,10 @@ export const Config = { blockWords: ['屏蔽词1', '屏蔽词b'], // 改为true后,全局默认以图片形式回复,并自动发出Continue命令补全回答。长回复可能会有bug。 defaultUsePicture: false, + // 如果true,字数大于阈值(autoUsePictureThreshold)会自动用图片发送,即使是文本模式。 + autoUsePicture: true, + // 仅当autoUsePicture为true时生效,字数大于该阈值会自动用图片发送,即使是文本模式。 + autoUsePictureThreshold: 1200, // 每个人发起的对话保留时长。超过这个时长没有进行对话,再进行对话将开启新的对话。单位:秒 conversationPreserveTime: 0, // 触发方式 可选值:at 或 prefix 。at模式下只有at机器人才会回复。prefix模式下不需要at,但需要添加前缀#chat @@ -26,6 +30,7 @@ export const Config = { // *********************************************************************************************************************************** // from https://github.com/acheong08/ChatGPT api: 'https://chatgpt.duti.tech/api/conversation', + apiBaseUrl: 'https://chatgpt.duti.tech', // *********************************************************************************************************************************** // 以下为API2方式的配置 * // *********************************************************************************************************************************** @@ -60,7 +65,7 @@ export const Config = { '2captchaToken': '', // http或socks5代理 proxy: PROXY, - debug: false, + debug: true, // 各个地方的默认超时时间 defaultTimeoutMs: 120000, // bing默认超时时间,bing太慢了有的时候 diff --git a/resources/conversation/chatgpt.html b/resources/conversation/chatgpt.html new file mode 100644 index 0000000..d455edb --- /dev/null +++ b/resources/conversation/chatgpt.html @@ -0,0 +1,36 @@ + + + + + + + + +{{@headStyle}} + + +
+
+
ChatGPT-Plugin
+

最近对话列表

+ +
+
+
+ {{each conversations item}} +
+ +
+
{{item.id}}
+
最近问题:{{item.lastPrompt}}
+
发起者:{{item.creater}}
+
+
+ {{/each}} +
+
+ +
+ + + \ No newline at end of file diff --git a/resources/conversation/conversation.css b/resources/conversation/conversation.css new file mode 100644 index 0000000..2c952fa --- /dev/null +++ b/resources/conversation/conversation.css @@ -0,0 +1,178 @@ +@font-face { + font-family: "tttgbnumber"; + src: url("../../../../../resources/font/tttgbnumber.ttf"); + font-weight: normal; + font-style: normal; +} +* { + margin: 0; + padding: 0; + box-sizing: border-box; + user-select: none; +} +body { + font-family: sans-serif; + font-size: 16px; + width: 630px; + color: #1e1f20; + transform: scale(1.5); + transform-origin: 0 0; +} +.container { + width: 630px; + padding: 20px 15px 10px 15px; + background-color: #f5f6fb; +} +.head_box { + border-radius: 15px; + font-family: tttgbnumber; + padding: 10px 20px; + position: relative; + box-shadow: 0 5px 10px 0 rgb(0 0 0 / 15%); +} +.head_box .id_text { + font-size: 24px; +} +.head_box .day_text { + font-size: 20px; +} +.head_box .chatgpt_logo { + position: absolute; + top: 12px; + right: 15px; + width: 50px; +} +.base_info { + position: relative; + padding-left: 10px; +} +.uid { + font-family: tttgbnumber; +} + +.data_box { + border-radius: 15px; + margin-top: 20px; + margin-bottom: 15px; + padding: 20px 0px 5px 0px; + background: #fff; + box-shadow: 0 5px 10px 0 rgb(0 0 0 / 15%); + position: relative; +} +.tab_lable { + position: absolute; + top: -10px; + left: -8px; + background: #d4b98c; + color: #fff; + font-size: 14px; + padding: 3px 10px; + border-radius: 15px 0px 15px 15px; + z-index: 20; +} +.data_line { + display: flex; + justify-content: space-around; + margin-bottom: 14px; +} +.data_line_item { + width: 100px; + text-align: center; + /*margin: 0 20px;*/ +} +.num { + font-family: tttgbnumber; + font-size: 24px; +} +.data_box .lable { + font-size: 14px; + color: #7f858a; + line-height: 1; + margin-top: 3px; +} + +.list{ + display: flex; + justify-content: flex-start; + flex-wrap: wrap; +} + +.list .item-normal { + width: 575px; + display: flex; + align-items: center; + background: #f1f1f1; + padding: 8px 6px 8px 6px; + border-radius: 8px; + margin: 0 0px 10px 10px; +} +.list .item-normal .icon{ + width: 24px; + height: 24px; + background-repeat: no-repeat; + background-size: 100% 100%; + position: relative; + flex-shrink: 0; +} +.list .item-normal .title{ + font-size: 16px; + margin-left: 6px; + line-height: 20px; +} +.list .item-normal .title .text{ + color: #1995A4; + white-space: nowrap; +} +.list .item-normal .title .creater{ + font-size: 12px; + color: #69878B; + margin-top: 2px; +} +.list .item-using .title .dec{ + font-size: 12px; + color: #999; + margin-top: 2px; +} +.list .item-using { + width: 575px; + display: flex; + align-items: center; + background: #157985; + padding: 8px 6px 8px 6px; + border-radius: 8px; + margin: 0 0px 10px 10px; +} +.list .item-using .icon{ + width: 24px; + height: 24px; + background-repeat: no-repeat; + background-size: 100% 100%; + position: relative; + flex-shrink: 0; +} +.list .item-using .title{ + font-size: 16px; + margin-left: 6px; + line-height: 20px; +} +.list .item-using .title .text{ + color: #CBD4D5; + white-space: nowrap; +} +.list .item-using .title .dec{ + font-size: 12px; + color: #CBD4D5; + margin-top: 2px; +} +.list .item-using .title .creater{ + font-size: 12px; + color: #CBD4D5; + margin-top: 2px; +} + +.logo { + font-size: 14px; + font-family: "tttgbnumber"; + text-align: center; + color: #7994a7; +} \ No newline at end of file diff --git a/utils/browser.js b/utils/browser.js index fb19466..5f7b93a 100644 --- a/utils/browser.js +++ b/utils/browser.js @@ -4,8 +4,6 @@ import StealthPlugin from 'puppeteer-extra-plugin-stealth' import { getOpenAIAuth } from './openai-auth.js' import delay from 'delay' import { v4 as uuidv4 } from 'uuid' -import { pTimeout } from './common.js' -console.log({ pTimeout }) const chatUrl = 'https://chat.openai.com/chat' let puppeteer = {} diff --git a/utils/conversation.js b/utils/conversation.js new file mode 100644 index 0000000..11156f9 --- /dev/null +++ b/utils/conversation.js @@ -0,0 +1,117 @@ +import fetch from 'node-fetch' +import { Config } from '../config/index.js' + +export async function getConversations (qq = '') { + let accessToken = await redis.get('CHATGPT:TOKEN') + if (!accessToken) { + throw new Error('未绑定ChatGPT AccessToken,请使用#chatgpt设置token命令绑定token') + } + let response = await fetch(`${Config.apiBaseUrl}/api/conversations?offset=0&limit=20`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + accessToken + } + }) + let json = await response.text() + if (Config.debug) { + logger.mark(json) + } + let conversations = JSON.parse(json) + let result = conversations.items?.sort((a, b) => b.create_time - a.create_time) + let map = {} + for (let i = 0; i < conversations.items.length; i++) { + // 老用户初次更新该功能,这里频繁请求可能会429。由并行改为串行以尽量降低频率。必要时可可能还要等待。 + let item = conversations.items[i] + let cachedConversationLastMessage = await redis.get(`CHATGPT:CONVERSATION_LAST_MESSAGE_PROMPT:${item.id}`) + if (!cachedConversationLastMessage) { + map[item.id] = cachedConversationLastMessage + } else { + // 缓存中没有,就去查官方api + let conversationDetailResponse = await fetch(`${Config.apiBaseUrl}/api/conversation/${item.id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + accessToken + } + }) + let conversationDetail = await conversationDetailResponse.text() + if (Config.debug) { + logger.mark('conversation detail for conversation ' + item.id, conversationDetail) + } + conversationDetail = JSON.parse(conversationDetail) + let messages = Object.values(conversationDetail.mapping) + + messages = messages + .filter(message => message.message) + .map(messages => messages.message) + + let messagesAssistant = messages.filter(messages => messages.role === 'assistant') + .sort((a, b) => b.create_time - a.create_time) + let messagesUser = messages.filter(messages => messages.role === 'user') + .sort((a, b) => b.create_time - a.create_time) + await redis.set(`CHATGPT:CONVERSATION_LENGTH:${item.id}`, messagesUser?.length || 0) + let lastMessage = null + if (messagesUser.length > 0) { + lastMessage = messagesUser[0].content.parts[0] + await redis.set(`CHATGPT:CONVERSATION_LAST_MESSAGE_PROMPT:${item.id}`, lastMessage) + } + if (messagesAssistant.length > 0) { + await redis.set(`CHATGPT:CONVERSATION_LAST_MESSAGE_ID:${item.id}`, messagesAssistant[0].id) + } + await redis.set(`CHATGPT:CONVERSATION_CREATE_TIME:${item.id}`, new Date(conversationDetail.create_time * 1000).toLocaleString()) + map[item.id] = lastMessage + } + } + let res = [] + let usingConversationId + if (qq) { + usingConversationId = await redis.get(`CHATGPT:QQ_CONVERSATION:${qq}`) + } + let promisesPostProcess = result.map(async conversation => { + conversation.lastPrompt = map[conversation.id] + conversation.create_time = new Date(conversation.create_time).toLocaleString() + // 这里的时间格式还可以。不用管了。conversation.create_time = + // title 全是 New chat,不要了 + delete conversation.title + conversation.creater = await redis.get(`CHATGPT:CONVERSATION_CREATER_NICK_NAME:${conversation.id}`) + if (qq && conversation.id === usingConversationId) { + conversation.status = 'using' + } else { + conversation.status = 'normal' + } + if (conversation.lastPrompt?.length > 80) { + conversation.lastPrompt = conversation.lastPrompt.slice(0, 80) + '......' + } + res.push(conversation) + }) + await Promise.all(promisesPostProcess) + return res +} + +export async function getLatestMessageIdByConversationId (conversationId) { + let accessToken = await redis.get('CHATGPT:TOKEN') + if (!accessToken) { + throw new Error('未绑定ChatGPT AccessToken,请使用#chatgpt设置token命令绑定token') + } + let conversationDetailResponse = await fetch(`${Config.apiBaseUrl}/api/conversation/${conversationId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + accessToken + } + }) + let conversationDetail = await conversationDetailResponse.text() + if (Config.debug) { + logger.mark('conversation detail for conversation ' + conversationId, conversationDetail) + } + conversationDetail = JSON.parse(conversationDetail) + let messages = Object.values(conversationDetail.mapping) + messages = messages + .filter(message => message.message) + .map(messages => messages.message) + .filter(messages => messages.role === 'assistant') + .sort((a, b) => b.create_time - a.create_time) + await redis.set(`CHATGPT:CONVERSATION_LAST_MESSAGE_ID:${conversationId}`, messages[0].id) + return messages[0].id +} diff --git a/utils/redis-key.js b/utils/redis-key.js new file mode 100644 index 0000000..e69de29