aichatの強化(URL対応、グラウンディング対応)&説明文追記 (#166)

- README.mdとtorisetu.mdを修正
- 設定例ファイル、example.jsonを追加
- aichatにURLを対応
- グラウンディング(根拠づけ)に対応
This commit is contained in:
tetsuya-ki 2025-02-03 13:26:08 +09:00 committed by GitHub
parent 3a5d7f916b
commit 5f546bda68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 269 additions and 59 deletions

View file

@ -8,25 +8,26 @@ Misskey用の日本語Botです。
> Node.js と npm と MeCab (オプション) がインストールされている必要があります。 > Node.js と npm と MeCab (オプション) がインストールされている必要があります。
まず適当なディレクトリに `git clone` します。 まず適当なディレクトリに `git clone` します。
次にそのディレクトリに `config.json` を作成します。中身は次のようにします: 次にそのディレクトリに `config.json` を作成します(example.jsonをコピーして作ってもOK)。中身は次のようにします:
``` json ``` json
{ {
"host": "https:// + あなたのインスタンスのURL (末尾の / は除く)", "host": "https:// + あなたのインスタンスのURL (末尾の / は除く)",
"i": "藍として動かしたいアカウントのアクセストークン", "i": "藍として動かしたいアカウントのアクセストークン",
"master": "管理者のユーザー名(オプション)", "master": "管理者のユーザー名(オプション)",
"notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる", "notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる(二重引用符(”)は不要)",
"keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false)", "keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"chartEnabled": "チャート機能を無効化する場合は false を入れてください", "chartEnabled": "チャート機能を無効化する場合は false を入れる(二重引用符(”)は不要)",
"reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)", "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))。この機能を使う場合、藍のBotに管理者権限を与え、「絵文字を見る」権限を付与したアクセストークンを発行の上設定が必要。",
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false(いずれも二重引用符(”)は不要))",
"geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>", "geminiProApiKey": "Gemini APIキー。2025年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>",
"pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜11月は無料でトライアルだった(2025年現在有料のみ)。詳細は<https://plamo.preferredai.jp/>",
"prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください(短くてもOK)。ただし、リスト記法はMisskeyが対応しておらず、パーサーが壊れるため使用禁止です。列挙する場合は「・」を使ってください。」", "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に近づくほど発動しやすい))", "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))",
"aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)", "aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)",
"aichatGroundingWithGoogleSearchAlwaysEnabled": "aichatでGoogle検索を利用したグラウンディングを常に行う場合 true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)",
"mecabDic": "MeCab の辞書ファイルパス (オプション)", "mecabDic": "MeCab の辞書ファイルパス (オプション)",
"memoryDir": "memory.jsonの保存先オプション、デフォルトは'.'(レポジトリのルートです))" "memoryDir": "memory.jsonの保存先オプション、デフォルトは'.'(レポジトリのルートです))"
@ -36,26 +37,27 @@ Misskey用の日本語Botです。
## Dockerで動かす ## Dockerで動かす
まず適当なディレクトリに `git clone` します。 まず適当なディレクトリに `git clone` します。
次にそのディレクトリに `config.json` を作成します。中身は次のようにします: 次にそのディレクトリに `config.json` を作成します(example.jsonをコピーして作ってもOK)。中身は次のようにします:
MeCabの設定、memoryDirについては触らないでください MeCabの設定、memoryDirについては触らないでください
``` json ``` json
{ {
"host": "https:// + あなたのインスタンスのURL (末尾の / は除く)", "host": "https:// + あなたのインスタンスのURL (末尾の / は除く)",
"i": "藍として動かしたいアカウントのアクセストークン", "i": "藍として動かしたいアカウントのアクセストークン",
"master": "管理者のユーザー名(オプション)", "master": "管理者のユーザー名(オプション)",
"notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる", "notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる(二重引用符(”)は不要)",
"keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false)", "keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"chartEnabled": "チャート機能を無効化する場合は false を入れてください", "chartEnabled": "チャート機能を無効化する場合は false を入れる(二重引用符(”)は不要)",
"reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)", "reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)", "serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)", "checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))。この機能を使う場合、藍のBotに管理者権限を与え、「絵文字を見る」権限を付与したアクセストークンを発行の上設定が必要。",
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false(いずれも二重引用符(”)は不要))",
"geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>", "geminiProApiKey": "Gemini APIキー。2025年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>",
"pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜11月は無料でトライアルだった(2025年現在有料のみ)。詳細は<https://plamo.preferredai.jp/>",
"prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください(短くてもOK)。ただし、リスト記法はMisskeyが対応しておらず、パーサーが壊れるため使用禁止です。列挙する場合は「・」を使ってください。」", "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に近づくほど発動しやすい))。デフォルトは0.02(2%)", "aichatRandomTalkProbability": "ランダムにaichatを発動し話しかける機能の確率(1以下の小数点を含む数値(0.01など。1に近づくほど発動しやすい))",
"aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)", "aichatRandomTalkIntervalMinutes": "ランダムトーク間隔(分)。指定した時間ごとにタイムラインを取得し、適当に選んだ人にaichatする(1の場合1分ごと実行)。デフォルトは720分(12時間)",
"aichatGroundingWithGoogleSearchAlwaysEnabled": "aichatでGoogle検索を利用したグラウンディングを常に行う場合 true を入れる (無効にする場合は false(いずれも二重引用符(”)は不要))",
"mecab": "/usr/bin/mecab", "mecab": "/usr/bin/mecab",
"mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/", "mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/",
"memoryDir": "data" "memoryDir": "data"

20
example.json Normal file
View file

@ -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"
}

View file

@ -15,9 +15,10 @@ type Config = {
geminiProApiKey?: string; geminiProApiKey?: string;
pLaMoApiKey?: string; pLaMoApiKey?: string;
prompt?: string; prompt?: string;
aichatRandomTalkEnabled?: string; aichatRandomTalkEnabled?: boolean;
aichatRandomTalkProbability?: string; aichatRandomTalkProbability?: string;
aichatRandomTalkIntervalMinutes?: string; aichatRandomTalkIntervalMinutes?: string;
aichatGroundingWithGoogleSearchAlwaysEnabled?: boolean;
mecab?: string; mecab?: string;
mecabDic?: string; mecabDic?: string;
memoryDir?: string; memoryDir?: string;

View file

@ -5,6 +5,7 @@ import Message from '@/message.js';
import config from '@/config.js'; import config from '@/config.js';
import Friend from '@/friend.js'; import Friend from '@/friend.js';
import urlToBase64 from '@/utils/url2base64.js'; import urlToBase64 from '@/utils/url2base64.js';
import urlToJson from '@/utils/url2json.js';
import got from 'got'; import got from 'got';
import loki from 'lokijs'; import loki from 'lokijs';
@ -13,8 +14,10 @@ type AiChat = {
prompt: string; prompt: string;
api: string; api: string;
key: string; key: string;
history?: { role: string; content: string }[]; fromMention: boolean;
friendName?: string; friendName?: string;
grounding?: boolean;
history?: { role: string; content: string }[];
}; };
type base64File = { type base64File = {
type: string; type: string;
@ -40,23 +43,48 @@ type GeminiContents = {
role: string; role: string;
parts: GeminiParts; parts: GeminiParts;
}; };
type GeminiOptions = {
contents?: GeminiContents[],
systemInstruction?: GeminiSystemInstruction,
tools?: [{}]
};
type AiChatHist = { type AiChatHist = {
postId: string; postId: string;
createdAt: number; createdAt: number;
type: string; type: string;
fromMention: boolean;
api?: string; api?: string;
grounding?: boolean;
history?: { history?: {
role: string; role: string;
content: 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 KIGO = '&';
const TYPE_GEMINI = 'gemini'; const TYPE_GEMINI = 'gemini';
const GEMINI_PRO = 'gemini-pro'; const GEMINI_PRO = 'gemini-pro';
const GEMINI_FLASH = 'gemini-flash'; const GEMINI_FLASH = 'gemini-flash';
const TYPE_PLAMO = 'plamo'; 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_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'; // 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('aichatRandomTalkEnabled:' + config.aichatRandomTalkEnabled);
this.log('randomTalkProbability:' + this.randomTalkProbability); this.log('randomTalkProbability:' + this.randomTalkProbability);
this.log('randomTalkIntervalMinutes:' + (this.randomTalkIntervalMinutes / (60 * 1000))); this.log('randomTalkIntervalMinutes:' + (this.randomTalkIntervalMinutes / (60 * 1000)));
this.log('aichatGroundingWithGoogleSearchAlwaysEnabled:' + config.aichatGroundingWithGoogleSearchAlwaysEnabled);
// 定期的にデータを取得しaichatRandomTalkを行う // 定期的にデータを取得しaichatRandomTalkを行う
if (config.aichatRandomTalkEnabled) { if (config.aichatRandomTalkEnabled) {
@ -116,15 +145,64 @@ export default class extends Module {
minute: '2-digit' minute: '2-digit'
}); });
// 設定のプロンプトに加え、現在時刻を渡す // 設定のプロンプトに加え、現在時刻を渡す
let systemInstructionText = aiChat.prompt + "。また、現在日時は" + now + "であり、これは回答の参考にし、時刻を聞かれるまで時刻情報は提供しないこと(なお、他の日時は無効とすること)。"; let systemInstructionText = aiChat.prompt + 'また、現在日時は' + now + 'であり、これは回答の参考にし、時刻を聞かれるまで時刻情報は提供しないこと(なお、他の日時は無効とすること)。';
// 名前を伝えておく // 名前を伝えておく
if (aiChat.friendName != undefined) { 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}]}; const systemInstruction: GeminiSystemInstruction = {role: 'system', parts: [{text: systemInstructionText}]};
parts = [{text: aiChat.question}]; parts = [{text: aiChat.question}];
// ファイルが存在する場合、画像を添付して問い合わせ // ファイルが存在する場合、ファイルを添付して問い合わせ
if (files.length >= 1) { if (files.length >= 1) {
for (const file of files){ for (const file of files){
parts.push( parts.push(
@ -150,34 +228,70 @@ export default class extends Module {
} }
contents.push({role: 'user', parts: parts}); 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 = { let options = {
url: aiChat.api, url: aiChat.api,
searchParams: { searchParams: {
key: aiChat.key, key: aiChat.key,
}, },
json: { json: geminiOptions,
contents: contents,
systemInstruction: systemInstruction,
},
}; };
this.log(JSON.stringify(options)); this.log(JSON.stringify(options));
let res_data:any = null; let res_data:any = null;
let responseText:string = '';
try { try {
res_data = await got.post(options, res_data = await got.post(options,
{parseJson: (res: string) => JSON.parse(res)}).json(); {parseJson: (res: string) => JSON.parse(res)}).json();
this.log(JSON.stringify(res_data)); this.log(JSON.stringify(res_data));
if (res_data.hasOwnProperty('candidates')) { 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].hasOwnProperty('content')) {
if (res_data.candidates[0].content.hasOwnProperty('parts')) { if (res_data.candidates[0].content.hasOwnProperty('parts')) {
if (res_data.candidates[0].content.parts.length > 0) { if (res_data.candidates[0].content.parts.length > 0) {
if (res_data.candidates[0].content.parts[0].hasOwnProperty('text')) { for (let i = 0; i < res_data.candidates[0].content.parts.length; i++) {
const responseText = res_data.candidates[0].content.parts[0].text; if (res_data.candidates[0].content.parts[i].hasOwnProperty('text')) {
return responseText; 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) { } catch (err: unknown) {
@ -186,7 +300,7 @@ export default class extends Module {
this.log(`${err.name}\n${err.message}\n${err.stack}`); this.log(`${err.name}\n${err.message}\n${err.stack}`);
} }
} }
return null; return responseText;
} }
@bindThis @bindThis
@ -305,18 +419,19 @@ export default class extends Module {
const current : AiChatHist = { const current : AiChatHist = {
postId: msg.id, postId: msg.id,
createdAt: Date.now(),// 適当なもの createdAt: Date.now(),// 適当なもの
type: type type: type,
fromMention: true,
}; };
// 引用している場合、情報を取得しhistoryとして与える // 引用している場合、情報を取得しhistoryとして与える
if (msg.quoteId) { if (msg.quoteId) {
const quotedNote = await this.ai.api("notes/show", { const quotedNote = await this.ai.api('notes/show', {
noteId: msg.quoteId, noteId: msg.quoteId,
}); });
current.history = [ current.history = [
{ {
role: "user", role: 'user',
content: content:
"ユーザーが与えた前情報である、引用された文章: " + 'ユーザーが与えた前情報である、引用された文章: ' +
quotedNote.text, quotedNote.text,
}, },
]; ];
@ -404,11 +519,28 @@ export default class extends Module {
// ランダムに選択 // ランダムに選択
const choseNote = interestedNotes[Math.floor(Math.random() * interestedNotes.length)]; 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では対応しない // aichatHistに該当のポストが見つかった場合は会話中のためaichatRandomTalkでは対応しない
let exist : AiChatHist | null = null; 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) { if (conversationData != undefined) {
for (const message of conversationData) { for (const message of conversationData) {
exist = this.aichatHist.findOne({ exist = this.aichatHist.findOne({
@ -437,7 +569,8 @@ export default class extends Module {
const current : AiChatHist = { const current : AiChatHist = {
postId: choseNote.id, postId: choseNote.id,
createdAt: Date.now(),// 適当なもの createdAt: Date.now(),// 適当なもの
type: TYPE_GEMINI type: TYPE_GEMINI, // 別のAPIをデフォルトにしてもよい
fromMention: false, // ランダムトークの場合はfalseとする
}; };
// AIに問い合わせ // AIに問い合わせ
let targetedMessage = choseNote; let targetedMessage = choseNote;
@ -457,7 +590,7 @@ export default class extends Module {
@bindThis @bindThis
private async handleAiChat(exist: AiChatHist, msg: Message) { private async handleAiChat(exist: AiChatHist, msg: Message) {
let text: string, aiChat: AiChat; let text: string | null, aiChat: AiChat;
let prompt: string = ''; let prompt: string = '';
if (config.prompt) { if (config.prompt) {
prompt = config.prompt; prompt = config.prompt;
@ -476,23 +609,29 @@ export default class extends Module {
reKigoType = RegExp(KIGO + GEMINI_PRO, 'i'); 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); const friend: Friend | null = this.ai.lookupFriend(msg.userId);
this.log("msg.userId:"+msg.userId);
let friendName: string | undefined; let friendName: string | undefined;
if (friend != null && friend.name != null) { if (friend != null && friend.name != null) {
friendName = friend.name; friendName = friend.name;
this.log("friend.name:" + friend.name);
} else if (msg.user.name) { } else if (msg.user.name) {
friendName = msg.user.name; friendName = msg.user.name;
this.log("msg.user.username:" + msg.user.username);
} else { } else {
friendName = msg.user.username; friendName = msg.user.username;
this.log("msg.user.username:" + msg.user.username);
} }
const question = extractedText const question = extractedText
.replace(reName, '') .replace(reName, '')
.replace(reKigoType, '') .replace(reKigoType, '')
.replace(GROUNDING_TARGET, '')
.trim(); .trim();
switch (exist.type) { switch (exist.type) {
case TYPE_GEMINI: case TYPE_GEMINI:
@ -508,10 +647,14 @@ export default class extends Module {
api: GEMINI_20_FLASH_API, api: GEMINI_20_FLASH_API,
key: config.geminiProApiKey, key: config.geminiProApiKey,
history: exist.history, history: exist.history,
friendName: friendName friendName: friendName,
fromMention: exist.fromMention
}; };
if (exist.api) { if (exist.api) {
aiChat.api = exist.api aiChat.api = exist.api;
}
if (exist.grounding) {
aiChat.grounding = exist.grounding;
} }
text = await this.genTextByGemini(aiChat, base64Files); text = await this.genTextByGemini(aiChat, base64Files);
break; break;
@ -528,7 +671,8 @@ export default class extends Module {
api: PLAMO_API, api: PLAMO_API,
key: config.pLaMoApiKey, key: config.pLaMoApiKey,
history: exist.history, history: exist.history,
friendName: friendName friendName: friendName,
fromMention: exist.fromMention
}; };
text = await this.genTextByPLaMo(aiChat); text = await this.genTextByPLaMo(aiChat);
break; break;
@ -538,7 +682,7 @@ export default class extends Module {
return false; 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.') this.log('The result is invalid. It seems that tokens and other items need to be reviewed.')
msg.reply(serifs.aichat.error(exist.type)); msg.reply(serifs.aichat.error(exist.type));
return false; return false;
@ -562,7 +706,8 @@ export default class extends Module {
type: exist.type, type: exist.type,
api: aiChat.api, api: aiChat.api,
history: exist.history, history: exist.history,
friendName: friendName grounding: exist.grounding,
fromMention: exist.fromMention,
}); });
this.log('Subscribe&Set Timer...'); this.log('Subscribe&Set Timer...');

View file

@ -4,8 +4,8 @@ import got from 'got';
export default async function(url: string): Promise<string> { export default async function(url: string): Promise<string> {
try { try {
const buffer = await got(url).buffer(); const buffer = await got(url).buffer();
const base64Image = buffer.toString('base64'); const base64File = buffer.toString('base64');
return base64Image; return base64File;
} catch (err: unknown) { } catch (err: unknown) {
log('Error in url2base64'); log('Error in url2base64');
if (err instanceof Error) { if (err instanceof Error) {

27
src/utils/url2json.ts Normal file
View file

@ -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<string> {
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;
}
}

View file

@ -76,14 +76,29 @@ Misskeyにアカウントを作成して初めて投稿を行うと、藍がネ
PONGを返します。生存確認にどうぞ PONGを返します。生存確認にどうぞ
### カスタム絵文字チェック ### カスタム絵文字チェック
1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。 1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。この機能を使う際は、藍用アクセストークンの作り直しが必要となる可能性があります。**藍を動かすBotアカウントに管理者権限を付与し、Botアカウントで「絵文字をみる」権限を追加で付与したアクセストークンを作成し、そのトークンを設定**してください。
### aichat ### aichat
``` ```
@ai aichat 部屋の片付けの手順を教えて @ai aichat 部屋の片付けの手順を教えて
``` ```
のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。 のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。**ggg**を文章に入れると、Google検索によるグラウンディング(モデルを検証可能な情報源に接続するプロセス)を行った回答を行います(ただし、AI側で判断し、検索しないこともある)。
APIキーを登録の上、設定でaichatRandomTalkEnabledをtrueにすると、ランダムトーク(ランダムでaichatを発動)させることも可能です。ランダムトーク間隔、ランダムトーク確率を設定で指定可能です。 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でない場合のみ実行されます
* 条件を変更したい場合はソース修正してください
### その他反応するフレーズ (トークのみ) ### その他反応するフレーズ (トークのみ)
* かわいい * かわいい