From 371c083d6ae08daf4065270063e447508dc7f42b Mon Sep 17 00:00:00 2001 From: tetsuya-k <64536338+tetsuya-ki@users.noreply.github.com> Date: Wed, 25 Oct 2023 18:56:28 +0900 Subject: [PATCH 01/18] =?UTF-8?q?=E3=82=AB=E3=82=B9=E3=82=BF=E3=83=A0?= =?UTF-8?q?=E7=B5=B5=E6=96=87=E5=AD=97=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=81=AE=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 2 + src/index.ts | 2 + src/modules/check-custom-emojis/index.ts | 146 +++++++++++++++++++++++ src/serifs.ts | 5 + 4 files changed, 155 insertions(+) create mode 100644 src/modules/check-custom-emojis/index.ts diff --git a/src/config.ts b/src/config.ts index 1918b4f..a404782 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ type Config = { host: string; + serverName: string; i: string; master?: string; wsUrl: string; @@ -9,6 +10,7 @@ type Config = { notingEnabled: boolean; chartEnabled: boolean; serverMonitoring: boolean; + checkEmojisEnabled: boolean; mecab?: string; mecabDic?: string; memoryDir?: string; diff --git a/src/index.ts b/src/index.ts index dca5703..c6c2fa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import SleepReportModule from './modules/sleep-report'; import NotingModule from './modules/noting'; import PollModule from './modules/poll'; import ReminderModule from './modules/reminder'; +import CheckCustomEmojisModule from './modules/check-custom-emojis'; console.log(' __ ____ _____ ___ '); console.log(' /__\\ (_ _)( _ )/ __)'); @@ -88,6 +89,7 @@ promiseRetry(retry => { new NotingModule(), new PollModule(), new ReminderModule(), + new CheckCustomEmojisModule(), ]); }).catch(e => { log(chalk.red('Failed to fetch the account')); diff --git a/src/modules/check-custom-emojis/index.ts b/src/modules/check-custom-emojis/index.ts new file mode 100644 index 0000000..70653be --- /dev/null +++ b/src/modules/check-custom-emojis/index.ts @@ -0,0 +1,146 @@ +import autobind from 'autobind-decorator'; +import * as loki from 'lokijs'; +import Module from '@/module'; +import serifs from '@/serifs'; +import config from '@/config'; +import Message from '@/message'; + +export default class extends Module { + public readonly name = 'checkCustomEmojis'; + + private lastEmoji: loki.Collection<{ + id: string; + updatedAt: number; + }>; + + @autobind + public install() { + if (config.checkEmojisEnabled === false) return {}; + this.lastEmoji = this.ai.getCollection('lastEmoji', { + indices: ['id'] + }); + + this.timeCheck(); + setInterval(this.timeCheck, 1000 * 60 * 3); + + return { + mentionHook: this.mentionHook + }; + } + + @autobind + private timeCheck() { + const now = new Date(); + if (now.getHours() !== 23) return; + const date = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`; + const data = this.getData(); + if (data.lastPosted == date) return; + data.lastPosted = date; + this.setData(data); + + this.log('Time to check custom emojis!'); + this.post(); + } + + @autobind + private async post() { + this.log('Start to check custom emojis.'); + const lastEmoji = this.lastEmoji.find({}); + // this.log('lastEmoji'); + // this.log(JSON.stringify(lastEmoji,null,'\t')); + + const lastId = lastEmoji.length != 0 ? lastEmoji[0].id : null; + const emojisData = await this.checkCumstomEmojis(lastId); + if (emojisData.length == 0) return; + + // 絵文字データが取得された場合、元々のデータを削除しておく + const emojiSize = emojisData.length; + this.lastEmoji.remove(lastEmoji); + + // 概要について投稿 + const server_name = config.serverName ? config.serverName : 'このサーバー'; + this.log('Posting...'); + this.log(serifs.checkCustomEmojis.post(server_name, emojiSize)); + await this.ai.post({ + text: serifs.checkCustomEmojis.post(server_name, emojiSize) + }); + + // 各絵文字について投稿 + for (const emoji of emojisData){ + await this.ai.post({ + text: serifs.checkCustomEmojis.emojiPost(emoji.name) + }); + this.log(serifs.checkCustomEmojis.emojiPost(emoji.name)); + } + + // データの保存 + this.log('Last custom emoji data saving...'); + this.log(JSON.stringify(emojisData[emojiSize-1],null,'\t')); + this.lastEmoji.insertOne({ + id: emojisData[emojiSize-1].id, + updatedAt: Date.now() + }); + this.log('Check custom emojis finished!'); + } + + @autobind + private async checkCumstomEmojis(lastId : any) { + this.log('CustomEmojis fetching...'); + let emojisData; + if(lastId != null){ + this.log('lastId is **not** null'); + emojisData = await this.ai.api('admin/emoji/list', { + sinceId: lastId, + limit: 30 + }); + } else { + this.log('lastId is null'); + emojisData = await this.ai.api('admin/emoji/list', { + limit: 100 + }); + + // 最後まで取得 + let beforeEmoji = null; + let afterEmoji = emojisData.length > 1 ? emojisData[0] : null; + while(emojisData.length == 100 && beforeEmoji != afterEmoji){ + const lastId = emojisData[emojisData.length-1].id; + // sinceIdを指定して再度取り直す + emojisData = await this.ai.api('admin/emoji/list', { + limit: 100, + sinceId: lastId + }); + beforeEmoji = afterEmoji; + afterEmoji = emojisData.length > 1 ? emojisData[0] : null; + await this.sleep(50); + } + + // sinceIdが未指定の場合、末尾から5件程度にしておく + let newJson: any[] = []; + for (let i = emojisData.length - 5; i < emojisData.length; i++) { + newJson.push(emojisData[i]); + } + emojisData = newJson; + } + return emojisData; + } + + @autobind + private async mentionHook(msg: Message) { + if (!msg.includes(['カスタムえもじチェック'])) { + return false; + } else { + this.log('Check custom emojis requested'); + } + + await this.post(); + + return { + reaction: 'like' + }; + } + + @autobind + private async sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)); + } +} diff --git a/src/serifs.ts b/src/serifs.ts index 15ae16e..32515d5 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -381,6 +381,11 @@ export default { foryou: '描きました!' }, + checkCustomEmojis: { + post: (server_name, num) => `${server_name}に${num}件の絵文字が追加されました!`, + emojiPost: emoji => `:${emoji}:\n(\`${emoji}\`) #AddCustomEmojis` + }, + sleepReport: { report: hours => `んぅ、${hours}時間くらい寝ちゃってたみたいです`, reportUtatane: 'ん... うたた寝しちゃってました', From 1ebf2c7d372fcd74d5798003b371293047fb67fd Mon Sep 17 00:00:00 2001 From: tetsuya-k <64536338+tetsuya-ki@users.noreply.github.com> Date: Mon, 8 Jan 2024 13:18:11 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=E3=82=AB=E3=82=B9=E3=82=BF=E3=83=A0?= =?UTF-8?q?=E7=B5=B5=E6=96=87=E5=AD=97=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.mdやtorisetu.mdに記載 - カスタム絵文字投稿をまとめる設定を追加(checkEmojisAtOnce) --- README.md | 4 ++ src/config.ts | 5 ++- src/modules/check-custom-emojis/index.ts | 54 +++++++++++++++--------- src/serifs.ts | 4 +- torisetu.md | 3 ++ 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 87dcf11..042e75e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Misskey用の日本語Botです。 "chartEnabled": "チャート機能を無効化する場合は false を入れてください", "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)", "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", + "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", + "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", "memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))" @@ -40,6 +42,8 @@ Misskey用の日本語Botです。 "chartEnabled": "チャート機能を無効化する場合は false を入れてください", "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)", "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", + "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", + "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "memoryDir": "data" diff --git a/src/config.ts b/src/config.ts index a404782..59154a3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ type Config = { host: string; - serverName: string; + serverName?: string; i: string; master?: string; wsUrl: string; @@ -10,7 +10,8 @@ type Config = { notingEnabled: boolean; chartEnabled: boolean; serverMonitoring: boolean; - checkEmojisEnabled: boolean; + checkEmojisEnabled?: boolean; + checkEmojisAtOnce?: boolean; mecab?: string; mecabDic?: string; memoryDir?: string; diff --git a/src/modules/check-custom-emojis/index.ts b/src/modules/check-custom-emojis/index.ts index 70653be..d164c59 100644 --- a/src/modules/check-custom-emojis/index.ts +++ b/src/modules/check-custom-emojis/index.ts @@ -15,7 +15,7 @@ export default class extends Module { @autobind public install() { - if (config.checkEmojisEnabled === false) return {}; + if (!config.checkEmojisEnabled) return {}; this.lastEmoji = this.ai.getCollection('lastEmoji', { indices: ['id'] }); @@ -38,49 +38,65 @@ export default class extends Module { data.lastPosted = date; this.setData(data); - this.log('Time to check custom emojis!'); + this.log('Time to Check CustomEmojis!'); this.post(); } @autobind private async post() { - this.log('Start to check custom emojis.'); + this.log('Start to Check CustomEmojis.'); const lastEmoji = this.lastEmoji.find({}); - // this.log('lastEmoji'); - // this.log(JSON.stringify(lastEmoji,null,'\t')); const lastId = lastEmoji.length != 0 ? lastEmoji[0].id : null; const emojisData = await this.checkCumstomEmojis(lastId); - if (emojisData.length == 0) return; + if (emojisData.length == 0) { + this.log('No CustomEmojis Added.'); + return; + } // 絵文字データが取得された場合、元々のデータを削除しておく const emojiSize = emojisData.length; this.lastEmoji.remove(lastEmoji); - // 概要について投稿 const server_name = config.serverName ? config.serverName : 'このサーバー'; this.log('Posting...'); - this.log(serifs.checkCustomEmojis.post(server_name, emojiSize)); - await this.ai.post({ - text: serifs.checkCustomEmojis.post(server_name, emojiSize) - }); - // 各絵文字について投稿 - for (const emoji of emojisData){ + // 一気に投稿しないver + if (!config.checkEmojisAtOnce){ + // 概要について投稿 + this.log(serifs.checkCustomEmojis.post(server_name, emojiSize)); await this.ai.post({ - text: serifs.checkCustomEmojis.emojiPost(emoji.name) + text: serifs.checkCustomEmojis.post(server_name, emojiSize) + }); + + // 各絵文字について投稿 + for (const emoji of emojisData){ + await this.ai.post({ + text: serifs.checkCustomEmojis.emojiPost(emoji.name) + }); + this.log(serifs.checkCustomEmojis.emojiPost(emoji.name)); + } + } else { + // 一気に投稿ver + let text = ''; + for (const emoji of emojisData){ + text += serifs.checkCustomEmojis.emojiOnce(emoji.name); + } + const message = serifs.checkCustomEmojis.postOnce(server_name, emojiSize, text); + this.log(message); + await this.ai.post({ + text: message }); - this.log(serifs.checkCustomEmojis.emojiPost(emoji.name)); } // データの保存 - this.log('Last custom emoji data saving...'); + this.log('Last CustomEmojis data saving...'); this.log(JSON.stringify(emojisData[emojiSize-1],null,'\t')); this.lastEmoji.insertOne({ id: emojisData[emojiSize-1].id, updatedAt: Date.now() }); - this.log('Check custom emojis finished!'); + this.log('Check CustomEmojis finished!'); } @autobind @@ -126,10 +142,10 @@ export default class extends Module { @autobind private async mentionHook(msg: Message) { - if (!msg.includes(['カスタムえもじチェック'])) { + if (!msg.includes(['カスタムえもじチェック','カスタムえもじを調べて','カスタムえもじを確認'])) { return false; } else { - this.log('Check custom emojis requested'); + this.log('Check CustomEmojis requested'); } await this.post(); diff --git a/src/serifs.ts b/src/serifs.ts index 32515d5..49fc8d4 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -383,7 +383,9 @@ export default { checkCustomEmojis: { post: (server_name, num) => `${server_name}に${num}件の絵文字が追加されました!`, - emojiPost: emoji => `:${emoji}:\n(\`${emoji}\`) #AddCustomEmojis` + emojiPost: emoji => `:${emoji}:\n(\`${emoji}\`) #AddCustomEmojis`, + postOnce: (server_name, num, text) => `${server_name}に${num}件の絵文字が追加されました!\n${text} #AddCustomEmojis`, + emojiOnce: emoji => `:${emoji}:(\`${emoji}\`)` }, sleepReport: { diff --git a/torisetu.md b/torisetu.md index 3b1c13b..0d29985 100644 --- a/torisetu.md +++ b/torisetu.md @@ -75,6 +75,9 @@ Misskeyにアカウントを作成して初めて投稿を行うと、藍がネ ### ping PONGを返します。生存確認にどうぞ +### カスタム絵文字チェック +1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。 + ### その他反応するフレーズ (トークのみ) * かわいい * なでなで From 47e17052c5f67c9b384002f7bf848e268492a7d7 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Sun, 25 Feb 2024 19:24:53 +0900 Subject: [PATCH 03/18] =?UTF-8?q?Feat:=20=20aichat=E6=A9=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 現時点ではGemini APIのみ対応 --- README.md | 4 ++ src/config.ts | 2 + src/index.ts | 2 + src/modules/aichat/index.ts | 128 ++++++++++++++++++++++++++++++++++++ src/serifs.ts | 5 ++ torisetu.md | 6 ++ 6 files changed, 147 insertions(+) create mode 100644 src/modules/aichat/index.ts diff --git a/README.md b/README.md index 042e75e..cfc8bb9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", "memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))" @@ -44,6 +46,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "memoryDir": "data" diff --git a/src/config.ts b/src/config.ts index 9466b0c..119daa8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,8 @@ type Config = { serverMonitoring: boolean; checkEmojisEnabled?: boolean; checkEmojisAtOnce?: boolean; + geminiProApiKey?: string; + prompt?: string; mecab?: string; mecabDic?: string; memoryDir?: string; diff --git a/src/index.ts b/src/index.ts index 2f85f93..991a6d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import NotingModule from './modules/noting/index.js'; import PollModule from './modules/poll/index.js'; import ReminderModule from './modules/reminder/index.js'; import CheckCustomEmojisModule from './modules/check-custom-emojis/index.js'; +import AiChatModule from './modules/aichat/index.js'; console.log(' __ ____ _____ ___ '); console.log(' /__\\ (_ _)( _ )/ __)'); @@ -96,6 +97,7 @@ promiseRetry(retry => { new PollModule(), new ReminderModule(), new CheckCustomEmojisModule(), + new AiChatModule(), ]); }).catch(e => { log(chalk.red('Failed to fetch the account')); diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts new file mode 100644 index 0000000..5c6dfbc --- /dev/null +++ b/src/modules/aichat/index.ts @@ -0,0 +1,128 @@ +import { bindThis } from '@/decorators.js'; +import Module from '@/module.js'; +import serifs from '@/serifs.js'; +import Message from '@/message.js'; +import config from '@/config.js'; +import got from 'got'; + +type AiChat = { + question: string; + prompt: string; + api: string; + key: string; +}; +const GEMINI_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'; + +export default class extends Module { + public readonly name = 'aichat'; + + @bindThis + public install() { + return { + mentionHook: this.mentionHook + }; + } + + @bindThis + private async genTextByGemini(aiChat: AiChat) { + this.log('Generate Text By Gemini...'); + var options = { + url: aiChat.api, + searchParams: { + key: aiChat.key, + }, + json: { + contents: [{ + parts:[{ + text: aiChat.prompt + aiChat.question + }] + }] + }, + }; + let res_data:any = null; + try { + res_data = await got.post(options, + {parseJson: res => JSON.parse(res)}).json(); + this.log(JSON.stringify(res_data)); + if (res_data.hasOwnProperty('candidates')) { + if (res_data.candidates.length > 0) { + if (res_data.candidates[0].hasOwnProperty('content')) { + if (res_data.candidates[0].content.hasOwnProperty('parts')) { + if (res_data.candidates[0].content.parts.length > 0) { + if (res_data.candidates[0].content.parts[0].hasOwnProperty('text')) { + return res_data.candidates[0].content.parts[0].text; + } + } + } + } + } + } + } catch (err: unknown) { + this.log('Error By Call Gemini'); + if (err instanceof Error) { + this.log(`${err.name}\n${err.message}`); + } + } + return null; + } + + @bindThis + private async mentionHook(msg: Message) { + if (!msg.includes([this.name])) { + return false; + } else { + this.log('AiChat requested'); + } + + const kigo = '&'; + let type = 'gemini'; + if (msg.includes([kigo + 'gemini'])) { + type = 'gemini'; + } else if (msg.includes([kigo + 'chatgpt4'])) { + type = 'chatgpt4'; + } else if (msg.includes([kigo + 'chatgpt'])) { + type = 'chatgpt3.5'; + } + const question = msg.extractedText + .replace(this.name, '') + .replace(kigo + type, '') + .trim(); + + let text; + let prompt = ''; + if (config.prompt) { + prompt = config.prompt; + } + switch(type) { + case 'gemini': + if (!config.geminiProApiKey) { + msg.reply(serifs.aichat.nothing(type)); + return false; + } + const aiChat = { + question: question, + prompt: prompt, + api: GEMINI_API, + key: config.geminiProApiKey + }; + text = await this.genTextByGemini(aiChat); + break; + default: + msg.reply(serifs.aichat.nothing(type)); + return false; + } + + if (text == null) { + this.log('The result is invalid. It seems that tokens and other items need to be reviewed.') + msg.reply(serifs.aichat.nothing(type)); + return false; + } + + this.log('Replying...'); + msg.reply(serifs.aichat.post(text, type)); + + return { + reaction: 'like' + }; + } +} diff --git a/src/serifs.ts b/src/serifs.ts index 49fc8d4..2fe0c15 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -388,6 +388,11 @@ export default { emojiOnce: emoji => `:${emoji}:(\`${emoji}\`)` }, + aichat: { + nothing: type => `あぅ... ${type}のAPIキーが登録されてないみたいです`, + post: (text, type) => `${text} (${type}) #aichat`, + }, + sleepReport: { report: hours => `んぅ、${hours}時間くらい寝ちゃってたみたいです`, reportUtatane: 'ん... うたた寝しちゃってました', diff --git a/torisetu.md b/torisetu.md index 0d29985..4ab4b97 100644 --- a/torisetu.md +++ b/torisetu.md @@ -78,6 +78,12 @@ PONGを返します。生存確認にどうぞ ### カスタム絵文字チェック 1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。 +### aichat +``` +@ai aichat 部屋の片付けの手順を教えて +``` +のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。 + ### その他反応するフレーズ (トークのみ) * かわいい * なでなで From c30659e526fff60b6f350971eb976e25bb3017ae Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Fri, 8 Mar 2024 23:25:17 +0900 Subject: [PATCH 04/18] =?UTF-8?q?enhance:=20aichat=E6=A9=9F=E8=83=BD(?= =?UTF-8?q?=E7=94=BB=E5=83=8F=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/aichat/index.ts | 75 +++++++++++++++++++++++++++++++------ src/utils/url2base64.ts | 16 ++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 src/utils/url2base64.ts diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 5c6dfbc..deb4466 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -3,6 +3,7 @@ import Module from '@/module.js'; import serifs from '@/serifs.js'; import Message from '@/message.js'; import config from '@/config.js'; +import urlToBase64 from '@/utils/url2base64.js'; import got from 'got'; type AiChat = { @@ -11,7 +12,12 @@ type AiChat = { api: string; key: string; }; +type Base64Image = { + type: string; + base64: string; +}; const GEMINI_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'; +const GEMINI_VISION_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent'; export default class extends Module { public readonly name = 'aichat'; @@ -24,21 +30,34 @@ export default class extends Module { } @bindThis - private async genTextByGemini(aiChat: AiChat) { + private async genTextByGemini(aiChat: AiChat, image:Base64Image|null) { this.log('Generate Text By Gemini...'); - var options = { + let parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[]; + if (image === null) { + // 画像がない場合、メッセージのみで問い合わせ + parts = [{text: aiChat.prompt + aiChat.question}]; + } else { + // 画像が存在する場合、画像を添付して問い合わせ + parts = [ + { text: aiChat.prompt + aiChat.question }, + { + inline_data: { + mime_type: image.type, + data: image.base64, + }, + }, + ]; + } + let options = { url: aiChat.api, searchParams: { key: aiChat.key, }, json: { - contents: [{ - parts:[{ - text: aiChat.prompt + aiChat.question - }] - }] + contents: {parts: parts} }, }; + this.log(JSON.stringify(options)); let res_data:any = null; try { res_data = await got.post(options, @@ -60,7 +79,35 @@ export default class extends Module { } catch (err: unknown) { this.log('Error By Call Gemini'); if (err instanceof Error) { - this.log(`${err.name}\n${err.message}`); + this.log(`${err.name}\n${err.message}\n${err.stack}`); + } + } + return null; + } + + @bindThis + private async note2base64Image(notesId: string) { + const noteData = await this.ai.api('notes/show', { noteId: notesId }); + let fileType: string | undefined,thumbnailUrl: string | undefined; + if (noteData !== null && noteData.hasOwnProperty('files')) { + if (noteData.files.length > 0) { + if (noteData.files[0].hasOwnProperty('type')) { + fileType = noteData.files[0].type; + } + if (noteData.files[0].hasOwnProperty('thumbnailUrl')) { + thumbnailUrl = noteData.files[0].thumbnailUrl; + } + } + if (fileType !== undefined && thumbnailUrl !== undefined) { + try { + const image = await urlToBase64(thumbnailUrl); + const base64Image:Base64Image = {type: fileType, base64: image}; + return base64Image; + } catch (err: unknown) { + if (err instanceof Error) { + this.log(`${err.name}\n${err.message}\n${err.stack}`); + } + } } } return null; @@ -88,8 +135,8 @@ export default class extends Module { .replace(kigo + type, '') .trim(); - let text; - let prompt = ''; + let text:string, aiChat:AiChat; + let prompt:string = ''; if (config.prompt) { prompt = config.prompt; } @@ -99,13 +146,17 @@ export default class extends Module { msg.reply(serifs.aichat.nothing(type)); return false; } - const aiChat = { + const base64Image:Base64Image|null = await this.note2base64Image(msg.id); + aiChat = { question: question, prompt: prompt, api: GEMINI_API, key: config.geminiProApiKey }; - text = await this.genTextByGemini(aiChat); + if (base64Image !== null) { + aiChat.api = GEMINI_VISION_API; + } + text = await this.genTextByGemini(aiChat, base64Image); break; default: msg.reply(serifs.aichat.nothing(type)); diff --git a/src/utils/url2base64.ts b/src/utils/url2base64.ts new file mode 100644 index 0000000..7350a66 --- /dev/null +++ b/src/utils/url2base64.ts @@ -0,0 +1,16 @@ +import log from '@/utils/log.js'; +import got from 'got'; + +export default async function(url: string): Promise { + try { + const buffer = await got(url).buffer(); + const base64Image = buffer.toString('base64'); + return base64Image; + } catch (err: unknown) { + log('Error in url2base64'); + if (err instanceof Error) { + log(`${err.name}\n${err.message}\n${err.stack}`); + } + throw err; + } +} From c545a3045c679b9b063754293d1262899bf8cca8 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:34:49 +0900 Subject: [PATCH 05/18] fix #2 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5a5fcf1..0685bfc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN if [ $enable_mecab -ne 0 ]; then apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt-get/lists/* \ && cd /opt \ - && git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git \ + && git clone --depth 1 https://github.com/yokomotod/mecab-ipadic-neologd.git \ && cd /opt/mecab-ipadic-neologd \ && ./bin/install-mecab-ipadic-neologd -n -y \ && rm -rf /opt/mecab-ipadic-neologd \ From 3056d0ead517a5327a8a91ae9e42b0705d50209c Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:44:26 +0900 Subject: [PATCH 06/18] fix #4 --- src/modules/aichat/index.ts | 14 ++++++++------ src/serifs.ts | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index deb4466..817e1d8 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -16,8 +16,8 @@ type Base64Image = { type: string; base64: string; }; -const GEMINI_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'; -const GEMINI_VISION_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent'; +const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; +const GEMINI_15_PRO_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent'; export default class extends Module { public readonly name = 'aichat'; @@ -131,6 +131,7 @@ export default class extends Module { type = 'chatgpt3.5'; } const question = msg.extractedText + .toLowerCase() .replace(this.name, '') .replace(kigo + type, '') .trim(); @@ -142,6 +143,7 @@ export default class extends Module { } switch(type) { case 'gemini': + // geminiの場合、APIキーが必須 if (!config.geminiProApiKey) { msg.reply(serifs.aichat.nothing(type)); return false; @@ -150,11 +152,11 @@ export default class extends Module { aiChat = { question: question, prompt: prompt, - api: GEMINI_API, + api: GEMINI_15_PRO_API, key: config.geminiProApiKey }; - if (base64Image !== null) { - aiChat.api = GEMINI_VISION_API; + if (msg.includes([kigo + 'gemini-flash'])) { + aiChat.api = GEMINI_15_FLASH_API; } text = await this.genTextByGemini(aiChat, base64Image); break; @@ -165,7 +167,7 @@ export default class extends Module { if (text == null) { this.log('The result is invalid. It seems that tokens and other items need to be reviewed.') - msg.reply(serifs.aichat.nothing(type)); + msg.reply(serifs.aichat.error(type)); return false; } diff --git a/src/serifs.ts b/src/serifs.ts index 2fe0c15..4bf19b2 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -390,6 +390,7 @@ export default { aichat: { nothing: type => `あぅ... ${type}のAPIキーが登録されてないみたいです`, + error: type => `うぇ...${type}でエラーが発生しちゃったみたいです。gemini-flashだと動くかも?`, post: (text, type) => `${text} (${type}) #aichat`, }, From 43cc2457b5224b6e7c75ec1b9077239d4c5a7daa Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:47:32 +0900 Subject: [PATCH 07/18] fix #5 --- src/config.ts | 1 + src/modules/aichat/index.ts | 62 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 119daa8..5f93baa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ type Config = { checkEmojisEnabled?: boolean; checkEmojisAtOnce?: boolean; geminiProApiKey?: string; + pLaMoApiKey?: string; prompt?: string; mecab?: string; mecabDic?: string; diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 817e1d8..3ba07fc 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -18,6 +18,7 @@ type Base64Image = { }; const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; const GEMINI_15_PRO_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent'; +const PLAMO_API = 'https://platform.preferredai.jp/api/completion/v1/chat/completions'; export default class extends Module { public readonly name = 'aichat'; @@ -85,6 +86,47 @@ export default class extends Module { return null; } + @bindThis + private async genTextByPLaMo(aiChat: AiChat) { + this.log('Generate Text By PLaMo...'); + + let options = { + url: aiChat.api, + headers: { + Authorization: 'Bearer ' + aiChat.key + }, + json: { + model: 'plamo-beta', + messages: [ + {role: 'system', content: aiChat.prompt}, + {role: 'user', content: aiChat.question}, + ], + }, + }; + this.log(JSON.stringify(options)); + let res_data:any = null; + try { + res_data = await got.post(options, + {parseJson: res => JSON.parse(res)}).json(); + this.log(JSON.stringify(res_data)); + if (res_data.hasOwnProperty('choices')) { + if (res_data.choices.length > 0) { + if (res_data.choices[0].hasOwnProperty('message')) { + if (res_data.choices[0].message.hasOwnProperty('content')) { + return res_data.choices[0].message.content; + } + } + } + } + } catch (err: unknown) { + this.log('Error By Call PLaMo'); + if (err instanceof Error) { + this.log(`${err.name}\n${err.message}\n${err.stack}`); + } + } + return null; + } + @bindThis private async note2base64Image(notesId: string) { const noteData = await this.ai.api('notes/show', { noteId: notesId }); @@ -129,6 +171,8 @@ export default class extends Module { type = 'chatgpt4'; } else if (msg.includes([kigo + 'chatgpt'])) { type = 'chatgpt3.5'; + } else if (msg.includes([kigo + 'plamo'])) { + type = 'plamo'; } const question = msg.extractedText .toLowerCase() @@ -160,7 +204,23 @@ export default class extends Module { } text = await this.genTextByGemini(aiChat, base64Image); break; - default: + + case 'PLaMo': + // PLaMoの場合、APIキーが必須 + if (!config.pLaMoApiKey) { + msg.reply(serifs.aichat.nothing(type)); + return false; + } + aiChat = { + question: question, + prompt: prompt, + api: PLAMO_API, + key: config.pLaMoApiKey + }; + text = await this.genTextByPLaMo(aiChat); + break; + + default: msg.reply(serifs.aichat.nothing(type)); return false; } From ed3f60cb418e0fbcbec6e27e311fa059e5de7e08 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:55:25 +0900 Subject: [PATCH 08/18] =?UTF-8?q?README.md=E3=81=ABPLaMo=E3=81=AB=E3=81=A4?= =?UTF-8?q?=E3=81=84=E3=81=A6=E8=A8=98=E8=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cfc8bb9..aca38a7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", - "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", + "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", @@ -46,7 +47,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", - "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", + "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", From 95b5e5e7023f4d71fbf876d8b24224b28727765b Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:27:33 +0900 Subject: [PATCH 09/18] =?UTF-8?q?aichat=E3=81=AE=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=88=97=E7=BD=AE=E6=8F=9B=E3=82=92=E6=AD=A3=E8=A6=8F=E8=A1=A8?= =?UTF-8?q?=E7=8F=BE=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/aichat/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 3ba07fc..861e5b7 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -174,10 +174,11 @@ export default class extends Module { } else if (msg.includes([kigo + 'plamo'])) { type = 'plamo'; } + const reName = RegExp(this.name, "i"); + const reKigoType = RegExp(kigo + type, "i"); const question = msg.extractedText - .toLowerCase() - .replace(this.name, '') - .replace(kigo + type, '') + .replace(reName, '') + .replace(reKigoType, '') .trim(); let text:string, aiChat:AiChat; From f39761490fa8514c2f7412ef2d83c1c56615d7e5 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Sun, 25 Feb 2024 19:24:53 +0900 Subject: [PATCH 10/18] =?UTF-8?q?Feat:=20=20aichat=E6=A9=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 現時点ではGemini APIのみ対応 --- README.md | 4 ++ src/config.ts | 2 + src/index.ts | 2 + src/modules/aichat/index.ts | 128 ++++++++++++++++++++++++++++++++++++ src/serifs.ts | 5 ++ torisetu.md | 6 ++ 6 files changed, 147 insertions(+) create mode 100644 src/modules/aichat/index.ts diff --git a/README.md b/README.md index 042e75e..cfc8bb9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", "memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))" @@ -44,6 +46,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "memoryDir": "data" diff --git a/src/config.ts b/src/config.ts index 9466b0c..119daa8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,8 @@ type Config = { serverMonitoring: boolean; checkEmojisEnabled?: boolean; checkEmojisAtOnce?: boolean; + geminiProApiKey?: string; + prompt?: string; mecab?: string; mecabDic?: string; memoryDir?: string; diff --git a/src/index.ts b/src/index.ts index 2f85f93..991a6d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import NotingModule from './modules/noting/index.js'; import PollModule from './modules/poll/index.js'; import ReminderModule from './modules/reminder/index.js'; import CheckCustomEmojisModule from './modules/check-custom-emojis/index.js'; +import AiChatModule from './modules/aichat/index.js'; console.log(' __ ____ _____ ___ '); console.log(' /__\\ (_ _)( _ )/ __)'); @@ -96,6 +97,7 @@ promiseRetry(retry => { new PollModule(), new ReminderModule(), new CheckCustomEmojisModule(), + new AiChatModule(), ]); }).catch(e => { log(chalk.red('Failed to fetch the account')); diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts new file mode 100644 index 0000000..5c6dfbc --- /dev/null +++ b/src/modules/aichat/index.ts @@ -0,0 +1,128 @@ +import { bindThis } from '@/decorators.js'; +import Module from '@/module.js'; +import serifs from '@/serifs.js'; +import Message from '@/message.js'; +import config from '@/config.js'; +import got from 'got'; + +type AiChat = { + question: string; + prompt: string; + api: string; + key: string; +}; +const GEMINI_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'; + +export default class extends Module { + public readonly name = 'aichat'; + + @bindThis + public install() { + return { + mentionHook: this.mentionHook + }; + } + + @bindThis + private async genTextByGemini(aiChat: AiChat) { + this.log('Generate Text By Gemini...'); + var options = { + url: aiChat.api, + searchParams: { + key: aiChat.key, + }, + json: { + contents: [{ + parts:[{ + text: aiChat.prompt + aiChat.question + }] + }] + }, + }; + let res_data:any = null; + try { + res_data = await got.post(options, + {parseJson: res => JSON.parse(res)}).json(); + this.log(JSON.stringify(res_data)); + if (res_data.hasOwnProperty('candidates')) { + if (res_data.candidates.length > 0) { + if (res_data.candidates[0].hasOwnProperty('content')) { + if (res_data.candidates[0].content.hasOwnProperty('parts')) { + if (res_data.candidates[0].content.parts.length > 0) { + if (res_data.candidates[0].content.parts[0].hasOwnProperty('text')) { + return res_data.candidates[0].content.parts[0].text; + } + } + } + } + } + } + } catch (err: unknown) { + this.log('Error By Call Gemini'); + if (err instanceof Error) { + this.log(`${err.name}\n${err.message}`); + } + } + return null; + } + + @bindThis + private async mentionHook(msg: Message) { + if (!msg.includes([this.name])) { + return false; + } else { + this.log('AiChat requested'); + } + + const kigo = '&'; + let type = 'gemini'; + if (msg.includes([kigo + 'gemini'])) { + type = 'gemini'; + } else if (msg.includes([kigo + 'chatgpt4'])) { + type = 'chatgpt4'; + } else if (msg.includes([kigo + 'chatgpt'])) { + type = 'chatgpt3.5'; + } + const question = msg.extractedText + .replace(this.name, '') + .replace(kigo + type, '') + .trim(); + + let text; + let prompt = ''; + if (config.prompt) { + prompt = config.prompt; + } + switch(type) { + case 'gemini': + if (!config.geminiProApiKey) { + msg.reply(serifs.aichat.nothing(type)); + return false; + } + const aiChat = { + question: question, + prompt: prompt, + api: GEMINI_API, + key: config.geminiProApiKey + }; + text = await this.genTextByGemini(aiChat); + break; + default: + msg.reply(serifs.aichat.nothing(type)); + return false; + } + + if (text == null) { + this.log('The result is invalid. It seems that tokens and other items need to be reviewed.') + msg.reply(serifs.aichat.nothing(type)); + return false; + } + + this.log('Replying...'); + msg.reply(serifs.aichat.post(text, type)); + + return { + reaction: 'like' + }; + } +} diff --git a/src/serifs.ts b/src/serifs.ts index 49fc8d4..2fe0c15 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -388,6 +388,11 @@ export default { emojiOnce: emoji => `:${emoji}:(\`${emoji}\`)` }, + aichat: { + nothing: type => `あぅ... ${type}のAPIキーが登録されてないみたいです`, + post: (text, type) => `${text} (${type}) #aichat`, + }, + sleepReport: { report: hours => `んぅ、${hours}時間くらい寝ちゃってたみたいです`, reportUtatane: 'ん... うたた寝しちゃってました', diff --git a/torisetu.md b/torisetu.md index 0d29985..4ab4b97 100644 --- a/torisetu.md +++ b/torisetu.md @@ -78,6 +78,12 @@ PONGを返します。生存確認にどうぞ ### カスタム絵文字チェック 1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。 +### aichat +``` +@ai aichat 部屋の片付けの手順を教えて +``` +のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。 + ### その他反応するフレーズ (トークのみ) * かわいい * なでなで From 260621950ade46b03251020227514f319ec340fc Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Fri, 8 Mar 2024 23:25:17 +0900 Subject: [PATCH 11/18] =?UTF-8?q?enhance:=20aichat=E6=A9=9F=E8=83=BD(?= =?UTF-8?q?=E7=94=BB=E5=83=8F=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/aichat/index.ts | 75 +++++++++++++++++++++++++++++++------ src/utils/url2base64.ts | 16 ++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 src/utils/url2base64.ts diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 5c6dfbc..deb4466 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -3,6 +3,7 @@ import Module from '@/module.js'; import serifs from '@/serifs.js'; import Message from '@/message.js'; import config from '@/config.js'; +import urlToBase64 from '@/utils/url2base64.js'; import got from 'got'; type AiChat = { @@ -11,7 +12,12 @@ type AiChat = { api: string; key: string; }; +type Base64Image = { + type: string; + base64: string; +}; const GEMINI_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'; +const GEMINI_VISION_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent'; export default class extends Module { public readonly name = 'aichat'; @@ -24,21 +30,34 @@ export default class extends Module { } @bindThis - private async genTextByGemini(aiChat: AiChat) { + private async genTextByGemini(aiChat: AiChat, image:Base64Image|null) { this.log('Generate Text By Gemini...'); - var options = { + let parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[]; + if (image === null) { + // 画像がない場合、メッセージのみで問い合わせ + parts = [{text: aiChat.prompt + aiChat.question}]; + } else { + // 画像が存在する場合、画像を添付して問い合わせ + parts = [ + { text: aiChat.prompt + aiChat.question }, + { + inline_data: { + mime_type: image.type, + data: image.base64, + }, + }, + ]; + } + let options = { url: aiChat.api, searchParams: { key: aiChat.key, }, json: { - contents: [{ - parts:[{ - text: aiChat.prompt + aiChat.question - }] - }] + contents: {parts: parts} }, }; + this.log(JSON.stringify(options)); let res_data:any = null; try { res_data = await got.post(options, @@ -60,7 +79,35 @@ export default class extends Module { } catch (err: unknown) { this.log('Error By Call Gemini'); if (err instanceof Error) { - this.log(`${err.name}\n${err.message}`); + this.log(`${err.name}\n${err.message}\n${err.stack}`); + } + } + return null; + } + + @bindThis + private async note2base64Image(notesId: string) { + const noteData = await this.ai.api('notes/show', { noteId: notesId }); + let fileType: string | undefined,thumbnailUrl: string | undefined; + if (noteData !== null && noteData.hasOwnProperty('files')) { + if (noteData.files.length > 0) { + if (noteData.files[0].hasOwnProperty('type')) { + fileType = noteData.files[0].type; + } + if (noteData.files[0].hasOwnProperty('thumbnailUrl')) { + thumbnailUrl = noteData.files[0].thumbnailUrl; + } + } + if (fileType !== undefined && thumbnailUrl !== undefined) { + try { + const image = await urlToBase64(thumbnailUrl); + const base64Image:Base64Image = {type: fileType, base64: image}; + return base64Image; + } catch (err: unknown) { + if (err instanceof Error) { + this.log(`${err.name}\n${err.message}\n${err.stack}`); + } + } } } return null; @@ -88,8 +135,8 @@ export default class extends Module { .replace(kigo + type, '') .trim(); - let text; - let prompt = ''; + let text:string, aiChat:AiChat; + let prompt:string = ''; if (config.prompt) { prompt = config.prompt; } @@ -99,13 +146,17 @@ export default class extends Module { msg.reply(serifs.aichat.nothing(type)); return false; } - const aiChat = { + const base64Image:Base64Image|null = await this.note2base64Image(msg.id); + aiChat = { question: question, prompt: prompt, api: GEMINI_API, key: config.geminiProApiKey }; - text = await this.genTextByGemini(aiChat); + if (base64Image !== null) { + aiChat.api = GEMINI_VISION_API; + } + text = await this.genTextByGemini(aiChat, base64Image); break; default: msg.reply(serifs.aichat.nothing(type)); diff --git a/src/utils/url2base64.ts b/src/utils/url2base64.ts new file mode 100644 index 0000000..7350a66 --- /dev/null +++ b/src/utils/url2base64.ts @@ -0,0 +1,16 @@ +import log from '@/utils/log.js'; +import got from 'got'; + +export default async function(url: string): Promise { + try { + const buffer = await got(url).buffer(); + const base64Image = buffer.toString('base64'); + return base64Image; + } catch (err: unknown) { + log('Error in url2base64'); + if (err instanceof Error) { + log(`${err.name}\n${err.message}\n${err.stack}`); + } + throw err; + } +} From 634215765fc0867cc7560ae2caf2bb9993508761 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:44:26 +0900 Subject: [PATCH 12/18] fix #4 --- src/modules/aichat/index.ts | 14 ++++++++------ src/serifs.ts | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index deb4466..817e1d8 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -16,8 +16,8 @@ type Base64Image = { type: string; base64: string; }; -const GEMINI_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'; -const GEMINI_VISION_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent'; +const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; +const GEMINI_15_PRO_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent'; export default class extends Module { public readonly name = 'aichat'; @@ -131,6 +131,7 @@ export default class extends Module { type = 'chatgpt3.5'; } const question = msg.extractedText + .toLowerCase() .replace(this.name, '') .replace(kigo + type, '') .trim(); @@ -142,6 +143,7 @@ export default class extends Module { } switch(type) { case 'gemini': + // geminiの場合、APIキーが必須 if (!config.geminiProApiKey) { msg.reply(serifs.aichat.nothing(type)); return false; @@ -150,11 +152,11 @@ export default class extends Module { aiChat = { question: question, prompt: prompt, - api: GEMINI_API, + api: GEMINI_15_PRO_API, key: config.geminiProApiKey }; - if (base64Image !== null) { - aiChat.api = GEMINI_VISION_API; + if (msg.includes([kigo + 'gemini-flash'])) { + aiChat.api = GEMINI_15_FLASH_API; } text = await this.genTextByGemini(aiChat, base64Image); break; @@ -165,7 +167,7 @@ export default class extends Module { if (text == null) { this.log('The result is invalid. It seems that tokens and other items need to be reviewed.') - msg.reply(serifs.aichat.nothing(type)); + msg.reply(serifs.aichat.error(type)); return false; } diff --git a/src/serifs.ts b/src/serifs.ts index 2fe0c15..4bf19b2 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -390,6 +390,7 @@ export default { aichat: { nothing: type => `あぅ... ${type}のAPIキーが登録されてないみたいです`, + error: type => `うぇ...${type}でエラーが発生しちゃったみたいです。gemini-flashだと動くかも?`, post: (text, type) => `${text} (${type}) #aichat`, }, From fc493a4d93e0654d961b6dd2354d1254473a1a9b Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:47:32 +0900 Subject: [PATCH 13/18] fix #5 --- src/config.ts | 1 + src/modules/aichat/index.ts | 62 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 119daa8..5f93baa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ type Config = { checkEmojisEnabled?: boolean; checkEmojisAtOnce?: boolean; geminiProApiKey?: string; + pLaMoApiKey?: string; prompt?: string; mecab?: string; mecabDic?: string; diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 817e1d8..3ba07fc 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -18,6 +18,7 @@ type Base64Image = { }; const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; const GEMINI_15_PRO_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent'; +const PLAMO_API = 'https://platform.preferredai.jp/api/completion/v1/chat/completions'; export default class extends Module { public readonly name = 'aichat'; @@ -85,6 +86,47 @@ export default class extends Module { return null; } + @bindThis + private async genTextByPLaMo(aiChat: AiChat) { + this.log('Generate Text By PLaMo...'); + + let options = { + url: aiChat.api, + headers: { + Authorization: 'Bearer ' + aiChat.key + }, + json: { + model: 'plamo-beta', + messages: [ + {role: 'system', content: aiChat.prompt}, + {role: 'user', content: aiChat.question}, + ], + }, + }; + this.log(JSON.stringify(options)); + let res_data:any = null; + try { + res_data = await got.post(options, + {parseJson: res => JSON.parse(res)}).json(); + this.log(JSON.stringify(res_data)); + if (res_data.hasOwnProperty('choices')) { + if (res_data.choices.length > 0) { + if (res_data.choices[0].hasOwnProperty('message')) { + if (res_data.choices[0].message.hasOwnProperty('content')) { + return res_data.choices[0].message.content; + } + } + } + } + } catch (err: unknown) { + this.log('Error By Call PLaMo'); + if (err instanceof Error) { + this.log(`${err.name}\n${err.message}\n${err.stack}`); + } + } + return null; + } + @bindThis private async note2base64Image(notesId: string) { const noteData = await this.ai.api('notes/show', { noteId: notesId }); @@ -129,6 +171,8 @@ export default class extends Module { type = 'chatgpt4'; } else if (msg.includes([kigo + 'chatgpt'])) { type = 'chatgpt3.5'; + } else if (msg.includes([kigo + 'plamo'])) { + type = 'plamo'; } const question = msg.extractedText .toLowerCase() @@ -160,7 +204,23 @@ export default class extends Module { } text = await this.genTextByGemini(aiChat, base64Image); break; - default: + + case 'PLaMo': + // PLaMoの場合、APIキーが必須 + if (!config.pLaMoApiKey) { + msg.reply(serifs.aichat.nothing(type)); + return false; + } + aiChat = { + question: question, + prompt: prompt, + api: PLAMO_API, + key: config.pLaMoApiKey + }; + text = await this.genTextByPLaMo(aiChat); + break; + + default: msg.reply(serifs.aichat.nothing(type)); return false; } From 7c1895267145ebdd3c1fe08aa4eb1681f8f09613 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:55:25 +0900 Subject: [PATCH 14/18] =?UTF-8?q?README.md=E3=81=ABPLaMo=E3=81=AB=E3=81=A4?= =?UTF-8?q?=E3=81=84=E3=81=A6=E8=A8=98=E8=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cfc8bb9..aca38a7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", - "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", + "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", @@ -46,7 +47,8 @@ Misskey用の日本語Botです。 "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", - "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は"", + "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", + "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", From 63b134146e35e868e8d1dd7ecc3ec6401d937161 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:27:33 +0900 Subject: [PATCH 15/18] =?UTF-8?q?aichat=E3=81=AE=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=88=97=E7=BD=AE=E6=8F=9B=E3=82=92=E6=AD=A3=E8=A6=8F=E8=A1=A8?= =?UTF-8?q?=E7=8F=BE=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/aichat/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 3ba07fc..861e5b7 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -174,10 +174,11 @@ export default class extends Module { } else if (msg.includes([kigo + 'plamo'])) { type = 'plamo'; } + const reName = RegExp(this.name, "i"); + const reKigoType = RegExp(kigo + type, "i"); const question = msg.extractedText - .toLowerCase() - .replace(this.name, '') - .replace(kigo + type, '') + .replace(reName, '') + .replace(reKigoType, '') .trim(); let text:string, aiChat:AiChat; From b8f478400757578d6ee05102b0e76187cdd2988b Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 2 Jan 2025 09:28:42 +0900 Subject: [PATCH 16/18] fix #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aichatで回答したものに返信すると、文脈を保持して回答されるように変更 --- README.md | 4 +- src/modules/aichat/index.ts | 224 +++++++++++++++++++++++++++++------- 2 files changed, 186 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index aca38a7..aea6153 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Misskey用の日本語Botです。 "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", - "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", + "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。」", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", "memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))" @@ -49,7 +49,7 @@ Misskey用の日本語Botです。 "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", - "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", + "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。」", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "memoryDir": "data" diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 861e5b7..0fb36e4 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -5,28 +5,52 @@ import Message from '@/message.js'; import config from '@/config.js'; import urlToBase64 from '@/utils/url2base64.js'; import got from 'got'; +import loki from 'lokijs'; type AiChat = { question: string; prompt: string; api: string; key: string; + history?: { role: string; content: string }[]; }; type Base64Image = { type: string; base64: string; }; +type AiChatHist = { + postId: string; + createdAt: number; + type: string; + api?: string; + history?: { + role: string; + content: string; + }[]; +}; + +const KIGO = '&'; +const TYPE_GEMINI = 'gemini'; +const TYPE_PLAMO = 'plamo'; const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; const GEMINI_15_PRO_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent'; const PLAMO_API = 'https://platform.preferredai.jp/api/completion/v1/chat/completions'; +const TIMEOUT_TIME = 1000 * 60 * 60 * 0.5; + export default class extends Module { public readonly name = 'aichat'; + private aichatHist: loki.Collection; @bindThis public install() { + this.aichatHist = this.ai.getCollection('aichatHist', { + indices: ['postId'] + }); return { - mentionHook: this.mentionHook + mentionHook: this.mentionHook, + contextHook: this.contextHook, + timeoutCallback: this.timeoutCallback, }; } @@ -34,13 +58,15 @@ export default class extends Module { private async genTextByGemini(aiChat: AiChat, image:Base64Image|null) { this.log('Generate Text By Gemini...'); let parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[]; - if (image === null) { + const systemInstruction : {role: string; parts: [{text: string}]} = {role: 'system', parts: [{text: aiChat.prompt}]}; + + if (!image) { // 画像がない場合、メッセージのみで問い合わせ - parts = [{text: aiChat.prompt + aiChat.question}]; + parts = [{text: aiChat.question}]; } else { // 画像が存在する場合、画像を添付して問い合わせ parts = [ - { text: aiChat.prompt + aiChat.question }, + { text: aiChat.question }, { inline_data: { mime_type: image.type, @@ -49,20 +75,31 @@ export default class extends Module { }, ]; } + + // 履歴を追加 + let contents: ({ role: string; parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[]}[]) = []; + if (aiChat.history != null) { + aiChat.history.forEach(entry => { + contents.push({ role : entry.role, parts: [{text: entry.content}]}); + }); + } + contents.push({role: 'user', parts: parts}); + let options = { url: aiChat.api, searchParams: { key: aiChat.key, }, json: { - contents: {parts: parts} + contents: contents, + systemInstruction: systemInstruction, }, }; this.log(JSON.stringify(options)); let res_data:any = null; try { res_data = await got.post(options, - {parseJson: res => JSON.parse(res)}).json(); + {parseJson: (res: string) => JSON.parse(res)}).json(); this.log(JSON.stringify(res_data)); if (res_data.hasOwnProperty('candidates')) { if (res_data.candidates.length > 0) { @@ -70,7 +107,8 @@ export default class extends Module { if (res_data.candidates[0].content.hasOwnProperty('parts')) { if (res_data.candidates[0].content.parts.length > 0) { if (res_data.candidates[0].content.parts[0].hasOwnProperty('text')) { - return res_data.candidates[0].content.parts[0].text; + const responseText = res_data.candidates[0].content.parts[0].text; + return responseText; } } } @@ -107,7 +145,7 @@ export default class extends Module { let res_data:any = null; try { res_data = await got.post(options, - {parseJson: res => JSON.parse(res)}).json(); + {parseJson: (res: string) => JSON.parse(res)}).json(); this.log(JSON.stringify(res_data)); if (res_data.hasOwnProperty('choices')) { if (res_data.choices.length > 0) { @@ -163,80 +201,186 @@ export default class extends Module { this.log('AiChat requested'); } - const kigo = '&'; - let type = 'gemini'; - if (msg.includes([kigo + 'gemini'])) { - type = 'gemini'; - } else if (msg.includes([kigo + 'chatgpt4'])) { + // タイプを決定 + let type = TYPE_GEMINI; + if (msg.includes([KIGO + TYPE_GEMINI])) { + type = TYPE_GEMINI; + } else if (msg.includes([KIGO + 'chatgpt4'])) { type = 'chatgpt4'; - } else if (msg.includes([kigo + 'chatgpt'])) { + } else if (msg.includes([KIGO + 'chatgpt'])) { type = 'chatgpt3.5'; - } else if (msg.includes([kigo + 'plamo'])) { - type = 'plamo'; + } else if (msg.includes([KIGO + TYPE_PLAMO])) { + type = TYPE_PLAMO; + } + const current : AiChatHist = { + postId: msg.id, + createdAt: Date.now(),// 適当なもの + type: type + }; + // AIに問い合わせ + const result = await this.handleAiChat(current, msg); + + if (result) { + return { + reaction: 'like' + }; + } + return false; + } + + @bindThis + private async contextHook(key: any, msg: Message) { + this.log('contextHook...'); + if (msg.text == null) return false; + + // msg.idをもとにnotes/conversationを呼び出し、該当のidかチェック + const conversationData = await this.ai.api('notes/conversation', { noteId: msg.id }); + + // 結果がnullやサイズ0の場合は終了 + if (conversationData == null || conversationData.length == 0 ) { + this.log('conversationData is nothing.'); + return false; + } + + // aichatHistに該当のポストが見つからない場合は終了 + let exist : AiChatHist | null = null; + for (const message of conversationData) { + exist = this.aichatHist.findOne({ + postId: message.id + }); + // 見つかった場合はそれを利用 + if (exist != null) break; + } + if (exist == null) { + this.log('conversationData is not found.'); + return false; + } + + // 見つかった場合はunsubscribe&removeし、回答。今回のでsubscribe,insert,timeout設定 + this.log('unsubscribeReply & remove.'); + this.log(exist.type + ':' + exist.postId); + if (exist.history) { + for (const his of exist.history) { + this.log(his.role + ':' + his.content); + } + } + this.unsubscribeReply(key); + this.aichatHist.remove(exist); + + // AIに問い合わせ + const result = await this.handleAiChat(exist, msg); + + if (result) { + return { + reaction: 'like' + }; + } + return false; + } + + @bindThis + private async handleAiChat(exist: AiChatHist, msg: Message) { + let text: string, aiChat: AiChat; + let prompt: string = ''; + if (config.prompt) { + prompt = config.prompt; } const reName = RegExp(this.name, "i"); - const reKigoType = RegExp(kigo + type, "i"); + const reKigoType = RegExp(KIGO + exist.type, "i"); const question = msg.extractedText .replace(reName, '') .replace(reKigoType, '') .trim(); - - let text:string, aiChat:AiChat; - let prompt:string = ''; - if (config.prompt) { - prompt = config.prompt; - } - switch(type) { - case 'gemini': + switch (exist.type) { + case TYPE_GEMINI: // geminiの場合、APIキーが必須 if (!config.geminiProApiKey) { - msg.reply(serifs.aichat.nothing(type)); + msg.reply(serifs.aichat.nothing(exist.type)); return false; } - const base64Image:Base64Image|null = await this.note2base64Image(msg.id); + const base64Image: Base64Image | null = await this.note2base64Image(msg.id); aiChat = { question: question, prompt: prompt, api: GEMINI_15_PRO_API, - key: config.geminiProApiKey + key: config.geminiProApiKey, + history: exist.history }; - if (msg.includes([kigo + 'gemini-flash'])) { + if (msg.includes([KIGO + 'gemini-flash']) || (exist.api && exist.api === GEMINI_15_FLASH_API)) { aiChat.api = GEMINI_15_FLASH_API; } text = await this.genTextByGemini(aiChat, base64Image); break; - case 'PLaMo': + case TYPE_PLAMO: // PLaMoの場合、APIキーが必須 if (!config.pLaMoApiKey) { - msg.reply(serifs.aichat.nothing(type)); + msg.reply(serifs.aichat.nothing(exist.type)); return false; } aiChat = { - question: question, + question: msg.text, prompt: prompt, api: PLAMO_API, - key: config.pLaMoApiKey + key: config.pLaMoApiKey, + history: exist.history }; text = await this.genTextByPLaMo(aiChat); break; - default: - msg.reply(serifs.aichat.nothing(type)); + default: + msg.reply(serifs.aichat.nothing(exist.type)); return false; } if (text == null) { this.log('The result is invalid. It seems that tokens and other items need to be reviewed.') - msg.reply(serifs.aichat.error(type)); + msg.reply(serifs.aichat.error(exist.type)); return false; } this.log('Replying...'); - msg.reply(serifs.aichat.post(text, type)); + msg.reply(serifs.aichat.post(text, exist.type)).then(reply => { + // 履歴に登録 + if (!exist.history) { + exist.history = []; + } + exist.history.push({ role: 'user', content: question }); + exist.history.push({ role: 'model', content: text }); + // 履歴が10件を超えた場合、古いものを削除 + if (exist.history.length > 10) { + exist.history.shift(); + } + this.aichatHist.insertOne({ + postId: reply.id, + createdAt: Date.now(), + type: exist.type, + api: aiChat.api, + history: exist.history + }); - return { - reaction: 'like' - }; + this.log('Subscribe&Set Timer...'); + + // メンションをsubscribe + this.subscribeReply(reply.id, reply.id); + + // タイマーセット + this.setTimeoutWithPersistence(TIMEOUT_TIME, { + id: reply.id + }); + }); + return true; + } + + @bindThis + private async timeoutCallback({id}) { + this.log('timeoutCallback...'); + const exist = this.aichatHist.findOne({ + postId: id + }); + this.unsubscribeReply(id); + if (exist != null) { + this.aichatHist.remove(exist); + } } } From ec4069d8edbf1749e5390623190174dbf5d9c62e Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 2 Jan 2025 19:40:42 +0900 Subject: [PATCH 17/18] fix #9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ランダムにaichatを発動し話しかける機能の追加 - 設定追加 - aichatRandomTalkEnabled - aichatRandomTalkProbability - aichatRandomTalkIntervalMinutes --- README.md | 6 ++ src/config.ts | 3 + src/modules/aichat/index.ts | 117 +++++++++++++++++++++++++++++++++++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aea6153..a2e0147 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ Misskey用の日本語Botです。 "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。」", + "aichatRandomTalkEnabled": "ランダムにaichatを発動し話しかける機能を有効にする場合は true を入れる (無効にする場合は false)", + "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))", + "aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", "memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))" @@ -50,6 +53,9 @@ Misskey用の日本語Botです。 "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。」", + "aichatRandomTalkEnabled": "ランダムにaichatを発動し話しかける機能を有効にする場合は true を入れる (無効にする場合は false)", + "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))。デフォルトは0.02(2%)", + "aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "memoryDir": "data" diff --git a/src/config.ts b/src/config.ts index 5f93baa..31071e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,9 @@ type Config = { geminiProApiKey?: string; pLaMoApiKey?: string; prompt?: string; + aichatRandomTalkEnabled?: string; + aichatRandomTalkProbability?: string; + aichatRandomTalkIntervalMinutes?: string; mecab?: string; mecabDic?: string; memoryDir?: string; diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 0fb36e4..feb13ef 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -3,6 +3,7 @@ import Module from '@/module.js'; import serifs from '@/serifs.js'; import Message from '@/message.js'; import config from '@/config.js'; +import Friend from '@/friend.js'; import urlToBase64 from '@/utils/url2base64.js'; import got from 'got'; import loki from 'lokijs'; @@ -36,17 +37,39 @@ const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/mo const GEMINI_15_PRO_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent'; const PLAMO_API = 'https://platform.preferredai.jp/api/completion/v1/chat/completions'; -const TIMEOUT_TIME = 1000 * 60 * 60 * 0.5; +const RANDOMTALK_DEFAULT_PROBABILITY = 0.02;// デフォルトのrandomTalk確率 +const TIMEOUT_TIME = 1000 * 60 * 60 * 0.5;// aichatの返信を監視する時間 +const RANDOMTALK_DEFAULT_INTERVAL = 1000 * 60 * 60 * 12;// デフォルトのrandomTalk間隔 export default class extends Module { public readonly name = 'aichat'; private aichatHist: loki.Collection; + private randomTalkProbability: number = RANDOMTALK_DEFAULT_PROBABILITY; + private randomTalkIntervalMinutes: number = RANDOMTALK_DEFAULT_INTERVAL; @bindThis public install() { this.aichatHist = this.ai.getCollection('aichatHist', { indices: ['postId'] }); + + // 確率は設定されていればそちらを採用(設定がなければデフォルトを採用) + if (config.aichatRandomTalkProbability != undefined && !Number.isNaN(Number.parseFloat(config.aichatRandomTalkProbability))) { + this.randomTalkProbability = Number.parseFloat(config.aichatRandomTalkProbability); + } + // ランダムトーク間隔(分)は設定されていればそちらを採用(設定がなければデフォルトを採用) + if (config.aichatRandomTalkIntervalMinutes != undefined && !Number.isNaN(Number.parseInt(config.aichatRandomTalkIntervalMinutes))) { + this.randomTalkIntervalMinutes = 1000 * 60 * Number.parseInt(config.aichatRandomTalkIntervalMinutes); + } + this.log('aichatRandomTalkEnabled:' + config.aichatRandomTalkEnabled); + this.log('randomTalkProbability:' + this.randomTalkProbability); + this.log('randomTalkIntervalMinutes:' + (this.randomTalkIntervalMinutes / (60 * 1000))); + + // 定期的にデータを取得しaichatRandomTalkを行う + if (config.aichatRandomTalkEnabled) { + setInterval(this.aichatRandomTalk, this.randomTalkIntervalMinutes); + } + return { mentionHook: this.mentionHook, contextHook: this.contextHook, @@ -201,6 +224,21 @@ export default class extends Module { this.log('AiChat requested'); } + // msg.idをもとにnotes/conversationを呼び出し、会話中のidかチェック + const conversationData = await this.ai.api('notes/conversation', { noteId: msg.id }); + + // aichatHistに該当のポストが見つかった場合は会話中のためmentionHoonkでは対応しない + let exist : AiChatHist | null = null; + if (conversationData != undefined) { + for (const message of conversationData) { + exist = this.aichatHist.findOne({ + postId: message.id + }); + // 見つかった場合はそれを利用 + if (exist != null) return false; + } + } + // タイプを決定 let type = TYPE_GEMINI; if (msg.includes([KIGO + TYPE_GEMINI])) { @@ -278,6 +316,78 @@ export default class extends Module { return false; } + @bindThis + private async aichatRandomTalk() { + this.log('AiChat(randomtalk) started'); + const tl = await this.ai.api('notes/local-timeline', { + limit: 30 + }); + const interestedNotes = tl.filter(note => + note.userId !== this.ai.account.id && + note.text != null && + note.replyId == null && + note.renoteId == null && + note.cw == null && + !note.user.isBot + ); + + // 対象が存在しない場合は処理終了 + if (interestedNotes == undefined || interestedNotes.length == 0) return false; + + // ランダムに選択 + const choseNote = interestedNotes[Math.floor(Math.random() * interestedNotes.length)]; + + // msg.idをもとにnotes/conversationを呼び出し、会話中のidかチェック + const conversationData = await this.ai.api('notes/conversation', { noteId: choseNote.id }); + + // aichatHistに該当のポストが見つかった場合は会話中のためaichatRandomTalkでは対応しない + let exist : AiChatHist | null = null; + if (conversationData != undefined) { + for (const message of conversationData) { + exist = this.aichatHist.findOne({ + postId: message.id + }); + if (exist != null) return false; + } + } + + // 確率をクリアし、親愛度が指定以上、かつ、Botでない場合のみ実行 + if (Math.random() < this.randomTalkProbability) { + this.log('AiChat(randomtalk) targeted: ' + choseNote.id); + } else { + this.log('AiChat(randomtalk) is end.'); + return false; + } + const friend: Friend | null = this.ai.lookupFriend(choseNote.userId); + if (friend == null || friend.love < 7) { + this.log('AiChat(randomtalk) end.Because there was not enough affection.'); + return false; + } else if (choseNote.user.isBot) { + this.log('AiChat(randomtalk) end.Because message author is bot.'); + return false; + } + + const current : AiChatHist = { + postId: choseNote.id, + createdAt: Date.now(),// 適当なもの + type: TYPE_GEMINI + }; + // AIに問い合わせ + let targetedMessage = choseNote; + if (choseNote.extractedText == undefined) { + const data = await this.ai.api('notes/show', { noteId: choseNote.id }); + targetedMessage = new Message(this.ai, data); + } + const result = await this.handleAiChat(current, targetedMessage); + + if (result) { + return { + reaction: 'like' + }; + } + return false; + } + @bindThis private async handleAiChat(exist: AiChatHist, msg: Message) { let text: string, aiChat: AiChat; @@ -287,7 +397,10 @@ export default class extends Module { } const reName = RegExp(this.name, "i"); const reKigoType = RegExp(KIGO + exist.type, "i"); - const question = msg.extractedText + const extractedText = msg.extractedText; + if (extractedText == undefined || extractedText.length == 0) return false; + + const question = extractedText .replace(reName, '') .replace(reKigoType, '') .trim(); From b5ab153fc6aff20fb1d4681fc66d00377758bc86 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Thu, 2 Jan 2025 20:27:10 +0900 Subject: [PATCH 18/18] =?UTF-8?q?torisetu.md=E6=9B=B4=E6=96=B0(aichat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- torisetu.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/torisetu.md b/torisetu.md index 4ab4b97..8fdf8c0 100644 --- a/torisetu.md +++ b/torisetu.md @@ -82,7 +82,8 @@ PONGを返します。生存確認にどうぞ ``` @ai aichat 部屋の片付けの手順を教えて ``` -のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。 +のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。 +APIキーを登録の上、設定でaichatRandomTalkEnabledをtrueにすると、ランダムトーク(ランダムでaichatを発動)させることも可能です。ランダムトーク間隔、ランダムトーク確率を設定で指定可能です。 ### その他反応するフレーズ (トークのみ) * かわいい