【JavaScript】LLM全盛期にあえて自作!猫型チャットボット「のんたろ」の頭脳の仕組みを解説する
こんにちは!今回は、昔懐かしのPC-98時代やプレイステーションのどこでもいっしょ井上トロの空気感や「かな入力」の暗号、そしてちょっとニヤリとできるこだわりを詰め込んだ、猫型チャットボット「のんたろ」の頭脳(思考エンジン)の仕組みをご紹介します。
現代ではAPIを叩けば一瞬で高精度な返事が返ってくるLLM全盛の時代ですが、**「あえて全部自分の手でルールの優先順位や記憶の連鎖を組み立てたい!」**というロマンから、Vanilla JS(JavaScript)で独自の応答生成エンジンをスクラッチで実装しました。
今回は、この「のんたろ」の思考の中心である generateResponse 関数のなかで、どうやって「記憶」「学習」「優先度」「連想」をさばいているのか、そのプログラミングのこだわりをコードの構造とともに詳しくお届けします!
1. アプリケーションの概要:「のんたろ」とは?
「のんたろ」は、ユーザーとの会話から単語や文章の記憶を学び、自由気ままに雑談や連想トークを楽しめる猫型のチャットボットです。
フロントエンドのインタラクションと、JavaScriptによる軽量なルールベース+連想ロジックで動いています。
2. 処理のパイプライン設計:思考の「優先順位」が命
チャットボットの肝は、ユーザーの入力に対して「どの処理をどの順番で通すか」というパイプラインの設計です。
のんたろの頭脳では、入力文 (text) を受け取った後、以下のステップで思考を巡らせます。
[ 入力 text ]
↓
0. システムコマンド(「覚えてる?」等)のインターセプト
↓
1. 辞書学習(「AはB」形式の定義登録)
↓
2. 括弧内の単語抽出 & 自動学習
↓
3. 話題生成リクエスト(「何か話して」)のハンドリング
↓
4. 優先度ルール判定(コード上の最優先ルール群)
↓
5. 辞書検索 & 【過去の文章記憶からの連想発火】
↓
6. 未知ワードの自動キャッチ(フォールバック)
この上から順に判定していく「滝(ウォーターフォール)方式」をとることで、システム的な特殊コマンドやユーザーの明示的な学習指示を取りこぼさず、最後に深い辞書・記憶の海へダイブする構造にしています。
3. コードの全貌:generateResponse 関数
実際に動いている応答生成エンジンのコードがこちらです。
// --- 応答生成ロジック ---
function generateResponse(input) {
const text = input ? input.trim() : "";
if (!text) return "何かお話ししてほしいニャ〜!";
// 0. 「覚えてる?」で文章記憶編集ビュー(画面)に遷移
if (text.includes("覚えてる?") || text.includes("おぼえてる?")) {
const count = userMemories ? userMemories.length : 0;
setTimeout(() => {
if (typeof showView === 'function') showView('memory');
}, 600);
return count > 0
? `覚えてるニャ!キミが話してくれた文章の記憶が全部で ${count} 個あるよ。記憶の編集画面を開いてあげるニャ〜!`
: `うーん、まだ何も文章の記憶がないみたいニャ……。何かお話ししてくれたら覚えるニャ!`;
}
// 1. 辞書学習(AはB形式)
if (typeof parseAndLearnDefinition === 'function') {
const learnedDef = parseAndLearnDefinition(text);
if (learnedDef) {
if (learnedDef.forgotten) {
return `みゃ〜!のんたろの頭がいっぱいいっぱいだから、「${learnedDef.forgotten}」の単語を忘れちゃった代わりに、「${learnedDef.word}」をしっかり覚えたニャ!`;
}
if (learnedDef.subLinks && learnedDef.subLinks.length > 0) {
return `なるほど!「${learnedDef.word}」は「${learnedDef.meaning}」で、類義語に「${learnedDef.subLinks.join(', ')}」もあるんだね!バッチリ覚えたニャ〜!`;
}
return `なるほど!「${learnedDef.word}」は「${learnedDef.meaning}」なんだね!しっかり覚えたニャ〜!`;
}
}
// 2. カギ括弧内の単語抽出 & 自動学習
const matchBracket = text.match(/「([^」]+)」/);
if (matchBracket && typeof autoLearnWord === 'function') {
autoLearnWord(matchBracket[1]);
}
// 3. 話題生成リクエストの処理
if (text.includes("何か話して") || text.includes("お話しして") || text.includes("話題") || text.includes("話して")) {
const targetWord = matchBracket ? matchBracket[1] : null;
if (typeof generateTopicFromDict === 'function') {
return generateTopicFromDict(targetWord);
}
}
// 4. 優先度ルール判定(数字が小さい順=昇順で安全にソート)
if (typeof responseRules !== 'undefined' && Array.isArray(responseRules)) {
const sortedRules = [...responseRules].sort((a, b) => (a.priority || 99) - (b.priority || 99));
for (const rule of sortedRules) {
const keywordMatch = rule.keywords && rule.keywords.some(w => text.includes(w));
const customMatch = rule.match && rule.match(text);
if (keywordMatch || customMatch) {
const responses = rule.responses;
return responses[Math.floor(Math.random() * responses.length)];
}
}
}
// 5. 辞書検索 & ユーザー発言の文章記憶からの連想発火
if (typeof dictionary !== 'undefined' && Array.isArray(dictionary)) {
const sortedDict = [...dictionary].sort((a, b) => b.word.length - a.word.length);
let matchedCandidates = [];
// ① 通常の辞書データからの前方・部分一致検索
for (const item of sortedDict) {
const isMainMatch = text.includes(item.word);
const isSubMatch = item.subLinks && item.subLinks.some(link => link && text.includes(link.trim()));
if (isMainMatch || isSubMatch) {
matchedCandidates.push({ item, isSubMatch, isMainMatch });
}
}
// ② 辞書未ヒット、または確率(50%)で過去の「文章記憶(userMemories)」からもキーワード走査
const hasMemories = typeof userMemories !== 'undefined' && userMemories.length > 0;
if ((matchedCandidates.length === 0 && hasMemories) || Math.random() < 0.5) {
for (const mem of userMemories) {
for (const item of sortedDict) {
if (mem.text && mem.text.includes(item.word) && text.includes(item.word)) {
matchedCandidates.push({ item, isSubMatch: false, isMainMatch: true, rememberedText: mem.text });
break;
}
}
if (matchedCandidates.length > 0) break;
}
}
if (matchedCandidates.length > 0) {
// 優先度:1. 過去の文章記憶(rememberedText) > 2. 完全一致 > 3. ランダム候補
let selected = matchedCandidates.find(c => c.rememberedText);
if (!selected) {
selected = matchedCandidates.find(c => c.item.word === text);
}
if (!selected) {
selected = matchedCandidates[Math.floor(Math.random() * matchedCandidates.length)];
}
const { item, rememberedText } = selected;
// A. 過去の文章記憶から引き出された場合(エモい連想演出)
if (rememberedText) {
return `そういえば前に「${rememberedText}」って話してくれたよね!そこに出てきた「${item.word}」のこと思い出しちゃったニャ。${item.meaning || "お気に入りの言葉"}だよね!`;
}
// B. 通常の辞書ワード&サブリンク(類義語)連鎖演出
if (item.subLinks && item.subLinks.length > 0 && item.subLinks[0] !== "") {
const randomSub = item.subLinks[Math.floor(Math.random() * item.subLinks.length)].trim();
const chainedItem = dictionary.find(d => d.word === randomSub);
if (chainedItem) {
return `${item.word}について教えてくれて嬉しいニャ!「${item.word}」って聞いて思い出したんだけど、仲間である「${chainedItem.word}(${chainedItem.meaning})」のことも気になっちゃったニャ!`;
} else {
return `${item.word}について教えてくれて嬉しいニャ!「${item.word}」の話題が出たら、${item.subLinks.join(' とか ')} のことも自然と考えちゃうニャ!`;
}
} else {
return `${item.word}について教えてくれて嬉しいニャ!「${item.word}」かぁ!${item.meaning || "素敵な言葉"}だよね!`;
}
}
}
// 6. どの条件にも引っかからない場合の未知ワード対応
const learnTarget = matchBracket ? matchBracket[1] : text;
if (typeof autoLearnWord === 'function') {
autoLearnWord(learnTarget);
}
return `「${learnTarget}」ってはじめて聞いたニャ!しっかり覚えたから、あとで意味を教えてほしいニャ!`;
}
4. こだわりの実装ポイントを深掘り
① 優先度ルール(Priority)の安全な昇順ソート
ルールベースのチャットボットでありがちな「一般的なルールが先にヒットしてしまい、例外的な特例ルール(優先度高)が無視される」というバグを防ぐため、実行時に配列を priority の昇順で必ずソートしています。
これによって、システムの一番トップ(優先度 1)に仕込んだ、レトロPCのかな入力隠しコマンド(こんい = BYE、とろふ = TROFF、シーキュー = CQCQ)などの特例ルールを確実に最優先でヒットさせることができます。
const sortedRules = [...responseRules].sort((a, b) => (a.priority || 99) - (b.priority || 99));
② 辞書にないなら過去へ!「文章記憶からの連想発火」
のんたろの最もお気に入りの機能がここです。通常の辞書マッチでヒットしなかった場合、または一定の確率(50%)で、ユーザーが過去に語った文章の記憶(userMemories)のなかを走査しに行きます。
「今の入力」と「過去の記憶」の双方に共通するワードを見つけると、ボット側からこう語りかけてくれます。
「そういえば前に『〇〇』って話してくれたよね!そこに出てきた『〇〇』のこと思い出しちゃったニャ。」
ただキーワードに機械的に反応するだけでなく、「ユーザーとの過去の雑談の文脈」をランダムあるいは連動して引きずり出すことで、愛着の湧くインタラクションを実現しています。
③ 類義語(subLinks)を伝っていく連鎖反応
辞書アイテムに紐づく subLinks(類義語や関連ワードの配列)をランダムに辿り、別の辞書アイテムを引っ張ってくることで、話題が勝手に横道にそれていく「連想ゲーム感」を演出しています。
単一のQ&Aで会話を終わらせず、ボット側から話を広げることで、ユーザーがずっと話していて飽きないリズムを作っています。
5. まとめ:レトロゲームのAIのような手触りを目指して
最新のLLMにはない、「あらかじめプログラマーが仕込んだお約束」や「限られた辞書と記憶のなかで必死に言葉をつなぐ泥臭さ」こそが、こうした自作チャットボットの最大の醍醐味だと感じています。
たったひとつの generateResponse 関数の中に、優先度の制御、動的な記憶の検索、そしてちょっとした遊び心を詰め込む作業は、昔のゲームのNPCのAIを組んでいるようで最高に楽しい時間でした。
皆さんもぜひ、自分だけの相棒ボットをJavaScriptでGitHubPagesならば無料で作成できるのでこねこね作ってみてはいかがでしょうか?
作者
GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net
気に入っていただけたら GitHub に ⭐ をいただけると嬉しいです!
