From 5f546bda68b4541680c9b7c12e6e59a73a3e5ae9 Mon Sep 17 00:00:00 2001 From: tetsuya-ki <64536338+tetsuya-ki@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:26:08 +0900 Subject: [PATCH] =?UTF-8?q?aichat=E3=81=AE=E5=BC=B7=E5=8C=96(URL=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=80=81=E3=82=B0=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=83=B3=E3=82=B0=E5=AF=BE=E5=BF=9C)&=E8=AA=AC?= =?UTF-8?q?=E6=98=8E=E6=96=87=E8=BF=BD=E8=A8=98=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.mdとtorisetu.mdを修正 - 設定例ファイル、example.jsonを追加 - aichatにURLを対応 - グラウンディング(根拠づけ)に対応 --- README.md | 48 +++++---- example.json | 20 ++++ src/config.ts | 3 +- src/modules/aichat/index.ts | 207 ++++++++++++++++++++++++++++++------ src/utils/url2base64.ts | 4 +- src/utils/url2json.ts | 27 +++++ torisetu.md | 19 +++- 7 files changed, 269 insertions(+), 59 deletions(-) create mode 100644 example.json create mode 100644 src/utils/url2json.ts diff --git a/README.md b/README.md index b2c983d..1d0beac 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,26 @@ Misskey用の日本語Botです。 > Node.js と npm と MeCab (オプション) がインストールされている必要があります。 まず適当なディレクトリに `git clone` します。 -次にそのディレクトリに `config.json` を作成します。中身は次のようにします: +次にそのディレクトリに `config.json` を作成します(example.jsonをコピーして作ってもOK)。中身は次のようにします: ``` json { "host": "https:// + あなたのインスタンスのURL (末尾の / は除く)", "i": "藍として動かしたいアカウントのアクセストークン", "master": "管理者のユーザー名(オプション)", - "notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる", - "keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false)", - "chartEnabled": "チャート機能を無効化する場合は false を入れてください", - "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)", - "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", - "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", - "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", - "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", - "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", + "notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる(二重引用符(”)は不要)", + "keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "chartEnabled": "チャート機能を無効化する場合は false を入れる(二重引用符(”)は不要)", + "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))。この機能を使う場合、藍のBotに管理者権限を与え、「絵文字を見る」権限を付与したアクセストークンを発行の上設定が必要。", + "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false(いずれも二重引用符(”)は不要))", + "geminiProApiKey": "Gemini APIキー。2025年初頭は無料で取得可能。詳細は", + "pLaMoApiKey": "PLaMo APIキー。2024年8月〜11月は無料でトライアルだった(2025年現在有料のみ)。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください(短くてもOK)。ただし、リスト記法はMisskeyが対応しておらず、パーサーが壊れるため使用禁止です。列挙する場合は「・」を使ってください。」", - "aichatRandomTalkEnabled": "ランダムにaichatを発動し話しかける機能を有効にする場合は true を入れる (無効にする場合は false)", + "aichatRandomTalkEnabled": "ランダムにaichatを発動し話しかける機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))", "aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)", + "aichatGroundingWithGoogleSearchAlwaysEnabled": "aichatでGoogle検索を利用したグラウンディングを常に行う場合 true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecabDic": "MeCab の辞書ファイルパス (オプション)", "memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))" @@ -36,26 +37,27 @@ Misskey用の日本語Botです。 ## Dockerで動かす まず適当なディレクトリに `git clone` します。 -次にそのディレクトリに `config.json` を作成します。中身は次のようにします: +次にそのディレクトリに `config.json` を作成します(example.jsonをコピーして作ってもOK)。中身は次のようにします: (MeCabの設定、memoryDirについては触らないでください) ``` json { "host": "https:// + あなたのインスタンスのURL (末尾の / は除く)", "i": "藍として動かしたいアカウントのアクセストークン", "master": "管理者のユーザー名(オプション)", - "notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる", - "keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false)", - "chartEnabled": "チャート機能を無効化する場合は false を入れてください", - "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)", - "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", - "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", - "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", - "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は", - "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は", + "notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる(二重引用符(”)は不要)", + "keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "chartEnabled": "チャート機能を無効化する場合は false を入れる(二重引用符(”)は不要)", + "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))。この機能を使う場合、藍のBotに管理者権限を与え、「絵文字を見る」権限を付与したアクセストークンを発行の上設定が必要。", + "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false(いずれも二重引用符(”)は不要))", + "geminiProApiKey": "Gemini APIキー。2025年初頭は無料で取得可能。詳細は", + "pLaMoApiKey": "PLaMo APIキー。2024年8月〜11月は無料でトライアルだった(2025年現在有料のみ)。詳細は", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください(短くてもOK)。ただし、リスト記法はMisskeyが対応しておらず、パーサーが壊れるため使用禁止です。列挙する場合は「・」を使ってください。」", - "aichatRandomTalkEnabled": "ランダムにaichatを発動し話しかける機能を有効にする場合は true を入れる (無効にする場合は false)", - "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))。デフォルトは0.02(2%)", + "aichatRandomTalkEnabled": "ランダムにaichatを発動し話しかける機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", + "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))", "aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)", + "aichatGroundingWithGoogleSearchAlwaysEnabled": "aichatでGoogle検索を利用したグラウンディングを常に行う場合 true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))", "mecab": "/usr/bin/mecab", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "memoryDir": "data" diff --git a/example.json b/example.json new file mode 100644 index 0000000..cf53d85 --- /dev/null +++ b/example.json @@ -0,0 +1,20 @@ +{ + "host": "https://misskey.example.com", + "i": "_YOUR_BOT_TOKEN", + "master": "BOT_MASTER_NAME", + "notingEnabled": true, + "keywordEnabled": true, + "chartEnabled": true, + "reversiEnabled": true, + "serverMonitoring": true, + "checkEmojisAtOnce": false, + "geminiProApiKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "prompt": "返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください(短くてもOK)。ただし、リスト記法はMisskeyが対応しておらず、パーサーが壊れるため使用禁止です。列挙する場合は「・」を使ってください。", + "aichatRandomTalkEnabled": true, + "aichatRandomTalkProbability": 0.01, + "aichatRandomTalkIntervalMinutes": 720, + "aichatGroundingWithGoogleSearchAlwaysEnabled": 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 e409eb8..ee26e18 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,9 +15,10 @@ type Config = { geminiProApiKey?: string; pLaMoApiKey?: string; prompt?: string; - aichatRandomTalkEnabled?: string; + aichatRandomTalkEnabled?: boolean; aichatRandomTalkProbability?: string; aichatRandomTalkIntervalMinutes?: string; + aichatGroundingWithGoogleSearchAlwaysEnabled?: boolean; mecab?: string; mecabDic?: string; memoryDir?: string; diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 8990350..4577310 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -5,6 +5,7 @@ import Message from '@/message.js'; import config from '@/config.js'; import Friend from '@/friend.js'; import urlToBase64 from '@/utils/url2base64.js'; +import urlToJson from '@/utils/url2json.js'; import got from 'got'; import loki from 'lokijs'; @@ -13,8 +14,10 @@ type AiChat = { prompt: string; api: string; key: string; - history?: { role: string; content: string }[]; + fromMention: boolean; friendName?: string; + grounding?: boolean; + history?: { role: string; content: string }[]; }; type base64File = { type: string; @@ -40,23 +43,48 @@ type GeminiContents = { role: string; parts: GeminiParts; }; +type GeminiOptions = { + contents?: GeminiContents[], + systemInstruction?: GeminiSystemInstruction, + tools?: [{}] +}; type AiChatHist = { postId: string; createdAt: number; type: string; + fromMention: boolean; api?: string; + grounding?: boolean; history?: { role: string; content: string; }[]; }; +type UrlPreview = { + title: string; + icon: string; + description: string; + thumbnail: string; + player: { + url: string + width: number; + height: number; + allow: [] + } + sitename: string; + sensitive: boolean; + activityPub: string; + url: string; +}; + const KIGO = '&'; const TYPE_GEMINI = 'gemini'; const GEMINI_PRO = 'gemini-pro'; const GEMINI_FLASH = 'gemini-flash'; const TYPE_PLAMO = 'plamo'; +const GROUNDING_TARGET = 'ggg'; const GEMINI_20_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent'; // const GEMINI_15_FLASH_API = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; @@ -90,6 +118,7 @@ export default class extends Module { this.log('aichatRandomTalkEnabled:' + config.aichatRandomTalkEnabled); this.log('randomTalkProbability:' + this.randomTalkProbability); this.log('randomTalkIntervalMinutes:' + (this.randomTalkIntervalMinutes / (60 * 1000))); + this.log('aichatGroundingWithGoogleSearchAlwaysEnabled:' + config.aichatGroundingWithGoogleSearchAlwaysEnabled); // 定期的にデータを取得しaichatRandomTalkを行う if (config.aichatRandomTalkEnabled) { @@ -116,15 +145,64 @@ export default class extends Module { minute: '2-digit' }); // 設定のプロンプトに加え、現在時刻を渡す - let systemInstructionText = aiChat.prompt + "。また、現在日時は" + now + "であり、これは回答の参考にし、時刻を聞かれるまで時刻情報は提供しないこと(なお、他の日時は無効とすること)。"; + let systemInstructionText = aiChat.prompt + 'また、現在日時は' + now + 'であり、これは回答の参考にし、時刻を聞かれるまで時刻情報は提供しないこと(なお、他の日時は無効とすること)。'; // 名前を伝えておく if (aiChat.friendName != undefined) { - systemInstructionText += "なお、会話相手の名前は" + aiChat.friendName + "とする。"; + systemInstructionText += 'なお、会話相手の名前は' + aiChat.friendName + 'とする。'; + } + // ランダムトーク機能(利用者が意図(メンション)せず発動)の場合、ちょっとだけ配慮しておく + if (!aiChat.fromMention) { + systemInstructionText += 'これらのメッセージは、あなたに対するメッセージではないことを留意し、返答すること(会話相手は突然話しかけられた認識している)。'; + } + // グラウンディングについてもsystemInstructionTextに追記(こうしないとあまり使わないので) + if (aiChat.grounding) { + systemInstructionText += '返答のルール2:Google search with grounding.'; + } + // URLから情報を取得 + if (aiChat.question !== undefined) { + const urlexp = RegExp('(https?://[a-zA-Z0-9!?/+_~=:;.,*&@#$%\'-]+)', 'g'); + const urlarray = [...aiChat.question.matchAll(urlexp)]; + if (urlarray.length > 0) { + for (const url of urlarray) { + this.log('URL:' + url[0]); + let result: unknown = null; + try{ + result = await urlToJson(url[0]); + } catch (err: unknown) { + systemInstructionText += '補足として提供されたURLは無効でした:URL=>' + url[0] + this.log('Skip url becase error in urlToJson'); + continue; + } + const urlpreview: UrlPreview = result as UrlPreview; + if (urlpreview.title) { + systemInstructionText += + '補足として提供されたURLの情報は次の通り:URL=>' + urlpreview.url + +'サイト名('+urlpreview.sitename+')、'; + if (!urlpreview.sensitive) { + systemInstructionText += + 'タイトル('+urlpreview.title+')、' + + '説明('+urlpreview.description+')、' + + '質問にあるURLとサイト名・タイトル・説明を組み合わせ、回答の参考にすること。' + ; + this.log('urlpreview.sitename:' + urlpreview.sitename); + this.log('urlpreview.title:' + urlpreview.title); + this.log('urlpreview.description:' + urlpreview.description); + } else { + systemInstructionText += + 'これはセンシティブなURLの可能性があるため、質問にあるURLとサイト名のみで、回答の参考にすること(使わなくても良い)。' + ; + } + } else { + // 多分ここにはこないが念のため + this.log('urlpreview.title is nothing'); + } + } + } } const systemInstruction: GeminiSystemInstruction = {role: 'system', parts: [{text: systemInstructionText}]}; parts = [{text: aiChat.question}]; - // ファイルが存在する場合、画像を添付して問い合わせ + // ファイルが存在する場合、ファイルを添付して問い合わせ if (files.length >= 1) { for (const file of files){ parts.push( @@ -150,34 +228,70 @@ export default class extends Module { } contents.push({role: 'user', parts: parts}); + let geminiOptions:GeminiOptions = { + contents: contents, + systemInstruction: systemInstruction, + }; + // gemini api grounding support. ref:https://github.com/google-gemini/cookbook/blob/09f3b17df1751297798c2b498cae61c6bf710edc/quickstarts/Search_Grounding.ipynb + if (aiChat.grounding) { + geminiOptions.tools = [{google_search:{}}]; + } let options = { url: aiChat.api, searchParams: { key: aiChat.key, }, - json: { - contents: contents, - systemInstruction: systemInstruction, - }, + json: geminiOptions, }; + this.log(JSON.stringify(options)); let res_data:any = null; + let responseText:string = ''; try { res_data = await got.post(options, {parseJson: (res: string) => 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?.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')) { - const responseText = res_data.candidates[0].content.parts[0].text; - return responseText; + for (let i = 0; i < res_data.candidates[0].content.parts.length; i++) { + if (res_data.candidates[0].content.parts[i].hasOwnProperty('text')) { + responseText += res_data.candidates[0].content.parts[i].text; + } } } } } + // groundingMetadataを取得 + let groundingMetadata = ''; + if (res_data.candidates[0].hasOwnProperty('groundingMetadata')) { + // 参考サイト情報 + if (res_data.candidates[0].groundingMetadata.hasOwnProperty('groundingChunks')) { + // 参考サイトが多すぎる場合があるので、3つに制限 + let checkMaxLength = res_data.candidates[0].groundingMetadata.groundingChunks.length; + if (res_data.candidates[0].groundingMetadata.groundingChunks.length > 3) { + checkMaxLength = 3; + } + for (let i = 0; i < checkMaxLength; i++) { + if (res_data.candidates[0].groundingMetadata.groundingChunks[i].hasOwnProperty('web')) { + if (res_data.candidates[0].groundingMetadata.groundingChunks[i].web.hasOwnProperty('uri') + && res_data.candidates[0].groundingMetadata.groundingChunks[i].web.hasOwnProperty('title')) { + groundingMetadata += `参考(${i+1}): [${res_data.candidates[0].groundingMetadata.groundingChunks[i].web.title}](${res_data.candidates[0].groundingMetadata.groundingChunks[i].web.uri})\n`; + } + } + } + } + // 検索ワード + if (res_data.candidates[0].groundingMetadata.hasOwnProperty('webSearchQueries')) { + if (res_data.candidates[0].groundingMetadata.webSearchQueries.length > 0) { + groundingMetadata += '検索ワード: ' + res_data.candidates[0].groundingMetadata.webSearchQueries.join(',') + '\n'; + } + } + } + responseText += groundingMetadata; } } } catch (err: unknown) { @@ -186,7 +300,7 @@ export default class extends Module { this.log(`${err.name}\n${err.message}\n${err.stack}`); } } - return null; + return responseText; } @bindThis @@ -305,18 +419,19 @@ export default class extends Module { const current : AiChatHist = { postId: msg.id, createdAt: Date.now(),// 適当なもの - type: type + type: type, + fromMention: true, }; // 引用している場合、情報を取得しhistoryとして与える if (msg.quoteId) { - const quotedNote = await this.ai.api("notes/show", { + const quotedNote = await this.ai.api('notes/show', { noteId: msg.quoteId, }); current.history = [ { - role: "user", + role: 'user', content: - "ユーザーが与えた前情報である、引用された文章: " + + 'ユーザーが与えた前情報である、引用された文章: ' + quotedNote.text, }, ]; @@ -404,11 +519,28 @@ export default class extends Module { // ランダムに選択 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; + + // 選択されたノート自体が会話中のidかチェック + exist = this.aichatHist.findOne({ + postId: choseNote.id + }); + if (exist != null) return false; + + // msg.idをもとにnotes/childrenを呼び出し、会話中のidかチェック + const childrenData = await this.ai.api('notes/children', { noteId: choseNote.id }); + if (childrenData != undefined) { + for (const message of childrenData) { + exist = this.aichatHist.findOne({ + postId: message.id + }); + if (exist != null) return false; + } + } + + // msg.idをもとにnotes/conversationを呼び出し、会話中のidかチェック + const conversationData = await this.ai.api('notes/conversation', { noteId: choseNote.id }); if (conversationData != undefined) { for (const message of conversationData) { exist = this.aichatHist.findOne({ @@ -437,7 +569,8 @@ export default class extends Module { const current : AiChatHist = { postId: choseNote.id, createdAt: Date.now(),// 適当なもの - type: TYPE_GEMINI + type: TYPE_GEMINI, // 別のAPIをデフォルトにしてもよい + fromMention: false, // ランダムトークの場合はfalseとする }; // AIに問い合わせ let targetedMessage = choseNote; @@ -457,7 +590,7 @@ export default class extends Module { @bindThis private async handleAiChat(exist: AiChatHist, msg: Message) { - let text: string, aiChat: AiChat; + let text: string | null, aiChat: AiChat; let prompt: string = ''; if (config.prompt) { prompt = config.prompt; @@ -476,23 +609,29 @@ export default class extends Module { reKigoType = RegExp(KIGO + GEMINI_PRO, 'i'); } + // groudingサポート + if (msg.includes([GROUNDING_TARGET])) { + exist.grounding = true; + } + // 設定で、デフォルトgroundingがONの場合、メンションから来たときは強制的にgroundingをONとする(ランダムトークの場合は勝手にGoogle検索するのちょっと気が引けるため...) + if (exist.fromMention && config.aichatGroundingWithGoogleSearchAlwaysEnabled) { + exist.grounding = true; + } + const friend: Friend | null = this.ai.lookupFriend(msg.userId); - this.log("msg.userId:"+msg.userId); let friendName: string | undefined; if (friend != null && friend.name != null) { friendName = friend.name; - this.log("friend.name:" + friend.name); } else if (msg.user.name) { friendName = msg.user.name; - this.log("msg.user.username:" + msg.user.username); } else { friendName = msg.user.username; - this.log("msg.user.username:" + msg.user.username); } const question = extractedText .replace(reName, '') .replace(reKigoType, '') + .replace(GROUNDING_TARGET, '') .trim(); switch (exist.type) { case TYPE_GEMINI: @@ -508,10 +647,14 @@ export default class extends Module { api: GEMINI_20_FLASH_API, key: config.geminiProApiKey, history: exist.history, - friendName: friendName + friendName: friendName, + fromMention: exist.fromMention }; if (exist.api) { - aiChat.api = exist.api + aiChat.api = exist.api; + } + if (exist.grounding) { + aiChat.grounding = exist.grounding; } text = await this.genTextByGemini(aiChat, base64Files); break; @@ -528,7 +671,8 @@ export default class extends Module { api: PLAMO_API, key: config.pLaMoApiKey, history: exist.history, - friendName: friendName + friendName: friendName, + fromMention: exist.fromMention }; text = await this.genTextByPLaMo(aiChat); break; @@ -538,7 +682,7 @@ export default class extends Module { return false; } - if (text == null) { + if (text == null || text == '') { this.log('The result is invalid. It seems that tokens and other items need to be reviewed.') msg.reply(serifs.aichat.error(exist.type)); return false; @@ -562,7 +706,8 @@ export default class extends Module { type: exist.type, api: aiChat.api, history: exist.history, - friendName: friendName + grounding: exist.grounding, + fromMention: exist.fromMention, }); this.log('Subscribe&Set Timer...'); diff --git a/src/utils/url2base64.ts b/src/utils/url2base64.ts index 7350a66..2efe700 100644 --- a/src/utils/url2base64.ts +++ b/src/utils/url2base64.ts @@ -4,8 +4,8 @@ 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; + const base64File = buffer.toString('base64'); + return base64File; } catch (err: unknown) { log('Error in url2base64'); if (err instanceof Error) { diff --git a/src/utils/url2json.ts b/src/utils/url2json.ts new file mode 100644 index 0000000..5857122 --- /dev/null +++ b/src/utils/url2json.ts @@ -0,0 +1,27 @@ +import log from '@/utils/log.js'; +import config from '@/config.js'; +import got from 'got'; + +export default async function(url: string): Promise { + try { + const urlPreviewUrl = config.host + '/url'; + return await got( + urlPreviewUrl, { + searchParams: { + url: url, + lang: 'ja-JP' + }, + timeout: { + lookup: 500, + send: 500, + response: 10000 + }, + }).json(); + } catch (err: unknown) { + log('Error in url2json'); + if (err instanceof Error) { + log(`${err.name}\n${err.message}\n${err.stack}`); + } + throw err; + } +} diff --git a/torisetu.md b/torisetu.md index 8fdf8c0..3af2666 100644 --- a/torisetu.md +++ b/torisetu.md @@ -76,14 +76,29 @@ Misskeyにアカウントを作成して初めて投稿を行うと、藍がネ PONGを返します。生存確認にどうぞ ### カスタム絵文字チェック -1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。 +1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。この機能を使う際は、藍用アクセストークンの作り直しが必要となる可能性があります。**藍を動かすBotアカウントに管理者権限を付与し、Botアカウントで「絵文字をみる」権限を追加で付与したアクセストークンを作成し、そのトークンを設定**してください。 ### aichat ``` @ai aichat 部屋の片付けの手順を教えて ``` -のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。 +のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。**ggg**を文章に入れると、Google検索によるグラウンディング(モデルを検証可能な情報源に接続するプロセス)を行った回答を行います(ただし、AI側で判断し、検索しないこともある)。 APIキーを登録の上、設定でaichatRandomTalkEnabledをtrueにすると、ランダムトーク(ランダムでaichatを発動)させることも可能です。ランダムトーク間隔、ランダムトーク確率を設定で指定可能です。 +設定でaichatGroundingWithGoogleSearchAlwaysEnabledをtrueにすると、メンションの場合、つねにGoogle検索によるグラウンディングを行った回答を試みます(gggの入力は不要。AI側で判断し、検索をしないこともある)。このグラウンディング機能は2025年2月現在、[1日1,000件利用可能](https://ai.google.dev/gemini-api/docs/models/gemini-v2?hl=ja#search-tool)です。 + +#### aichatの細かすぎる話 + +* aichat、または、返信にURLがある場合、Misskeyのサマリプロキシ?を使って、情報を取得した上で返答します。 +* aichat、または、返信したものにファイルが添付されている場合、そのファイルをもとに返答します。 + * 画像、PDF、音声、動画(短いもの)、テキスト形式のファイルが利用可能です。 + * ただし、センシティブなファイルは送らないほうが無難です、よくないことが起こる可能性があります。 +* aichatを行った際(mentionHook)、引用ノートがあれば、そのノートの本文を参照し、返答します。 +* aichatの結果に対し、返信すると、その情報を加味して返信します。 + * 返信は10個を超えると参照しなくなります + * 「exist.history.length > 10」の数字を変更すれば、参照する返信の数を増減できます(返信数が多くなり、送る文章が長すぎるとレスポンスが返ってこなかったり、エラーになったりするので気をつけてください) + * 返信を監視する時間は定数TIMEOUT_TIMEで変更可能です(デフォルトは30分) +* ランダムトーク機能は確率(設定で指定)をクリアし、親愛度が指定(7)以上、かつ、Botでない場合のみ実行されます + * 条件を変更したい場合はソース修正してください ### その他反応するフレーズ (トークのみ) * かわいい