【週末アプリ作成】3DアバターのAIチャットアプリ v4(完成版) AIパートナー降臨にどうぞ
いくつかの機能を追加しました。
これで完成版とします。
主な機能
🎭 VRMアバター機能
VRMモデルの読み込み(URLまたはローカルファイル)
3Dモデルの表示・回転・カメラ操作
表情切り替え(通常・笑顔・怒り・悲しみ・驚き・リラックス)
自動まばたき
視線追従(正面へ視線固定)
リップシンク ★機能拡張
💬 チャット機能
テキスト入力・送信
ストリーミング表示(リアルタイム1文字ずつ表示)
会話履歴の保存・復元(最大50ターン/100メッセージ)★機能拡張
会話履歴のクリア
会話のエクスポート(テキストファイル)
メッセージ内容による自動表情変化
資料読み込み(テキストファイル、PDF) ★新規実装
📖資料読み込み機能 ★新規追加
テキストファイル (.txt, .md等) の読み込み
PDFファイルの読み込み
読み込んだ内容を会話のコンテキストに追加
🤖 LLM API連携
OpenAI API対応
カスタムAPI対応(OpenAI互換)★新規追加
llama.cpp
Kobold.cpp
LM Studio
Text Generation WebUI
システムプロンプト設定
温度(Temperature)調整
現在日時の自動取得・送信(メッセージ送信時毎)
🔍 検索機能
インターネット検索(Tavily API連携)
検索結果の表示
検索結果のコンテキスト統合
有効/無効の切り替え
🔊 TTS(音声合成)機能 ★新規追加
外部TTSサーバー連携(Style-Bert-VITS2-APIのみ)
リアルタイム音声再生(句読点で区切って順次再生)
メッセージ量に応じた自動間引き
音声再生中のリップシンク連動
話速調整
音声モデル指定
有効/無効の切り替え
🎨 UI/UX機能
背景画像のアップロード・設定
背景クリア
カメラリセット
モデル回転
ストリーミング中インジケーター(緑の点滅)★新規追加
システムメッセージ表示
⚙️ 設定管理
API設定(キー、エンドポイント、モード)
APIサーバーIP/ホスト名・ポート設定
システムプロンプト編集
検索API設定
TTS設定(ホスト・ポート・モデル・速度)★新規追加
全設定のローカルストレージ保存
設定の永続化(リロード時も設定、会話の維持)
🎯 技術的特徴
Three.js + @pixiv/three-vrm による3Dレンダリング
Server-Sent Events (SSE) によるストリーミング
LocalStorage による状態管理
モジュール式JavaScript(ES6+)
非同期処理の適切な管理
エラーハンドリング

