mirror of
https://github.com/syuilo/ai.git
synced 2025-01-31 07:29:10 +00:00
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:
parent
830c9c2ecd
commit
81543029a8
8 changed files with 551 additions and 1 deletions
|
@ -9,7 +9,7 @@ RUN if [ $enable_mecab -ne 0 ]; then apt-get update \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt-get/lists/* \
|
&& rm -rf /var/lib/apt-get/lists/* \
|
||||||
&& cd /opt \
|
&& 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 \
|
&& cd /opt/mecab-ipadic-neologd \
|
||||||
&& ./bin/install-mecab-ipadic-neologd -n -y \
|
&& ./bin/install-mecab-ipadic-neologd -n -y \
|
||||||
&& rm -rf /opt/mecab-ipadic-neologd \
|
&& rm -rf /opt/mecab-ipadic-neologd \
|
||||||
|
|
12
README.md
12
README.md
|
@ -21,6 +21,12 @@ Misskey用の日本語Botです。
|
||||||
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
|
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
|
||||||
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)",
|
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)",
|
||||||
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は 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)",
|
"mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)",
|
||||||
"mecabDic": "MeCab の辞書ファイルパス (オプション)",
|
"mecabDic": "MeCab の辞書ファイルパス (オプション)",
|
||||||
"memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))"
|
"memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))"
|
||||||
|
@ -44,6 +50,12 @@ Misskey用の日本語Botです。
|
||||||
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
|
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
|
||||||
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)",
|
"checkEmojisEnabled": "カスタム絵文字チェック機能を有効にする場合は true を入れる (無効にする場合は false)",
|
||||||
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は 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",
|
"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"
|
||||||
|
|
|
@ -12,6 +12,12 @@ type Config = {
|
||||||
serverMonitoring: boolean;
|
serverMonitoring: boolean;
|
||||||
checkEmojisEnabled?: boolean;
|
checkEmojisEnabled?: boolean;
|
||||||
checkEmojisAtOnce?: boolean;
|
checkEmojisAtOnce?: boolean;
|
||||||
|
geminiProApiKey?: string;
|
||||||
|
pLaMoApiKey?: string;
|
||||||
|
prompt?: string;
|
||||||
|
aichatRandomTalkEnabled?: string;
|
||||||
|
aichatRandomTalkProbability?: string;
|
||||||
|
aichatRandomTalkIntervalMinutes?: string;
|
||||||
mecab?: string;
|
mecab?: string;
|
||||||
mecabDic?: string;
|
mecabDic?: string;
|
||||||
memoryDir?: string;
|
memoryDir?: string;
|
||||||
|
|
|
@ -34,6 +34,7 @@ import NotingModule from './modules/noting/index.js';
|
||||||
import PollModule from './modules/poll/index.js';
|
import PollModule from './modules/poll/index.js';
|
||||||
import ReminderModule from './modules/reminder/index.js';
|
import ReminderModule from './modules/reminder/index.js';
|
||||||
import CheckCustomEmojisModule from './modules/check-custom-emojis/index.js';
|
import CheckCustomEmojisModule from './modules/check-custom-emojis/index.js';
|
||||||
|
import AiChatModule from './modules/aichat/index.js';
|
||||||
|
|
||||||
console.log(' __ ____ _____ ___ ');
|
console.log(' __ ____ _____ ___ ');
|
||||||
console.log(' /__\\ (_ _)( _ )/ __)');
|
console.log(' /__\\ (_ _)( _ )/ __)');
|
||||||
|
@ -96,6 +97,7 @@ promiseRetry(retry => {
|
||||||
new PollModule(),
|
new PollModule(),
|
||||||
new ReminderModule(),
|
new ReminderModule(),
|
||||||
new CheckCustomEmojisModule(),
|
new CheckCustomEmojisModule(),
|
||||||
|
new AiChatModule(),
|
||||||
]);
|
]);
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
log(chalk.red('Failed to fetch the account'));
|
log(chalk.red('Failed to fetch the account'));
|
||||||
|
|
501
src/modules/aichat/index.ts
Normal file
501
src/modules/aichat/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -388,6 +388,12 @@ export default {
|
||||||
emojiOnce: emoji => `:${emoji}:(\`${emoji}\`)`
|
emojiOnce: emoji => `:${emoji}:(\`${emoji}\`)`
|
||||||
},
|
},
|
||||||
|
|
||||||
|
aichat: {
|
||||||
|
nothing: type => `あぅ... ${type}のAPIキーが登録されてないみたいです`,
|
||||||
|
error: type => `うぇ...${type}でエラーが発生しちゃったみたいです。gemini-flashだと動くかも?`,
|
||||||
|
post: (text, type) => `${text} (${type}) #aichat`,
|
||||||
|
},
|
||||||
|
|
||||||
sleepReport: {
|
sleepReport: {
|
||||||
report: hours => `んぅ、${hours}時間くらい寝ちゃってたみたいです`,
|
report: hours => `んぅ、${hours}時間くらい寝ちゃってたみたいです`,
|
||||||
reportUtatane: 'ん... うたた寝しちゃってました',
|
reportUtatane: 'ん... うたた寝しちゃってました',
|
||||||
|
|
16
src/utils/url2base64.ts
Normal file
16
src/utils/url2base64.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -78,6 +78,13 @@ PONGを返します。生存確認にどうぞ
|
||||||
### カスタム絵文字チェック
|
### カスタム絵文字チェック
|
||||||
1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。
|
1日に1回、カスタム絵文字の追加を監視してくれます。「カスタムえもじチェック」または「カスタムえもじを確認して」ですぐに確認してくれます。
|
||||||
|
|
||||||
|
### aichat
|
||||||
|
```
|
||||||
|
@ai aichat 部屋の片付けの手順を教えて
|
||||||
|
```
|
||||||
|
のようにメンションを飛ばすと、GoogleのGemini APIなどを使って返答してくれます(今のバージョンではGemini APIのみ対応)。利用するにはAPIキーの登録が必要です。藍ちゃんの返信に対し、返信するとさらに返信されます(指定時間以内のみ)。
|
||||||
|
APIキーを登録の上、設定でaichatRandomTalkEnabledをtrueにすると、ランダムトーク(ランダムでaichatを発動)させることも可能です。ランダムトーク間隔、ランダムトーク確率を設定で指定可能です。
|
||||||
|
|
||||||
### その他反応するフレーズ (トークのみ)
|
### その他反応するフレーズ (トークのみ)
|
||||||
* かわいい
|
* かわいい
|
||||||
* なでなで
|
* なでなで
|
||||||
|
|
Loading…
Reference in a new issue