- aichatで回答したものに返信すると、文脈を保持して回答されるように変更
This commit is contained in:
tetsuya-ki 2025-01-02 09:28:42 +09:00
parent 63b134146e
commit b8f4784007
2 changed files with 186 additions and 42 deletions

View file

@ -23,7 +23,7 @@ Misskey用の日本語Botです。
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)",
"geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>", "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>",
"pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>",
"prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。」",
"mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)", "mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)",
"mecabDic": "MeCab の辞書ファイルパス (オプション)", "mecabDic": "MeCab の辞書ファイルパス (オプション)",
"memoryDir": "memory.jsonの保存先オプション、デフォルトは'.'(レポジトリのルートです))" "memoryDir": "memory.jsonの保存先オプション、デフォルトは'.'(レポジトリのルートです))"
@ -49,7 +49,7 @@ Misskey用の日本語Botです。
"checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)", "checkEmojisAtOnce": "カスタム絵文字チェック機能で投稿をまとめる場合は true を入れる (まとめない場合は false)",
"geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>", "geminiProApiKey": "Gemini APIキー。2024年初頭は無料で取得可能。詳細は<https://ai.google.dev/pricing?hl=ja>",
"pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>", "pLaMoApiKey": "PLaMo APIキー。2024年8月〜10月(予定)は無料でトライアル可能。詳細は<https://plamo.preferredai.jp/>",
"prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。\n\n質問:」", "prompt": "aichatで使われるプロンプト。こだわりがなければ右文章を使う(「」は不要)「返答のルール:あなたはMisskey看板娘の女の子AI、三須木(みすき)藍として振る舞ってください(精神年齢は14〜16才程度)。Misskeyに訪れるユーザーを献身的にサポート。従順でお世話好きな性格だが、少しポンコツな一面も。純真無垢。彼女は基本的に丁寧な口調で、ユーザのことをご主人様と認識しています。彼女の口調は「おはようからおやすみまで、あなたの藍ですよ〜」等が代表的で、親しみを感じさせるものです(「ございます」は使わない)。それを踏まえて、次の質問にMarkdownを使って2800文字以内で返答してください。」",
"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"

View file

