feat:aichat機能(Gemini APIなどを使ってチャットする機能) (#153)

* カスタム絵文字チェックモジュールの追加

* カスタム絵文字チェックモジュールの修正

- README.mdやtorisetu.mdに記載
- カスタム絵文字投稿をまとめる設定を追加(checkEmojisAtOnce)

* Feat:  aichat機能

* 現時点ではGemini APIのみ対応

* enhance: aichat機能(画像対応)

* fix #2

* fix #4

* fix #5

* README.mdにPLaMoについて記載

* aichatの文字列置換を正規表現に変更

* Feat:  aichat機能

* 現時点ではGemini APIのみ対応

* enhance: aichat機能(画像対応)

* fix #4

* fix #5

* README.mdにPLaMoについて記載

* aichatの文字列置換を正規表現に変更

* fix #7

- aichatで回答したものに返信すると、文脈を保持して回答されるように変更

* fix #9

- ランダムにaichatを発動し話しかける機能の追加
- 設定追加
  - aichatRandomTalkEnabled
  - aichatRandomTalkProbability
  - aichatRandomTalkIntervalMinutes

* torisetu.md更新(aichat)
This commit is contained in:
tetsuya-ki 2025-01-04 13:29:51 +09:00 committed by GitHub
parent 830c9c2ecd
commit 81543029a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 551 additions and 1 deletions

View file

@ -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 \

View file

@ -21,6 +21,12 @@ Misskey用の日本語Botです。
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)",
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)",
"geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>",
"pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>",
"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の保存先オプション、デフォルトは'.'(レポジトリのルートです))"
@ -44,6 +50,12 @@ Misskey用の日本語Botです。
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)",
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)",
"geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>",
"pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>",
"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"

View file

@ -12,6 +12,12 @@ type Config = {
serverMonitoring: boolean;
checkEmojisEnabled?: boolean;
checkEmojisAtOnce?: boolean;
geminiProApiKey?: string;
pLaMoApiKey?: string;
prompt?: string;
aichatRandomTalkEnabled?: string;
aichatRandomTalkProbability?: string;
aichatRandomTalkIntervalMinutes?: string;
mecab?: string;
mecabDic?: string;
memoryDir?: string;

View file

@ -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'));

501
src/modules/aichat/index.ts Normal file
View file

@ -0,0 +1,501 @@
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 Friend from '@/friend.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 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<AiChatHist>;
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,
timeoutCallback: this.timeoutCallback,
};
}
@bindThis
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; })[];
const systemInstruction : {role: string; parts: [{text: string}]} = {role: 'system', parts: [{text: aiChat.prompt}]};
if (!image) {
// 画像がない場合、メッセージのみで問い合わせ
parts = [{text: aiChat.question}];
} else {
// 画像が存在する場合、画像を添付して問い合わせ
parts = [
{ text: aiChat.question },
{
inline_data: {
mime_type: image.type,
data: image.base64,
},
},
];
}
// 履歴を追加
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: contents,
systemInstruction: systemInstruction,
},
};
this.log(JSON.stringify(options));
let res_data:any = null;
try {
res_data = await got.post(options,
{parseJson: (res: string) => JSON.parse(res)}).json();
{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')) {
const responseText = res_data.candidates[0].content.parts[0].text;
return responseText;
}
}
}
}
}
}
} catch (err: unknown) {
this.log('Error By Call Gemini');
if (err instanceof Error) {
this.log(`${err.name}\n${err.message}\n${err.stack}`);
}
}
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: string) => 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 });
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;
}
@bindThis
private async mentionHook(msg: Message) {
if (!msg.includes([this.name])) {
return false;
} else {
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])) {
type = TYPE_GEMINI;
} else if (msg.includes([KIGO + 'chatgpt4'])) {
type = 'chatgpt4';
} else if (msg.includes([KIGO + 'chatgpt'])) {
type = 'chatgpt3.5';
} 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 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;
let prompt: string = '';
if (config.prompt) {
prompt = config.prompt;
}
const reName = RegExp(this.name, "i");
const reKigoType = RegExp(KIGO + exist.type, "i");
const extractedText = msg.extractedText;
if (extractedText == undefined || extractedText.length == 0) return false;
const question = extractedText
.replace(reName, '')
.replace(reKigoType, '')
.trim();
switch (exist.type) {
case TYPE_GEMINI:
// geminiの場合、APIキーが必須
if (!config.geminiProApiKey) {
msg.reply(serifs.aichat.nothing(exist.type));
return false;
}
const base64Image: Base64Image | null = await this.note2base64Image(msg.id);
aiChat = {
question: question,
prompt: prompt,
api: GEMINI_15_PRO_API,
key: config.geminiProApiKey,
history: exist.history
};
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 TYPE_PLAMO:
// PLaMoの場合、APIキーが必須
if (!config.pLaMoApiKey) {
msg.reply(serifs.aichat.nothing(exist.type));
return false;
}
aiChat = {
question: msg.text,
prompt: prompt,
api: PLAMO_API,
key: config.pLaMoApiKey,
history: exist.history
};
text = await this.genTextByPLaMo(aiChat);
break;
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(exist.type));
return false;
}
this.log('Replying...');
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
});
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);
}
}
}

View file

@ -388,6 +388,12 @@ export default {
emojiOnce: emoji => `:${emoji}:(\`${emoji}\`)`
},
aichat: {
nothing: type => `あぅ... ${type}のAPIキーが登録されてないみたいです`,
error: type => `うぇ...${type}でエラーが発生しちゃったみたいです。gemini-flashだと動くかも`,
post: (text, type) => `${text} (${type}) #aichat`,
},
sleepReport: {
report: hours => `んぅ、${hours}時間くらい寝ちゃってたみたいです`,
reportUtatane: 'ん... うたた寝しちゃってました',

16
src/utils/url2base64.ts Normal file
View file

@ -0,0 +1,16 @@
import log from '@/utils/log.js';
import got from 'got';
export default async function(url: string): Promise<string> {
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;
}
}

View file

@ -78,6 +78,13 @@ PONGを返します。生存確認にどうぞ
### カスタム絵文字チェック
1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。
### aichat
```
@ai aichat 部屋の片付けの手順を教えて
```
のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。
APIキーを登録の上、設定でaichatRandomTalkEnabledをtrueにすると、ランダムトーク(ランダムでaichatを発動)させることも可能です。ランダムトーク間隔、ランダムトーク確率を設定で指定可能です。
### その他反応するフレーズ (トークのみ)
* かわいい
* なでなで