操作方法
セットアップ方法や主な操作方法は過去の投稿を参照ください
変更履歴
V3 カスタムAPI対応(OpenAI互換)、TTS(音声合成)機能を追加
V3.1 一部モーション追加
V3.2 ストリーミング表示(リアルタイム1文字ずつ表示)
V3.3 リップシンク対応、音声の間引き再生機能を追加
V4 資料読み込み機能、会話履歴ターン数の拡張(1~50ターン)
1ファイル、プログラム1000行前後を条件として、
可能な限りの機能を実装してみましたが、なんとか完成しました!
1つのhtmlファイルをクリックするだけで、
本格的な(?)3Dアバター付きのチャットアプリが実行できるのは
時代かなぁと思います。
このチャットアプリの特徴としては、
・システムプロンプト設定
・インターネット検索機能
・カスタムAPI対応(OpenAI互換)
が実装されていることかなと思います。
AIと特定のシチュエーションを楽しみたい時は、
・ユーザーの発想力(シチュエーションの具体化)と、
・AIの理解力とモデル内の学習データ
を元にした会話になります。
これをインターネットで「シチュエーションに応じた、シーンやストーリー、そしてセリフ」などを検索して取得することで、
AIの振る舞いは劇的に変わります!
そして、それを補完するのがカスタムAPI(ローカルLLMなど)です。
OpenAI-APIでは対応しきれない部分をローカルLLMで補完することができるようになります。
また、v4で資料読み込み機能を追加したので、AIの情報理解を更に広めたり、深めたりすることもできるようになりました。
TTS(音声合成)については、メッセージのストリーミング表示と間引き再生によって、再生しても煩わしくなくなったので実装しました。
メッセージをすべて受信してからの表示ではなく、
受信時に1文字ずつ表示することで、ネットワークやLLM性能でメッセージ生成に時間がかかっても待たされる感覚を減らすことができました。
このストリーミング表示をすることで、メッセージ表示の途中でアバターの表情を変えることにも対応できました。
VRMモデルは、Vroid Studioで作成したものを変換するほかにも、
ニコニコ立体での配布されているものや、BOOTHで衣装やアクセサリを含めて販売もされているので、お好みのキャラクターでチャットをお楽しみください。
実行の手軽さの割には多くの機能を盛り込んでみたので、
AIパートナーを模した3Dアバターとの会話などに、是非お試しください。
ライセンスと免責事項
このツールは個人使用を目的としています。noteの利用規約に違反しないよう、常識の範囲内でご使用ください。
本ツールの使用によって生じたいかなる損害についても、製作者は責任を負いません。
使用ライブラリ
このアプリケーションは以下のオープンソースライブラリを使用しています:
- Three.js (v0.167.0) および関連モジュール - MIT License
Copyright © 2010-2024 three.js authors
https://threejs.org/
- @pixiv/three-vrm (v3) - MIT License
Copyright © 2020 pixiv Inc.
https://github.com/pixiv/three-vrm
これらのライブラリは MIT License の下で提供されています。
コード
このコードをテキストエディタなどへコピペして拡張子を「.html」で保存し、Webブラウザで開いてください
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VRMモデル チャットアプリ v4.0</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
color: #fff;
display: flex;
flex-direction: column;
height: 100vh;
}
.container {
display: flex;
height: calc(100vh - 60px);
}
#canvas-container {
flex: 1;
position: relative;
}
.chat-container {
width: 400px;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.message {
margin-bottom: 15px;
padding: 10px 15px;
border-radius: 18px;
max-width: 80%;
word-break: break-word;
}
.user-message {
background-color: #1e88e5;
margin-left: auto;
border-bottom-right-radius: 4px;
}
.bot-message {
background-color: #424242;
margin-right: auto;
border-bottom-left-radius: 4px;
}
.reference-message {
background-color: #4a148c;
margin-right: auto;
border-left: 4px solid #9c27b0;
font-size: 12px;
max-width: 90%;
}
.streaming-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: #4CAF50;
border-radius: 50%;
margin-left: 5px;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.search-indicator {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 8px 12px;
margin: 10px 0;
border-radius: 4px;
font-size: 13px;
color: #856404;
}
.search-results {
background: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 8px 12px;
margin: 10px 0;
border-radius: 4px;
font-size: 12px;
color: #0d47a1;
max-width: 90%;
}
.search-results a {
color: #1976d2;
text-decoration: none;
word-break: break-all;
}
.search-results a:hover {
text-decoration: underline;
}
.tts-indicator {
background: #e1f5fe;
border-left: 4px solid #03a9f4;
padding: 8px 12px;
margin: 10px 0;
border-radius: 4px;
font-size: 13px;
color: #01579b;
}
.chat-input {
display: flex;
padding: 15px;
background-color: rgba(0, 0, 0, 0.3);
}
.chat-controls {
display: flex;
gap: 10px;
padding: 10px 15px;
background-color: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.chat-controls button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
font-size: 12px;
}
.chat-controls button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
#message-input {
flex: 1;
padding: 12px;
border: none;
border-radius: 25px;
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 16px;
outline: none;
}
#message-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
#send-button {
border: none;
background-color: #ff4081;
color: white;
padding: 0 20px;
border-radius: 25px;
margin-left: 10px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s;
}
#send-button:hover {
background-color: #f50057;
}
#send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: rgba(0, 0, 0, 0.5);
height: 60px;
box-sizing: border-box;
}
.header h1 {
margin: 0;
font-size: 20px;
}
.settings {
display: flex;
align-items: center;
}
.settings button {
margin-left: 10px;
padding: 8px 12px;
border: none;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
}
.settings button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
text-align: center;
background-color: rgba(0, 0, 0, 0.7);
padding: 20px;
border-radius: 10px;
}
.expression-buttons {
position: absolute;
bottom: 20px;
left: 20px;
display: flex;
gap: 10px;
}
.expression-button {
background-color: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 5px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.expression-button:hover {
background-color: rgba(0, 0, 0, 0.8);
}
#model-url {
width: 300px;
padding: 8px;
margin-right: 10px;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 4px;
}
#model-controls {
position: absolute;
top: 20px;
left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: rgba(0, 0, 0, 0.6);
padding: 15px;
border-radius: 8px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: #fff;
border-radius: 8px;
width: 600px;
max-width: 90%;
max-height: 80vh;
overflow-y: auto;
color: #333;
padding: 20px;
}
.modal-content h2 {
margin-top: 0;
}
.modal-content h3 {
margin-top: 20px;
margin-bottom: 10px;
color: #555;
font-size: 16px;
border-bottom: 2px solid #667eea;
padding-bottom: 5px;
}
.modal-content label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.modal-content input, .modal-content textarea {
width: 100%;
padding: 8px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.modal-content textarea {
min-height: 80px;
resize: vertical;
}
.checkbox-container {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.checkbox-container input[type="checkbox"] {
width: auto;
margin-right: 10px;
margin-bottom: 0;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.modal-buttons button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-save {
background-color: #4CAF50;
color: white;
}
.btn-cancel {
background-color: #f44336;
color: white;
}
</style>
</head>
<body>
<div class="header">
<h1>VRMモデル チャットアプリ v4.0</h1>
<div class="settings">
<button id="settings-button">⚙️ API設定</button>
</div>
</div>
<div class="container">
<div id="canvas-container">
<div id="model-controls">
<div>
<input type="text" id="model-url" placeholder="VRMモデルのURLを入力">
<button id="load-model-button">URLからロード</button>
</div>
<div>
<input type="file" id="model-file" accept=".vrm" style="display: none;">
<button id="file-upload-button">ファイルを選択</button>
<span id="file-name" style="margin-left: 10px; font-size: 12px; color: rgba(255,255,255,0.7);">ファイルが選択されていません</span>
</div>
<div>
<input type="file" id="background-file" accept="image/*" style="display: none;">
<button id="background-upload-button">背景画像を選択</button>
<button id="clear-background-button">背景クリア</button>
</div>
<div>
<button id="reset-camera">カメラリセット</button>
<button id="rotate-model">モデル回転</button>
</div>
</div>
<div class="loading" id="loading-screen" style="display: none;">VRMモデル読み込み中...<br>しばらくお待ちください</div>
<div class="expression-buttons">
<button class="expression-button" data-expression="neutral">通常</button>
<button class="expression-button" data-expression="happy">笑顔</button>
<button class="expression-button" data-expression="angry">怒り</button>
<button class="expression-button" data-expression="sad">悲しみ</button>
<button class="expression-button" data-expression="surprised">驚き</button>
<!-- 必要に応じてコメント解除 -->
<!-- <button class="expression-button" id="motion-toggle">...</button> -->
</div>
</div>
<div class="chat-container">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-controls">
<button id="clear-chat">会話クリア</button>
<button id="export-chat">会話エクスポート</button>
<button id="import-reference">📄 資料読み込み</button>
</div>
<div class="chat-input">
<input type="text" id="message-input" placeholder="メッセージを入力...">
<button id="send-button">送信</button>
</div>
</div>
</div>
<input type="file" id="reference-file" accept=".txt,.md,.pdf" style="display: none;">
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.167.0/examples/jsm/",
"@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3/lib/three-vrm.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
// PDF.jsの設定
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
function debugLog(message) {
console.log(`[Debug] ${message}`);
}
let currentVrm = null;
let scene, camera, renderer;
let clock = new THREE.Clock();
let controls;
let nextBlinkTime = 0;
let lipSyncInterval = null;
let isProcessing = false;
let isSpeaking = false;
let bodyMotionMode = 0;
let currentBotMessageElement = null;
const motionSettings = {
small: { speed: 8.0, amount: 0.03 },
large: { speed: 18.0, amount: 0.02 }
};
let apiKey = localStorage.getItem('api-key') || '';
let apiEndpoint = localStorage.getItem('api-endpoint') || 'https://api.openai.com/v1/chat/completions';
let apiMode = localStorage.getItem('api-mode') || 'openai';
let systemPrompt = localStorage.getItem('system-prompt') || 'あなたはフレンドリーなAIアシスタントです。簡潔で親しみやすい返答をしてください。';
let temperature = parseFloat(localStorage.getItem('temperature')) || 0.7;
let savedModelUrl = localStorage.getItem('model-url') || '';
let savedBackground = localStorage.getItem('background-image') || '';
let maxConversationTurns = parseInt(localStorage.getItem('max-conversation-turns')) || 10;
let tavilyApiKey = localStorage.getItem('tavily-api-key') || '';
let searchEnabled = localStorage.getItem('search-enabled') === 'true';
let ttsEnabled = localStorage.getItem('tts-enabled') === 'true';
let ttsHost = localStorage.getItem('tts-host') || 'localhost';
let ttsPort = localStorage.getItem('tts-port') || '5000';
let ttsModel = localStorage.getItem('tts-model') || '';
let ttsSpeed = parseFloat(localStorage.getItem('tts-speed')) || 1.0;
let conversationHistory = JSON.parse(localStorage.getItem('conversation-history') || '[]');
function getCurrentDateTime() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const dayOfWeek = ['日', '月', '火', '水', '木', '金', '土'][now.getDay()];
return `${year}年${month}月${day}日(${dayOfWeek}) ${hours}:${minutes}`;
}
// デバッグ: 起動時に時刻を表示
console.log('[時刻テスト] 現在時刻:', getCurrentDateTime());
function splitTextForTTS(text, maxLength = 100) {
const segments = [];
const sentences = text.split(/([。!?、])/);
let currentSegment = '';
for (let i = 0; i < sentences.length; i++) {
const part = sentences[i];
if (currentSegment.length + part.length <= maxLength) {
currentSegment += part;
} else {
if (currentSegment) {
segments.push(currentSegment.trim());
}
currentSegment = part;
}
}
if (currentSegment.trim()) {
segments.push(currentSegment.trim());
}
return segments.filter(s => s.length > 0);
}
async function speakWithTTS(text) {
if (!ttsEnabled || !ttsModel || isSpeaking) return;
const segments = splitTextForTTS(text);
if (segments.length === 0) return;
isSpeaking = true;
if (!lipSyncInterval) {
startLipSync();
}
try {
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const ttsUrl = `http://${ttsHost}:${ttsPort}/voice`;
const params = new URLSearchParams({
text: segment,
model_name: ttsModel,
length: ttsSpeed
});
const response = await fetch(`${ttsUrl}?${params}`);
if (!response.ok) {
throw new Error(`TTS APIエラー: ${response.status}`);
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
await new Promise((resolve, reject) => {
audio.onended = () => {
URL.revokeObjectURL(audioUrl);
resolve();
};
audio.onerror = reject;
audio.play().catch(reject);
});
}
} catch (error) {
console.error('TTS error:', error);
} finally {
isSpeaking = false;
}
}
function speakWithTTSAsync(text) {
speakWithTTS(text).catch(err => console.error('TTS async error:', err));
}
// PDF読み込み関数
async function readPdfFile(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({data: arrayBuffer}).promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map(item => item.str).join(' ');
fullText += pageText + '\n';
}
return fullText;
}
// テキストファイル読み込み関数
async function readTextFile(file) {
return await file.text();
}
// 資料読み込みハンドラー
async function handleReferenceImport() {
const fileInput = document.getElementById('reference-file');
const file = fileInput.files[0];
if (!file) return;
try {
let content = '';
if (file.name.endsWith('.pdf')) {
addSystemMessage('📄 PDFファイルを読み込んでいます...');
content = await readPdfFile(file);
} else {
content = await readTextFile(file);
}
// 参考資料としてメッセージ追加
const referenceMessage = {
role: 'system',
content: `【参考資料: ${file.name}】\n${content}`
};
conversationHistory.push(referenceMessage);
saveConversation();
// UI表示
addReferenceMessage(`📄 資料「${file.name}」を読み込みました(${Math.floor(content.length / 1000)}KB)`);
// ファイル入力をリセット
fileInput.value = '';
} catch (error) {
console.error('資料読み込みエラー:', error);
addSystemMessage(`資料の読み込みに失敗しました: ${error.message}`);
}
}
function init() {
debugLog("初期化開始");
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(30, container.clientWidth / container.clientHeight, 0.1, 20.0);
camera.position.set(0, 0.8, 1.3);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x000000, 0);
container.appendChild(renderer.domElement);
const light = new THREE.DirectionalLight(0xffffff, 1.5);
light.position.set(1, 1, 1).normalize();
scene.add(light);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
scene.add(ambientLight);
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.screenSpacePanning = true;
controls.update();
window.addEventListener('resize', () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
restoreConversation();
if (savedBackground) {
setBackgroundImage(savedBackground);
}
if (savedModelUrl) {
document.getElementById('model-url').value = savedModelUrl;
loadVRM(savedModelUrl);
}
setupEventListeners();
animate();
}
function setupEventListeners() {
document.getElementById('send-button').addEventListener('click', handleSendMessage);
document.getElementById('message-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !isProcessing) handleSendMessage();
});
document.getElementById('load-model-button').addEventListener('click', () => {
const modelUrl = document.getElementById('model-url').value;
if (modelUrl) {
localStorage.setItem('model-url', modelUrl);
loadVRM(modelUrl);
}
});
document.getElementById('file-upload-button').addEventListener('click', () => {
document.getElementById('model-file').click();
});
document.getElementById('model-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
document.getElementById('file-name').textContent = file.name;
loadVRMFromFile(file);
});
document.getElementById('reset-camera').addEventListener('click', () => {
camera.position.set(0, 1.3, 1.5);
controls.target.set(0, 1, 0);
controls.update();
});
document.getElementById('rotate-model').addEventListener('click', () => {
if (currentVrm) currentVrm.scene.rotation.y += Math.PI / 2;
});
document.getElementById('background-upload-button').addEventListener('click', () => {
document.getElementById('background-file').click();
});
document.getElementById('background-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(event) {
const imageData = event.target.result;
setBackgroundImage(imageData);
localStorage.setItem('background-image', imageData);
};
reader.readAsDataURL(file);
});
document.getElementById('clear-background-button').addEventListener('click', () => {
document.body.style.background = 'linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d)';
localStorage.removeItem('background-image');
});
document.querySelectorAll('.expression-button').forEach(button => {
button.addEventListener('click', () => {
if (button.dataset.expression) {
setExpression(button.dataset.expression);
}
});
});
/* 必要に応じてコメント解除
document.getElementById('motion-toggle').addEventListener('click', () => {
bodyMotionMode = (bodyMotionMode + 1) % 3;
const button = document.getElementById('motion-toggle');
switch(bodyMotionMode) {
case 0:
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
button.textContent = '...';
break;
case 1:
button.style.backgroundColor = 'rgba(255, 165, 0, 0.8)';
button.textContent = '..';
break;
case 2:
button.style.backgroundColor = 'rgba(255, 64, 129, 0.8)';
button.textContent = '....';
break;
}
});
*/
document.getElementById('settings-button').addEventListener('click', showApiSettings);
document.getElementById('clear-chat').addEventListener('click', clearConversation);
document.getElementById('export-chat').addEventListener('click', exportConversation);
// 資料読み込みボタン
document.getElementById('import-reference').addEventListener('click', () => {
document.getElementById('reference-file').click();
});
document.getElementById('reference-file').addEventListener('change', handleReferenceImport);
}
function loadVRM(url) {
const loader = new GLTFLoader();
loader.register((parser) => new VRMLoaderPlugin(parser));
document.getElementById('loading-screen').style.display = 'block';
if (currentVrm) scene.remove(currentVrm.scene);
loader.load(url, (gltf) => {
const vrm = gltf.userData.vrm;
if (vrm) {
currentVrm = vrm;
VRMUtils.removeUnnecessaryVertices(gltf.scene);
VRMUtils.removeUnnecessaryJoints(gltf.scene);
scene.add(vrm.scene);
vrm.scene.position.set(0, -0.5, 0);
setRelaxedPose(vrm);
document.getElementById('loading-screen').style.display = 'none';
setExpression('neutral');
} else {
scene.add(gltf.scene);
document.getElementById('loading-screen').style.display = 'none';
}
}, (progress) => {
const percentage = Math.floor(100.0 * (progress.loaded / progress.total)) || 0;
document.getElementById('loading-screen').textContent = `VRMモデル読み込み中... ${percentage}%`;
}, (error) => {
console.error('Error loading VRM:', error);
document.getElementById('loading-screen').style.display = 'none';
});
}
function loadVRMFromFile(file) {
const loader = new GLTFLoader();
loader.register((parser) => new VRMLoaderPlugin(parser));
document.getElementById('loading-screen').style.display = 'block';
if (currentVrm) scene.remove(currentVrm.scene);
const reader = new FileReader();
reader.onload = function(e) {
loader.parse(e.target.result, '', (gltf) => {
const vrm = gltf.userData.vrm;
if (vrm) {
currentVrm = vrm;
VRMUtils.removeUnnecessaryVertices(gltf.scene);
VRMUtils.removeUnnecessaryJoints(gltf.scene);
scene.add(vrm.scene);
vrm.scene.position.set(0, -0.5, 0);
setRelaxedPose(vrm);
document.getElementById('loading-screen').style.display = 'none';
setExpression('neutral');
}
}, (error) => {
console.error('Error parsing VRM:', error);
document.getElementById('loading-screen').style.display = 'none';
});
};
reader.readAsArrayBuffer(file);
}
function setRelaxedPose(vrm) {
try {
const leftUpperArm = vrm.humanoid.getNormalizedBoneNode('leftUpperArm');
const rightUpperArm = vrm.humanoid.getNormalizedBoneNode('rightUpperArm');
if (leftUpperArm) leftUpperArm.rotation.z = -1.2;
if (rightUpperArm) rightUpperArm.rotation.z = 1.2;
} catch (error) {
debugLog(`ポーズ設定エラー: ${error.message}`);
}
}
function setExpression(expressionName) {
if (!currentVrm || !currentVrm.expressionManager) return;
try {
const expressionManager = currentVrm.expressionManager;
['happy', 'angry', 'sad', 'relaxed', 'surprised', 'neutral'].forEach(name => {
expressionManager.setValue(name, 0.0);
});
switch(expressionName) {
case 'happy': expressionManager.setValue('happy', 1.0); break;
case 'angry': expressionManager.setValue('angry', 1.0); break;
case 'sad': expressionManager.setValue('sad', 1.0); break;
case 'surprised': expressionManager.setValue('surprised', 1.0); break;
case 'relaxed': expressionManager.setValue('relaxed', 1.0); break;
default: expressionManager.setValue('neutral', 1.0);
}
} catch (error) {
console.warn('表情設定エラー:', error);
}
}
function startLipSync() {
if (!currentVrm || !currentVrm.expressionManager) return;
stopLipSync();
const lipShapes = ['aa', 'ih', 'ou', 'ee', 'oh'];
const expressionManager = currentVrm.expressionManager;
lipSyncInterval = setInterval(() => {
lipShapes.forEach(shape => expressionManager.setValue(shape, 0));
const randomShape = lipShapes[Math.floor(Math.random() * lipShapes.length)];
expressionManager.setValue(randomShape, 0.8);
}, 150);
}
function stopLipSync() {
if (lipSyncInterval) {
clearInterval(lipSyncInterval);
lipSyncInterval = null;
}
if (currentVrm && currentVrm.expressionManager) {
const lipShapes = ['aa', 'ih', 'ou', 'ee', 'oh'];
lipShapes.forEach(shape => currentVrm.expressionManager.setValue(shape, 0));
}
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
if (currentVrm) {
const spine = currentVrm.humanoid.getNormalizedBoneNode('spine');
if (spine) {
spine.rotation.z = Math.sin(elapsed * 1.5) * 0.01;
}
if (bodyMotionMode > 0) {
const settings = bodyMotionMode === 1 ? motionSettings.small : motionSettings.large;
currentVrm.scene.position.y = -0.5 + Math.sin(elapsed * settings.speed) * settings.amount;
} else {
currentVrm.scene.position.y = -0.5;
}
if (currentVrm.lookAt) {
const lookAtTarget = new THREE.Vector3();
camera.getWorldPosition(lookAtTarget);
currentVrm.lookAt.lookAt(lookAtTarget);
}
if (currentVrm.expressionManager && elapsed > nextBlinkTime) {
performBlink();
nextBlinkTime = elapsed + 3 + Math.random() * 3;
}
currentVrm.update(delta);
}
renderer.render(scene, camera);
}
function performBlink() {
if (!currentVrm || !currentVrm.expressionManager) return;
const expressionManager = currentVrm.expressionManager;
setTimeout(() => expressionManager.setValue('blink', 1.0), 0);
setTimeout(() => expressionManager.setValue('blink', 0.0), 100);
}
async function handleSendMessage() {
if (isProcessing) return;
const messageInput = document.getElementById('message-input');
const message = messageInput.value.trim();
if (message === '') return;
addMessage(message, 'user');
conversationHistory.push({ role: 'user', content: message });
saveConversation();
messageInput.value = '';
isProcessing = true;
document.getElementById('send-button').disabled = true;
try {
await getAiResponse(message);
} catch (error) {
console.error('Error:', error);
addMessage('エラーが発生しました: ' + error.message, 'bot');
} finally {
isProcessing = false;
document.getElementById('send-button').disabled = false;
}
}
function showApiSettings() {
const settingsHtml = `
<div class="modal-content">
<h2>API設定</h2>
<h3>🤖 LLM API 設定</h3>
<label>APIモード:</label>
<select id="api-mode-select" style="width: 100%; padding: 8px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 4px;">
<option value="openai" ${apiMode === 'openai' ? 'selected' : ''}>OpenAI API</option>
<option value="custom" ${apiMode === 'custom' ? 'selected' : ''}>カスタムAPI (OpenAI互換)</option>
</select>
<div id="openai-settings" style="display: ${apiMode === 'openai' ? 'block' : 'none'};">
<label>OpenAI APIキー:</label>
<input type="password" id="api-key" value="${apiKey}" placeholder="sk-...">
</div>
<div id="custom-settings" style="display: ${apiMode === 'custom' ? 'block' : 'none'};">
<label>APIキー (任意):</label>
<input type="password" id="custom-api-key" value="${apiKey}" placeholder="必要な場合のみ入力">
<label>サーバーIP/ホスト名:</label>
<input type="text" id="server-host" value="${localStorage.getItem('server-host') || 'localhost'}" placeholder="例: localhost, 192.168.1.100">
<label>ポート番号:</label>
<input type="text" id="server-port" value="${localStorage.getItem('server-port') || '5001'}" placeholder="例: 5001, 1234">
<label>エンドポイントパス:</label>
<input type="text" id="endpoint-path" value="${localStorage.getItem('endpoint-path') || '/v1/chat/completions'}" placeholder="例: /v1/chat/completions">
<div style="background: #e3f2fd; padding: 10px; margin: 10px 0; border-radius: 4px; font-size: 13px; color: #0d47a1;">
<strong>📝 対応API例:</strong><br>
• LM Studio: localhost:1234<br>
• Kobold.cpp: localhost:5001<br>
• Text Generation WebUI: localhost:5000<br>
• llama.cpp: localhost:8080<br>
• その他OpenAI互換API
</div>
</div>
<label>システムプロンプト:</label>
<textarea id="system-prompt">${systemPrompt}</textarea>
<label>温度 (Temperature): <span id="temp-value">${temperature}</span></label>
<input type="range" id="temperature-slider" min="0" max="2" step="0.1" value="${temperature}" style="width: 100%;">
<label>会話履歴保存数: <span id="turns-value">${maxConversationTurns}</span> ターン (${maxConversationTurns * 2} メッセージ)</label>
<input type="range" id="turns-slider" min="1" max="50" step="1" value="${maxConversationTurns}" style="width: 100%;">
<h3>🔍 検索機能 設定</h3>
<div class="checkbox-container">
<input type="checkbox" id="search-enabled" ${searchEnabled ? 'checked' : ''}>
<label for="search-enabled" style="margin: 0;">インターネット検索機能を有効にする</label>
</div>
<label>Tavily APIキー:</label>
<input type="password" id="tavily-api-key" value="${tavilyApiKey}" placeholder="tvly-...">
<h3>🔊 TTS 設定</h3>
<div class="checkbox-container">
<input type="checkbox" id="tts-enabled" ${ttsEnabled ? 'checked' : ''}>
<label for="tts-enabled" style="margin: 0;">TTS機能を有効にする</label>
</div>
<label>TTSサーバー ホスト/IP:</label>
<input type="text" id="tts-host" value="${ttsHost}" placeholder="例: localhost, 192.168.1.100">
<label>TTSサーバー ポート:</label>
<input type="text" id="tts-port" value="${ttsPort}" placeholder="例: 5000">
<label>モデル名:</label>
<input type="text" id="tts-model" value="${ttsModel}" placeholder="例: 【モデル名】">
<label>話速 (Speed): <span id="speed-value">${ttsSpeed}</span></label>
<input type="range" id="speed-slider" min="0.5" max="2.0" step="0.1" value="${ttsSpeed}" style="width: 100%;">
<div class="modal-buttons">
<button class="btn-save" id="save-settings">保存</button>
<button class="btn-cancel" id="cancel-settings">キャンセル</button>
</div>
</div>
`;
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = settingsHtml;
document.body.appendChild(modal);
document.getElementById('api-mode-select').addEventListener('change', (e) => {
const mode = e.target.value;
document.getElementById('openai-settings').style.display = mode === 'openai' ? 'block' : 'none';
document.getElementById('custom-settings').style.display = mode === 'custom' ? 'block' : 'none';
});
document.getElementById('temperature-slider').addEventListener('input', (e) => {
document.getElementById('temp-value').textContent = e.target.value;
});
document.getElementById('turns-slider').addEventListener('input', (e) => {
const turns = e.target.value;
document.getElementById('turns-value').textContent = turns;
const turnsValueSpan = document.getElementById('turns-value').parentElement;
turnsValueSpan.innerHTML = `会話履歴保存数: <span id="turns-value">${turns}</span> ターン (${turns * 2} メッセージ)`;
});
document.getElementById('speed-slider').addEventListener('input', (e) => {
document.getElementById('speed-value').textContent = e.target.value;
});
document.getElementById('save-settings').addEventListener('click', () => {
const selectedMode = document.getElementById('api-mode-select').value;
apiMode = selectedMode;
if (selectedMode === 'openai') {
apiKey = document.getElementById('api-key').value;
apiEndpoint = 'https://api.openai.com/v1/chat/completions';
} else {
apiKey = document.getElementById('custom-api-key').value;
const host = document.getElementById('server-host').value;
const port = document.getElementById('server-port').value;
const path = document.getElementById('endpoint-path').value;
apiEndpoint = `http://${host}:${port}${path}`;
localStorage.setItem('server-host', host);
localStorage.setItem('server-port', port);
localStorage.setItem('endpoint-path', path);
}
systemPrompt = document.getElementById('system-prompt').value;
temperature = parseFloat(document.getElementById('temperature-slider').value);
maxConversationTurns = parseInt(document.getElementById('turns-slider').value);
tavilyApiKey = document.getElementById('tavily-api-key').value;
searchEnabled = document.getElementById('search-enabled').checked;
ttsEnabled = document.getElementById('tts-enabled').checked;
ttsHost = document.getElementById('tts-host').value;
ttsPort = document.getElementById('tts-port').value;
ttsModel = document.getElementById('tts-model').value;
ttsSpeed = parseFloat(document.getElementById('speed-slider').value);
localStorage.setItem('api-key', apiKey);
localStorage.setItem('api-endpoint', apiEndpoint);
localStorage.setItem('api-mode', apiMode);
localStorage.setItem('system-prompt', systemPrompt);
localStorage.setItem('temperature', temperature);
localStorage.setItem('max-conversation-turns', maxConversationTurns);
localStorage.setItem('tavily-api-key', tavilyApiKey);
localStorage.setItem('search-enabled', searchEnabled);
localStorage.setItem('tts-enabled', ttsEnabled);
localStorage.setItem('tts-host', ttsHost);
localStorage.setItem('tts-port', ttsPort);
localStorage.setItem('tts-model', ttsModel);
localStorage.setItem('tts-speed', ttsSpeed);
document.body.removeChild(modal);
});
document.getElementById('cancel-settings').addEventListener('click', () => {
document.body.removeChild(modal);
});
}
async function performSearch(query) {
if (!searchEnabled || !tavilyApiKey) return null;
try {
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_key: tavilyApiKey,
query: query,
search_depth: 'basic',
max_results: 5
})
});
if (!response.ok) {
throw new Error('検索APIエラー: ' + response.status);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Search error:', error);
addSystemMessage('検索に失敗しました: ' + error.message);
return null;
}
}
function addSystemMessage(text) {
const chatMessages = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageDiv.style.cssText = 'background: #fff3cd; border-left: 4px solid #ffc107; padding: 8px 12px; margin: 10px 0; border-radius: 4px; font-size: 13px; color: #856404;';
messageDiv.textContent = text;
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addReferenceMessage(text) {
const chatMessages = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageDiv.className = 'reference-message message';
messageDiv.textContent = text;
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addSearchIndicator(text) {
const chatMessages = document.getElementById('chat-messages');
const indicator = document.createElement('div');
indicator.className = 'search-indicator';
indicator.id = 'searchIndicator';
indicator.textContent = text;
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addTTSIndicator(text) {
const chatMessages = document.getElementById('chat-messages');
const indicator = document.createElement('div');
indicator.className = 'tts-indicator';
indicator.id = 'ttsIndicator';
indicator.textContent = text;
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function removeTTSIndicator() {
const indicator = document.getElementById('ttsIndicator');
if (indicator) indicator.remove();
}
function displaySearchResults(data) {
const indicator = document.getElementById('searchIndicator');
if (indicator) indicator.remove();
if (!data.results || data.results.length === 0) return;
const chatMessages = document.getElementById('chat-messages');
const resultsDiv = document.createElement('div');
resultsDiv.className = 'search-results';
let html = '<strong>📚 検索結果:</strong><br><br>';
data.results.slice(0, 3).forEach((result, index) => {
html += `${index + 1}. <strong>${result.title}</strong><br>`;
html += `<a href="${result.url}" target="_blank">${result.url}</a><br><br>`;
});
resultsDiv.innerHTML = html;
chatMessages.appendChild(resultsDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function createBotMessage() {
const chatMessages = document.getElementById('chat-messages');
const messageElement = document.createElement('div');
messageElement.classList.add('message', 'bot-message');
messageElement.innerHTML = '<span class="streaming-indicator"></span>';
chatMessages.appendChild(messageElement);
chatMessages.scrollTop = chatMessages.scrollHeight;
return messageElement;
}
function updateBotMessage(element, text) {
element.textContent = text;
const chatMessages = document.getElementById('chat-messages');
chatMessages.scrollTop = chatMessages.scrollHeight;
if (text.includes('愛して') || text.includes('大好き') || text.includes('幸せ')) {
setExpression('relaxed');
} else if (text.includes('ありがとう') || text.includes('嬉しい')) {
setExpression('happy');
} else if (text.includes('残念') || text.includes('すみません')) {
setExpression('sad');
} else if (text.includes('注意') || text.includes('警告')) {
setExpression('angry');
} else if (text.includes('驚き') || text.includes('本当') || text.includes('えっ')) {
setExpression('surprised');
}
}
function addMessage(message, sender) {
const chatMessages = document.getElementById('chat-messages');
const messageElement = document.createElement('div');
messageElement.classList.add('message', sender === 'user' ? 'user-message' : 'bot-message');
messageElement.textContent = message;
chatMessages.appendChild(messageElement);
chatMessages.scrollTop = chatMessages.scrollHeight;
if (sender === 'bot') {
if (message.includes('ありがとう') || message.includes('嬉しい')) {
setExpression('happy');
} else if (message.includes('残念') || message.includes('すみません')) {
setExpression('sad');
} else if (message.includes('注意') || message.includes('警告')) {
setExpression('angry');
} else if (message.includes('驚き') || message.includes('本当') || message.includes('えっ')) {
setExpression('surprised');
} else {
setExpression('neutral');
}
}
}
function saveConversation() {
const maxMessages = maxConversationTurns * 2;
if (conversationHistory.length > maxMessages) {
conversationHistory = conversationHistory.slice(-maxMessages);
}
localStorage.setItem('conversation-history', JSON.stringify(conversationHistory));
}
function restoreConversation() {
const chatMessages = document.getElementById('chat-messages');
conversationHistory.forEach(msg => {
if (msg.role === 'system' && msg.content.startsWith('【参考資料:')) {
const fileName = msg.content.match(/【参考資料: (.+?)】/)?.[1] || '資料';
const contentSize = Math.floor(msg.content.length / 1000);
addReferenceMessage(`📄 資料「${fileName}」(${contentSize}KB)`);
} else {
const messageElement = document.createElement('div');
messageElement.classList.add('message', msg.role === 'user' ? 'user-message' : 'bot-message');
messageElement.textContent = msg.content;
chatMessages.appendChild(messageElement);
}
});
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function clearConversation() {
if (confirm('会話履歴をクリアしますか?')) {
conversationHistory = [];
localStorage.removeItem('conversation-history');
document.getElementById('chat-messages').innerHTML = '';
}
}
function exportConversation() {
const text = conversationHistory.map(msg => {
if (msg.role === 'system') return '';
return `${msg.role === 'user' ? 'あなた' : 'AI'}: ${msg.content}`;
}).filter(line => line).join('\n\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `conversation_${new Date().toISOString().slice(0,10)}.txt`;
a.click();
URL.revokeObjectURL(url);
}
function setBackgroundImage(imageData) {
document.body.style.background = `url(${imageData}) center/cover no-repeat`;
}
async function getAiResponse(message) {
const currentTime = getCurrentDateTime();
const searchKeywords = ['検索して', '調べて', '探して', 'search', 'find'];
const shouldSearch = searchEnabled && searchKeywords.some(keyword => message.includes(keyword));
let searchResults = null;
if (shouldSearch) {
addSearchIndicator('🔍 インターネット検索中...');
searchResults = await performSearch(message);
if (searchResults) {
displaySearchResults(searchResults);
}
}
if (apiMode === 'mock') {
startLipSync();
setTimeout(() => {
const responses = ["なるほど、興味深いですね。", "それについては、いろいろな見方がありそうですね。", "とても面白い視点ですね!"];
const response = responses[Math.floor(Math.random() * responses.length)];
stopLipSync();
addMessage(response, 'bot');
conversationHistory.push({ role: 'assistant', content: response });
saveConversation();
}, 1500);
} else if (apiMode === 'openai' || apiMode === 'custom') {
if (!apiKey && apiMode === 'openai') {
addMessage("APIキーが設定されていません。", 'bot');
return;
}
const enhancedSystemPrompt = `${systemPrompt}\n\n【重要】現在の正確な日時: ${currentTime}\n時刻を答える際は、必ずこの情報を使用してください。`;
console.log('[デバッグ] システムプロンプト:', enhancedSystemPrompt);
const messages = [
{ role: 'system', content: enhancedSystemPrompt },
...conversationHistory
];
if (searchResults && searchResults.results) {
const searchContext = searchResults.results.map((result, index) =>
`[検索結果${index + 1}]\nタイトル: ${result.title}\n内容: ${result.content}\nURL: ${result.url}`
).join('\n\n');
messages.push({
role: 'system',
content: `以下は「${message}」に関する最新の検索結果です:\n\n${searchContext}\n\nこれらの情報を参考にして回答してください。`
});
}
try {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: messages,
max_tokens: 500,
temperature: temperature,
stream: true
})
});
if (!response.ok) {
throw new Error(`APIエラー: ${response.status}`);
}
currentBotMessageElement = createBotMessage();
let fullResponse = '';
let ttsBuffer = '';
let isFirstToken = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
continue;
}
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
if (isFirstToken) {
startLipSync();
isFirstToken = false;
}
fullResponse += content;
ttsBuffer += content;
updateBotMessage(currentBotMessageElement, fullResponse);
if (ttsEnabled && /[。!?!?]/.test(ttsBuffer)) {
const sentences = ttsBuffer.split(/([。!?!?])/);
let completeSentence = '';
for (let i = 0; i < sentences.length - 1; i += 2) {
completeSentence += sentences[i] + (sentences[i + 1] || '');
}
if (completeSentence.trim()) {
speakWithTTSAsync(completeSentence.trim());
ttsBuffer = sentences[sentences.length - 1] || '';
}
}
}
} catch (e) {
// JSONパースエラーは無視
}
}
}
}
if (ttsEnabled && ttsBuffer.trim()) {
await speakWithTTS(ttsBuffer.trim());
}
stopLipSync();
if (fullResponse) {
conversationHistory.push({ role: 'assistant', content: fullResponse });
saveConversation();
}
} catch (error) {
stopLipSync();
if (currentBotMessageElement) {
currentBotMessageElement.remove();
}
addMessage("APIエラーが発生しました: " + error.message, 'bot');
console.error('API Error:', error);
}
}
}
window.onload = init;
</script>
</body>
</html>