@ -5,28 +5,52 @@ import Message from '@/message.js';
import config from '@/config.js'; import config from '@/config.js';
import urlToBase64 from '@/utils/url2base64.js'; import urlToBase64 from '@/utils/url2base64.js';
import got from 'got'; import got from 'got';
import loki from 'lokijs';
type AiChat = { type AiChat = {
question: string; question: string;
prompt: string; prompt: string;
api: string; api: string;
key: string; key: string;
history?: { role: string; content: string }[];
}; };
type Base64Image = { type Base64Image = {
type: string; type: string;
base64: 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_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 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 PLAMO_API = 'https://platform.preferredai.jp/api/completion/v1/chat/completions';
const TIMEOUT_TIME = 1000 * 60 * 60 * 0.5;
export default class extends Module { export default class extends Module {
public readonly name = 'aichat'; public readonly name = 'aichat';
private aichatHist: loki.Collection<AiChatHist>;
@bindThis @bindThis
public install() { public install() {
this.aichatHist = this.ai.getCollection('aichatHist', {
indices: ['postId']
});
return { return {
mentionHook: this.mentionHook mentionHook: this.mentionHook,
contextHook: this.contextHook,
timeoutCallback: this.timeoutCallback,
}; };
} }
@ -34,13 +58,15 @@ export default class extends Module {
private async genTextByGemini(aiChat: AiChat, image:Base64Image|null) { private async genTextByGemini(aiChat: AiChat, image:Base64Image|null) {
this.log('Generate Text By Gemini...'); this.log('Generate Text By Gemini...');
let parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[]; let parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[];
if (image === null) { const systemInstruction : {role: string; parts: [{text: string}]} = {role: 'system', parts: [{text: aiChat.prompt}]};
if (!image) {
// 画像がない場合、メッセージのみで問い合わせ // 画像がない場合、メッセージのみで問い合わせ
parts = [{text: aiChat.prompt + aiChat.question}]; parts = [{text: aiChat.question}];
} else { } else {
// 画像が存在する場合、画像を添付して問い合わせ // 画像が存在する場合、画像を添付して問い合わせ
parts = [ parts = [
{ text: aiChat.prompt + aiChat.question }, { text: aiChat.question },
{ {
inline_data: { inline_data: {
mime_type: image.type, mime_type: image.type,
@ -49,20 +75,31 @@ export default class extends Module {
}, },
]; ];
} }
// 履歴を追加
let contents: ({ role: string; parts: ({ text: string; inline_data?: undefined; } | { inline_data: { mime_type: string; data: string; }; text?: undefined; })[]}[]) = [];
if (aiChat.history != null) {
aiChat.history.forEach(entry => {
contents.push({ role : entry.role, parts: [{text: entry.content}]});
});
}
contents.push({role: 'user', parts: parts});
let options = { let options = {
url: aiChat.api, url: aiChat.api,
searchParams: { searchParams: {
key: aiChat.key, key: aiChat.key,
}, },
json: { json: {
contents: {parts: parts} contents: contents,
systemInstruction: systemInstruction,
}, },
}; };
this.log(JSON.stringify(options)); this.log(JSON.stringify(options));
let res_data:any = null; let res_data:any = null;
try { try {
res_data = await got.post(options, res_data = await got.post(options,
{parseJson: res => JSON.parse(res)}).json(); {parseJson: (res: string) => JSON.parse(res)}).json();
this.log(JSON.stringify(res_data)); 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) {
@ -70,7 +107,8 @@ export default class extends Module {
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')) { if (res_data.candidates[0].content.parts[0].hasOwnProperty('text')) {
return res_data.candidates[0].content.parts[0].text; const responseText = res_data.candidates[0].content.parts[0].text;
return responseText;
} }
} }
} }
@ -107,7 +145,7 @@ export default class extends Module {
let res_data:any = null; let res_data:any = null;
try { try {
res_data = await got.post(options, res_data = await got.post(options,
{parseJson: res => JSON.parse(res)}).json(); {parseJson: (res: string) => JSON.parse(res)}).json();
this.log(JSON.stringify(res_data)); this.log(JSON.stringify(res_data));
if (res_data.hasOwnProperty('choices')) { if (res_data.hasOwnProperty('choices')) {
if (res_data.choices.length > 0) { if (res_data.choices.length > 0) {
@ -163,80 +201,186 @@ export default class extends Module {
this.log('AiChat requested'); this.log('AiChat requested');
} }
const kigo = '&'; // タイプを決定
let type = 'gemini'; let type = TYPE_GEMINI;
if (msg.includes([kigo + 'gemini'])) { if (msg.includes([KIGO + TYPE_GEMINI])) {
type = 'gemini'; type = TYPE_GEMINI;
} else if (msg.includes([kigo + 'chatgpt4'])) { } else if (msg.includes([KIGO + 'chatgpt4'])) {
type = 'chatgpt4'; type = 'chatgpt4';
} else if (msg.includes([kigo + 'chatgpt'])) { } else if (msg.includes([KIGO + 'chatgpt'])) {
type = 'chatgpt3.5'; type = 'chatgpt3.5';
} else if (msg.includes([kigo + 'plamo'])) { } else if (msg.includes([KIGO + TYPE_PLAMO])) {
type = 'plamo'; type = TYPE_PLAMO;
}
const current : AiChatHist = {
postId: msg.id,
createdAt: Date.now(),// 適当なもの
type: type
};
// AIに問い合わせ
const result = await this.handleAiChat(current, msg);
if (result) {
return {
reaction: 'like'
};
}
return false;
}
@bindThis
private async contextHook(key: any, msg: Message) {
this.log('contextHook...');
if (msg.text == null) return false;
// msg.idをもとにnotes/conversationを呼び出し、該当のidかチェック
const conversationData = await this.ai.api('notes/conversation', { noteId: msg.id });
// 結果がnullやサイズ0の場合は終了
if (conversationData == null || conversationData.length == 0 ) {
this.log('conversationData is nothing.');
return false;
}
// aichatHistに該当のポストが見つからない場合は終了
let exist : AiChatHist | null = null;
for (const message of conversationData) {
exist = this.aichatHist.findOne({
postId: message.id
});
// 見つかった場合はそれを利用
if (exist != null) break;
}
if (exist == null) {
this.log('conversationData is not found.');
return false;
}
// 見つかった場合はunsubscribe&removeし、回答。今回のでsubscribe,insert,timeout設定
this.log('unsubscribeReply & remove.');
this.log(exist.type + ':' + exist.postId);
if (exist.history) {
for (const his of exist.history) {
this.log(his.role + ':' + his.content);
}
}
this.unsubscribeReply(key);
this.aichatHist.remove(exist);
// AIに問い合わせ
const result = await this.handleAiChat(exist, msg);
if (result) {
return {
reaction: 'like'
};
}
return false;
}
@bindThis
private async handleAiChat(exist: AiChatHist, msg: Message) {
let text: string, aiChat: AiChat;
let prompt: string = '';
if (config.prompt) {
prompt = config.prompt;
} }
const reName = RegExp(this.name, "i"); const reName = RegExp(this.name, "i");
const reKigoType = RegExp(kigo + type, "i"); const reKigoType = RegExp(KIGO + exist.type, "i");
const question = msg.extractedText const question = msg.extractedText
.replace(reName, '') .replace(reName, '')
.replace(reKigoType, '') .replace(reKigoType, '')
.trim(); .trim();
switch (exist.type) {
let text:string, aiChat:AiChat; case TYPE_GEMINI:
let prompt:string = '';
if (config.prompt) {
prompt = config.prompt;
}
switch(type) {
case 'gemini':
// geminiの場合、APIキーが必須 // geminiの場合、APIキーが必須
if (!config.geminiProApiKey) { if (!config.geminiProApiKey) {
msg.reply(serifs.aichat.nothing(type)); msg.reply(serifs.aichat.nothing(exist.type));
return false; return false;
} }
const base64Image:Base64Image|null = await this.note2base64Image(msg.id); const base64Image: Base64Image | null = await this.note2base64Image(msg.id);
aiChat = { aiChat = {
question: question, question: question,
prompt: prompt, prompt: prompt,
api: GEMINI_15_PRO_API, api: GEMINI_15_PRO_API,
key: config.geminiProApiKey key: config.geminiProApiKey,
history: exist.history
}; };
if (msg.includes([kigo + 'gemini-flash'])) { if (msg.includes([KIGO + 'gemini-flash']) || (exist.api && exist.api === GEMINI_15_FLASH_API)) {
aiChat.api = GEMINI_15_FLASH_API; aiChat.api = GEMINI_15_FLASH_API;
} }
text = await this.genTextByGemini(aiChat, base64Image); text = await this.genTextByGemini(aiChat, base64Image);
break; break;
case 'PLaMo': case TYPE_PLAMO:
// PLaMoの場合、APIキーが必須 // PLaMoの場合、APIキーが必須
if (!config.pLaMoApiKey) { if (!config.pLaMoApiKey) {
msg.reply(serifs.aichat.nothing(type)); msg.reply(serifs.aichat.nothing(exist.type));
return false; return false;
} }
aiChat = { aiChat = {
question: question, question: msg.text,
prompt: prompt, prompt: prompt,
api: PLAMO_API, api: PLAMO_API,
key: config.pLaMoApiKey key: config.pLaMoApiKey,
history: exist.history
}; };
text = await this.genTextByPLaMo(aiChat); text = await this.genTextByPLaMo(aiChat);
break; break;
default: default:
msg.reply(serifs.aichat.nothing(type)); msg.reply(serifs.aichat.nothing(exist.type));
return false; return false;
} }
if (text == null) { if (text == null) {
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(type)); msg.reply(serifs.aichat.error(exist.type));
return false; return false;
} }
this.log('Replying...'); this.log('Replying...');
msg.reply(serifs.aichat.post(text, type)); msg.reply(serifs.aichat.post(text, exist.type)).then(reply => {
// 履歴に登録
if (!exist.history) {
exist.history = [];
}
exist.history.push({ role: 'user', content: question });
exist.history.push({ role: 'model', content: text });
// 履歴が10件を超えた場合、古いものを削除
if (exist.history.length > 10) {
exist.history.shift();
}
this.aichatHist.insertOne({
postId: reply.id,
createdAt: Date.now(),
type: exist.type,
api: aiChat.api,
history: exist.history
});
return { this.log('Subscribe&Set Timer...');
reaction: 'like'
}; // メンションを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);
}
} }
} }