Create an ai chatbot selection page for logged in users that has the same header and footer from the index.html
53fe820 verified | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>FurryChat.ai</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet"> | |
| <script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.globe.min.js"></script> | |
| <style> | |
| .chat-container { | |
| height: calc(100vh - 180px); | |
| overflow-y: auto; | |
| scroll-behavior: smooth; | |
| } | |
| .message { | |
| max-width: 80%; | |
| animation: fadeIn 0.3s ease-in; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* Enhanced Audio Player Styles */ | |
| .message-audio { | |
| margin-top: 10px; | |
| padding: 8px; | |
| background: #f8fafc; | |
| border-radius: 8px; | |
| border: 1px solid #e5e7eb; | |
| } | |
| .message-audio audio { | |
| width: 100%; | |
| height: 32px; | |
| border-radius: 4px; | |
| } | |
| .audio-indicator { | |
| display: inline-block; | |
| background: #3b82f6; | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| margin-left: 8px; | |
| font-weight: 600; | |
| } | |
| .message-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 4px; | |
| } | |
| .message-sender { | |
| font-weight: 600; | |
| color: #374151; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .message-timestamp { | |
| font-size: 11px; | |
| color: #6b7280; | |
| } | |
| .bot-message { | |
| background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); | |
| border-left: 4px solid #3b82f6; | |
| } | |
| .user-message { | |
| background: linear-gradient(135deg, #e0f2fe 0%, #dbeafe 100%); | |
| border-right: 4px solid #1d4ed8; | |
| } | |
| .chat-welcome { | |
| background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); | |
| border: 1px solid #a7f3d0; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| } | |
| .typing-indicator { | |
| display: inline-flex; | |
| align-items: center; | |
| } | |
| .typing-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background-color: #9CA3AF; | |
| margin: 0 2px; | |
| animation: typing 1.4s infinite ease-in-out; | |
| } | |
| .typing-dot:nth-child(2) { | |
| animation-delay: 0.2s; | |
| } | |
| .typing-dot:nth-child(3) { | |
| animation-delay: 0.4s; | |
| } | |
| @keyframes typing { | |
| 0%, 60%, 100% { transform: translateY(0); } | |
| 30% { transform: translateY(-5px); } | |
| } | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #c5c5c5; | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #a8a8a8; | |
| } | |
| /* 3D Model Viewer Styles - UPDATED */ | |
| #three-container { | |
| width: 100%; | |
| height: 100%; | |
| background-color: #f8fafc; | |
| border-radius: 10px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #three-canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .animation-controls { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 10px; | |
| z-index: 10; | |
| background: rgba(255, 255, 255, 0.9); | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
| } | |
| .animation-btn { | |
| background: #3b82f6; | |
| color: white; | |
| border: none; | |
| padding: 6px 12px; | |
| border-radius: 15px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .animation-btn:hover { | |
| background: #2563eb; | |
| transform: translateY(-2px); | |
| } | |
| .animation-btn.active { | |
| background: #1d4ed8; | |
| box-shadow: 0 0 10px rgba(59, 130, 246, 0.5); | |
| } | |
| .loading-spinner { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| border: 3px solid #f3f3f3; | |
| border-top: 3px solid #3b82f6; | |
| border-radius: 50%; | |
| width: 30px; | |
| height: 30px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: translate(-50%, -50%) rotate(0deg); } | |
| 100% { transform: translate(-50%, -50%) rotate(360deg); } | |
| } | |
| /* Notification styles */ | |
| .notification { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| padding: 15px 25px; | |
| border-radius: 10px; | |
| box-shadow: 0 5px 15px rgba(0,0,0,0.2); | |
| z-index: 1000; | |
| animation: slideIn 0.3s ease-out; | |
| color: white; | |
| font-weight: 600; | |
| max-width: 400px; | |
| word-wrap: break-word; | |
| } | |
| .notification-success { background: #2ecc71; } | |
| .notification-error { background: #e74c3c; } | |
| .notification-warning { background: #f39c12; } | |
| .notification-info { background: #3498db; } | |
| .notification-content { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| @keyframes slideIn { | |
| from { transform: translateX(100%); opacity: 0; } | |
| to { transform: translateX(0); opacity: 1; } | |
| } | |
| @keyframes slideOut { | |
| from { transform: translateX(0); opacity: 1; } | |
| to { transform: translateX(100%); opacity: 0; } | |
| } | |
| /* Status indicators */ | |
| .status-dot { | |
| display: inline-block; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| margin-right: 6px; | |
| } | |
| .status-online { background-color: #10b981; } | |
| .status-offline { background-color: #ef4444; } | |
| /* Animations link button */ | |
| .animations-link { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| background: linear-gradient(135deg, #7209b7 0%, #3a0ca3 100%); | |
| color: white; | |
| padding: 12px 20px; | |
| border-radius: 30px; | |
| box-shadow: 0 4px 15px rgba(114, 9, 183, 0.3); | |
| z-index: 100; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| text-decoration: none; | |
| font-weight: 600; | |
| transition: all 0.3s ease; | |
| } | |
| .animations-link:hover { | |
| transform: translateY(-3px); | |
| box-shadow: 0 6px 20px rgba(114, 9, 183, 0.4); | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50"> | |
| <!-- Animations Link Button --> | |
| <a href="/animations" class="animations-link" target="_blank"> | |
| <i data-feather="play-circle"></i> | |
| Test Animations | |
| </a> | |
| <!-- Hero Section with Vanta.js Background --> | |
| <div id="vanta-bg" class="absolute top-0 left-0 w-full h-screen z-0"></div> | |
| <div class="relative z-10"> | |
| <!-- Navigation --> | |
| <nav class="bg-white bg-opacity-90 backdrop-blur-sm shadow-sm py-4 px-6"> | |
| <div class="container mx-auto flex justify-between items-center"> | |
| <div class="flex items-center space-x-2"> | |
| <img src="https://huggingface.co/spaces/Andrewbluebird/furry-tales-digital-whiskers/resolve/main/images/FurryChatLogo complete.png" alt="FurryChat Logo" class="w-[150px] h-[75px] rounded-lg"> | |
| </div> | |
| <div class="hidden md:flex space-x-8"> | |
| <a href="index.html" class="text-blue-600 font-medium transition-colors">Chat</a> | |
| <a href="characters.html" class="text-gray-600 hover:text-blue-600 transition-colors">Characters</a> | |
| <a href="#" class="text-gray-600 hover:text-blue-600 transition-colors">Create</a> | |
| <a href="#" class="text-gray-600 hover:text-blue-600 transition-colors">Marketplace</a> | |
| <a href="/animations" class="text-gray-600 hover:text-purple-600 transition-colors">Animations</a> | |
| </div> | |
| <button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"> | |
| Get Started | |
| </button> | |
| </div> | |
| </nav> | |
| <!-- Main Content --> | |
| <div class="container mx-auto px-4 py-8"> | |
| <!-- Combined Avatar and Chat Window --> | |
| <div class="max-w-4xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden" data-aos="fade-up"> | |
| <!-- Combined Header with Avatar and Chat Info --> | |
| <div class="bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white"> | |
| <div class="flex items-center space-x-4"> | |
| <!-- 3D Avatar Container --> | |
| <div class="w-16 h-16 bg-white bg-opacity-20 rounded-lg overflow-hidden"> | |
| <div id="avatar-canvas" style="height: 64px; width: 64px;"></div> | |
| </div> | |
| <div> | |
| <h2 class="text-xl font-semibold">Sancia</h2> | |
| <p class="text-blue-100 text-sm">The Free-to-try furry AI</p> | |
| <div id="tts-status" class="text-xs opacity-80 mt-1"> | |
| <span class="status-dot status-online"></span> | |
| <span>TTS: Initializing...</span> | |
| </div> | |
| <div id="animation-status" class="text-xs opacity-80 mt-1"> | |
| <span class="status-dot status-online"></span> | |
| <span>Animation: Loading...</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Combined Chat Container --> | |
| <div class="flex" style="height: 500px;"> | |
| <!-- 3D Avatar Side Panel (Now with Three.js) --> | |
| <div class="w-1/3 bg-gray-100 border-r border-gray-200"> | |
| <!-- Three.js Container - Full panel --> | |
| <div id="three-container" class="h-full"> | |
| <canvas id="three-canvas"></canvas> | |
| <div class="loading-spinner" id="model-loading"></div> | |
| <div class="animation-controls"> | |
| <button id="idle-animation-btn" class="animation-btn active" title="Idle Animation"> | |
| <i data-feather="moon"></i> Idle | |
| </button> | |
| <button id="talking-animation-btn" class="animation-btn" title="Talking Animation"> | |
| <i data-feather="message-circle"></i> Talk | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat Container --> | |
| <div class="w-2/3 flex flex-col"> | |
| <div class="chat-container p-4 bg-gray-50 flex-1" id="chat-messages"> | |
| <!-- Welcome message area --> | |
| <div id="welcome-container"></div> | |
| <!-- Sample conversation starters --> | |
| <div class="grid grid-cols-1 gap-2 mb-4 mt-4"> | |
| <button class="suggestion-btn bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg p-3 text-sm transition-colors" data-message="What can you do?"> | |
| <i data-feather="help-circle" class="w-4 h-4 inline mr-1"></i> What can you do? | |
| </button> | |
| <button class="suggestion-btn bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg p-3 text-sm transition-colors" data-message="Tell me about you"> | |
| <i data-feather="smile" class="w-4 h-4 inline mr-1"></i> Tell me about yourself | |
| </button> | |
| <button class="suggestion-btn bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg p-3 text-sm transition-colors" data-message="How does AI Furry work?"> | |
| <i data-feather="code" class="w-4 h-4 inline mr-1"></i> How does AI Furry work? | |
| </button> | |
| <button class="suggestion-btn bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg p-3 text-sm transition-colors" data-message="What animations do you have?"> | |
| <i data-feather="play-circle" class="w-4 h-4 inline mr-1"></i> What animations do you have? | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Input Area --> | |
| <div class="border-t border-gray-200 p-4 bg-white"> | |
| <div class="flex space-x-2"> | |
| <input type="text" id="user-input" placeholder="Type your message here..." | |
| class="flex-1 border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"> | |
| <button id="send-btn" class="bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700 transition-colors"> | |
| <i data-feather="send" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Footer --> | |
| <footer class="bg-gray-800 text-white mt-16 py-8"> | |
| <div class="container mx-auto px-4"> | |
| <div class="grid grid-cols-1 md:grid-cols-4 gap-8"> | |
| <div> | |
| <div class="flex items-center space-x-2"> | |
| <img src="https://huggingface.co/spaces/Andrewbluebird/furry-tales-digital-whiskers/resolve/main/images/FurryChatLogo complete.png" alt="FurryChat Logo" class="w-[150px] h-[75px] rounded-lg"> | |
| </div> | |
| <p class="text-gray-400">Perfect AI Furry roleplay haven powered by advanced artificial intelligence.</p> | |
| </div> | |
| <div> | |
| <h4 class="font-semibold mb-4">Product</h4> | |
| <ul class="space-y-2 text-gray-400"> | |
| <li><a href="#" class="hover:text-white transition-colors">Features</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">Pricing</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">API</a></li> | |
| <li><a href="/animations" class="hover:text-white transition-colors">Animations</a></li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h4 class="font-semibold mb-4">Community</h4> | |
| <ul class="space-y-2 text-gray-400"> | |
| <li><a href="#" class="hover:text-white transition-colors">Furry AI Gallery</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">Furry AI Avatar Marketplace</a></li> | |
| <li><a href="https://discord.gg/NKaAqn5q" class="hover:text-white transition-colors">Discord server</a></li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h4 class="font-semibold mb-4">Company</h4> | |
| <ul class="space-y-2 text-gray-400"> | |
| <li><a href="#" class="hover:text-white transition-colors">About</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">Blog</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">Careers</a></li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h4 class="font-semibold mb-4">Support</h4> | |
| <ul class="space-y-2 text-gray-400"> | |
| <li><a href="#" class="hover:text-white transition-colors">Help Center</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">Contact</a></li> | |
| <li><a href="#" class="hover:text-white transition-colors">Privacy Policy</a></li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="border-t border-gray-700 mt-8 pt-6 text-center text-gray-400"> | |
| <p>Β© 2025 MultiForce Entertainment Technologies LTD. All rights reserved.</p> | |
| </div> | |
| </div> | |
| </footer> | |
| </div> | |
| <!-- Import Three.js and related libraries --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.164.1/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.164.1/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| // Initialize animations and icons | |
| AOS.init({ | |
| duration: 800, | |
| easing: 'ease-in-out', | |
| once: true | |
| }); | |
| feather.replace(); | |
| // Initialize Vanta.js background with blue color | |
| VANTA.GLOBE({ | |
| el: "#vanta-bg", | |
| mouseControls: true, | |
| touchControls: true, | |
| gyroControls: false, | |
| minHeight: 200.00, | |
| minWidth: 200.00, | |
| scale: 1.00, | |
| scaleMobile: 1.00, | |
| color: 0x3b82f6, | |
| backgroundColor: 0xf8fafc, | |
| size: 0.8 | |
| }); | |
| // Import Three.js modules | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // Three.js variables | |
| let scene, camera, renderer, controls; | |
| let mixer, currentAction, idleAction, talkAction; | |
| let clock = new THREE.Clock(); | |
| let currentAnimation = 'idle'; | |
| let isModelLoaded = false; | |
| // DOM elements | |
| const threeContainer = document.getElementById('three-container'); | |
| const threeCanvas = document.getElementById('three-canvas'); | |
| const modelLoading = document.getElementById('model-loading'); | |
| const animationStatus = document.getElementById('animation-status'); | |
| const idleBtn = document.getElementById('idle-animation-btn'); | |
| const talkingBtn = document.getElementById('talking-animation-btn'); | |
| // Initialize Three.js scene | |
| function initThreeJS() { | |
| // Create scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xf8fafc); | |
| // Create camera | |
| const width = threeContainer.clientWidth; | |
| const height = threeContainer.clientHeight; | |
| camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); | |
| camera.position.set(2, 1.5, 3); | |
| // Create renderer | |
| renderer = new THREE.WebGLRenderer({ | |
| canvas: threeCanvas, | |
| antialias: true, | |
| alpha: true | |
| }); | |
| renderer.setSize(width, height); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| // Add orbit controls | |
| controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.minDistance = 1; | |
| controls.maxDistance = 10; | |
| controls.maxPolarAngle = Math.PI / 2; | |
| controls.enablePan = false; | |
| // Add lighting | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(3, 5, 2); | |
| directionalLight.castShadow = true; | |
| scene.add(directionalLight); | |
| // Add a subtle rim light | |
| const rimLight = new THREE.DirectionalLight(0x4cc9f0, 0.3); | |
| rimLight.position.set(-3, 2, -2); | |
| scene.add(rimLight); | |
| // Start animation loop | |
| animate(); | |
| // Load the 3D model | |
| loadModel(); | |
| } | |
| // Replace the loadModel function in index.html with this improved version: | |
| async function loadModel() { | |
| try { | |
| modelLoading.style.display = 'block'; | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Loading...`; | |
| // Get model info | |
| const modelInfo = await fetch('/api/model'); | |
| const data = await modelInfo.json(); | |
| if (data.status === 'success') { | |
| const modelUrl = '/api/model/file'; | |
| const loader = new GLTFLoader(); | |
| loader.load( | |
| modelUrl, | |
| (gltf) => { | |
| console.log('β Model loaded successfully:', gltf); | |
| // Clear any existing model | |
| scene.children.filter(child => child.userData.isCharacter).forEach(child => scene.remove(child)); | |
| const model = gltf.scene; | |
| model.userData.isCharacter = true; | |
| // ============================================ | |
| // FIX 1: Check and fix material issues | |
| // ============================================ | |
| model.traverse((node) => { | |
| if (node.isMesh) { | |
| console.log(`π Processing mesh: ${node.name}`); | |
| // FIX: Disable frustum culling for problematic meshes | |
| node.frustumCulled = false; | |
| // FIX: Enable shadows | |
| node.castShadow = true; | |
| node.receiveShadow = true; | |
| // FIX: Check and fix material issues | |
| if (node.material) { | |
| // Ensure material is properly configured | |
| if (Array.isArray(node.material)) { | |
| // Multiple materials | |
| node.material.forEach(mat => fixMaterial(mat)); | |
| } else { | |
| // Single material | |
| fixMaterial(node.material); | |
| } | |
| // DEBUG: Log material info | |
| console.log(` Material: ${node.material.name || 'unnamed'}`); | |
| console.log(` Side: ${node.material.side}`); | |
| console.log(` Transparent: ${node.material.transparent}`); | |
| } | |
| // FIX: Check geometry bounds | |
| if (node.geometry) { | |
| node.geometry.computeBoundingSphere(); | |
| node.geometry.computeBoundingBox(); | |
| const bounds = node.geometry.boundingBox; | |
| if (bounds) { | |
| const size = new THREE.Vector3(); | |
| bounds.getSize(size); | |
| console.log(` Mesh bounds size:`, size); | |
| // If mesh is extremely large or small, scale it | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| if (maxDim > 100 || maxDim < 0.01) { | |
| console.log(` β Abnormal mesh size detected: ${maxDim}`); | |
| // Apply corrective scaling | |
| const scaleFactor = 1 / maxDim; | |
| node.scale.multiplyScalar(scaleFactor); | |
| console.log(` Applied scale factor: ${scaleFactor}`); | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // ============================================ | |
| // FIX 2: Position and scale the model properly | |
| // ============================================ | |
| model.position.set(0, 0, 0); | |
| // Calculate bounding box for proper scaling | |
| const bbox = new THREE.Box3().setFromObject(model); | |
| const center = bbox.getCenter(new THREE.Vector3()); | |
| const size = bbox.getSize(new THREE.Vector3()); | |
| console.log(`π Model bounds:`); | |
| console.log(` Center:`, center); | |
| console.log(` Size:`, size); | |
| // Normalize scale if needed | |
| const maxSize = Math.max(size.x, size.y, size.z); | |
| if (maxSize > 5 || maxSize < 0.5) { | |
| const scaleFactor = 2 / maxSize; | |
| model.scale.setScalar(scaleFactor); | |
| console.log(`π§ Applied uniform scale: ${scaleFactor}`); | |
| // Recalculate bounds after scaling | |
| bbox.setFromObject(model); | |
| bbox.getSize(size); | |
| bbox.getCenter(center); | |
| } | |
| // Center the model | |
| model.position.sub(center); | |
| scene.add(model); | |
| // ============================================ | |
| // FIX 3: Set up animation mixer with better control | |
| // ============================================ | |
| if (gltf.animations && gltf.animations.length > 0) { | |
| mixer = new THREE.AnimationMixer(model); | |
| console.log(`π¬ Found ${gltf.animations.length} animation(s):`); | |
| gltf.animations.forEach((anim, index) => { | |
| console.log(` ${index}: ${anim.name} (${anim.duration.toFixed(2)}s)`); | |
| }); | |
| // Play the first animation | |
| if (gltf.animations[0]) { | |
| currentAction = mixer.clipAction(gltf.animations[0]); | |
| // FIX: Set proper animation parameters | |
| currentAction.clampWhenFinished = true; | |
| currentAction.loop = THREE.LoopRepeat; | |
| currentAction.timeScale = 1.0; | |
| currentAction.play(); | |
| // Check if this is a talking animation | |
| const modelName = data.model.filename.toLowerCase(); | |
| if (modelName.includes('talking') || modelName.includes('talk') || | |
| modelName.includes('laughing') || modelName.includes('speak')) { | |
| currentAnimation = 'talking'; | |
| updateAnimationButtons(); | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: ${gltf.animations[0].name || 'Talking'}`; | |
| } else { | |
| currentAnimation = 'idle'; | |
| updateAnimationButtons(); | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: ${gltf.animations[0].name || 'Idle'}`; | |
| } | |
| } | |
| } else { | |
| console.log('β No animations found in model'); | |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Static Model`; | |
| } | |
| isModelLoaded = true; | |
| modelLoading.style.display = 'none'; | |
| // ============================================ | |
| // FIX 4: Adjust camera for better viewing | |
| // ============================================ | |
| // Recalculate bounds after all transformations | |
| const finalBbox = new THREE.Box3().setFromObject(model); | |
| const finalCenter = finalBbox.getCenter(new THREE.Vector3()); | |
| const finalSize = finalBbox.getSize(new THREE.Vector3()); | |
| controls.target.copy(finalCenter); | |
| controls.update(); | |
| // Adjust camera distance based on final model size | |
| const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z); | |
| const fov = camera.fov * (Math.PI / 180); | |
| let cameraDistance = maxDim / (2 * Math.tan(fov / 2)); | |
| // Add some padding (30%) | |
| cameraDistance *= 1.3; | |
| // Ensure minimum distance | |
| cameraDistance = Math.max(cameraDistance, 2); | |
| // Position camera | |
| camera.position.copy(finalCenter); | |
| camera.position.x += cameraDistance; | |
| camera.position.y += cameraDistance * 0.3; // Slight elevation | |
| camera.position.z += cameraDistance; | |
| camera.lookAt(finalCenter); | |
| console.log(`π· Camera positioned at distance: ${cameraDistance.toFixed(2)}`); | |
| // ============================================ | |
| // FIX 5: Add debug visualization if needed | |
| // ============================================ | |
| if (DEBUG_MODE) { | |
| addDebugHelpers(model, finalBbox); | |
| } | |
| }, | |
| (xhr) => { | |
| const percent = (xhr.loaded / xhr.total * 100).toFixed(2); | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Loading ${percent}%`; | |
| }, | |
| (error) => { | |
| console.error('β Error loading model:', error); | |
| modelLoading.style.display = 'none'; | |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Failed to load`; | |
| // Show detailed error to user | |
| showNotification(`Model load error: ${error.message}. Using fallback.`, 'error'); | |
| createFallbackModel(); | |
| } | |
| ); | |
| } else { | |
| throw new Error(data.error || 'Failed to get model info'); | |
| } | |
| } catch (error) { | |
| console.error('β Error loading model:', error); | |
| modelLoading.style.display = 'none'; | |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Error`; | |
| showNotification(`Failed to load model: ${error.message}`, 'error'); | |
| createFallbackModel(); | |
| } | |
| } | |
| // Add this helper function to fix materials | |
| function fixMaterial(material) { | |
| if (!material) return; | |
| // FIX: Ensure double-sided rendering | |
| material.side = THREE.DoubleSide; | |
| // FIX: Disable depth test issues | |
| material.depthTest = true; | |
| material.depthWrite = true; | |
| // FIX: Ensure proper transparency handling | |
| if (material.transparent) { | |
| material.transparent = true; | |
| material.alphaTest = 0.5; | |
| material.opacity = 1.0; | |
| } | |
| // FIX: Ensure proper shading | |
| material.flatShading = false; | |
| material.needsUpdate = true; | |
| } | |
| // Add this for debugging (optional) | |
| function addDebugHelpers(model, bbox) { | |
| // Add bounding box helper | |
| const boxHelper = new THREE.Box3Helper(bbox, 0xffff00); | |
| scene.add(boxHelper); | |
| // Add axes helper | |
| const axesHelper = new THREE.AxesHelper(5); | |
| scene.add(axesHelper); | |
| // Add grid helper | |
| const gridHelper = new THREE.GridHelper(10, 10); | |
| scene.add(gridHelper); | |
| console.log('π§ Debug helpers added'); | |
| } | |
| // Add this constant at the top of your Three.js module | |
| const DEBUG_MODE = false; // Set to true for debugging, false for production | |
| // Create a fallback cube if model fails to load | |
| function createFallbackModel() { | |
| const geometry = new THREE.BoxGeometry(1, 2, 1); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0x3b82f6, | |
| roughness: 0.3, | |
| metalness: 0.5 | |
| }); | |
| const cube = new THREE.Mesh(geometry, material); | |
| cube.castShadow = true; | |
| cube.receiveShadow = true; | |
| cube.userData.isCharacter = true; | |
| scene.add(cube); | |
| isModelLoaded = true; | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Fallback Model`; | |
| } | |
| // Update animation button states | |
| function updateAnimationButtons() { | |
| idleBtn.classList.toggle('active', currentAnimation === 'idle'); | |
| talkingBtn.classList.toggle('active', currentAnimation === 'talking'); | |
| } | |
| // Switch animation | |
| async function switchAnimation(animationName) { | |
| if (currentAnimation === animationName) return; | |
| try { | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Switching...`; | |
| // Call backend to switch model | |
| const response = await fetch(`/api/animation/switch/${animationName}`, { | |
| method: 'POST' | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| currentAnimation = animationName; | |
| updateAnimationButtons(); | |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: ${animationName.charAt(0).toUpperCase() + animationName.slice(1)}`; | |
| // Reload the model | |
| await loadModel(); | |
| // If this is a talking animation and TTS is playing, trigger animation | |
| if (animationName === 'talking') { | |
| // We'll handle this in Phase 1 - syncing with TTS | |
| } | |
| } else { | |
| throw new Error(data.error || 'Failed to switch animation'); | |
| } | |
| } catch (error) { | |
| console.error('β Error switching animation:', error); | |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Switch Failed`; | |
| showNotification(`Failed to switch animation: ${error.message}`, 'error'); | |
| } | |
| } | |
| // Animation loop | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = clock.getDelta(); | |
| // Update animation mixer | |
| if (mixer) { | |
| mixer.update(delta); | |
| } | |
| // Update controls | |
| controls.update(); | |
| // Render the scene | |
| renderer.render(scene, camera); | |
| } | |
| // Handle window resize | |
| function onWindowResize() { | |
| const width = threeContainer.clientWidth; | |
| const height = threeContainer.clientHeight; | |
| camera.aspect = width / height; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(width, height); | |
| } | |
| // Initialize Three.js when DOM is loaded | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // Initialize Three.js | |
| initThreeJS(); | |
| // Set up animation button events | |
| idleBtn.addEventListener('click', () => switchAnimation('idle')); | |
| talkingBtn.addEventListener('click', () => switchAnimation('talking')); | |
| // Handle window resize | |
| window.addEventListener('resize', onWindowResize); | |
| }); | |
| // ============================================================ | |
| // EXISTING CHAT FUNCTIONALITY (Keep all of this) | |
| // ============================================================ | |
| // TTS State - TTS is always enabled by default | |
| let ttsEnabled = true; | |
| let ttsSupported = false; | |
| let selectedVoice = 'default'; // Always use default voice | |
| // Utility functions | |
| function showNotification(message, type = 'info') { | |
| const existingNotification = document.querySelector('.notification'); | |
| if (existingNotification) { | |
| existingNotification.remove(); | |
| } | |
| const notification = document.createElement('div'); | |
| notification.className = `notification notification-${type}`; | |
| const featherIcon = type === 'success' ? 'check-circle' : | |
| type === 'error' ? 'alert-circle' : | |
| type === 'warning' ? 'alert-triangle' : 'info'; | |
| notification.innerHTML = ` | |
| <div class="notification-content"> | |
| <i data-feather="${featherIcon}"></i> | |
| <span>${message}</span> | |
| </div> | |
| `; | |
| document.body.appendChild(notification); | |
| feather.replace(); | |
| const duration = type === 'error' ? 5000 : 4000; | |
| setTimeout(() => { | |
| notification.style.animation = 'slideOut 0.3s ease-out forwards'; | |
| setTimeout(() => notification.remove(), 300); | |
| }, duration); | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // SIMPLIFIED TTS INITIALIZATION - No user controls needed | |
| async function initializeTTS() { | |
| try { | |
| console.log('π Initializing TTS System...'); | |
| // Check TTS health | |
| const ttsResponse = await fetch('/api/tts/info'); | |
| const ttsInfo = await ttsResponse.json(); | |
| console.log(`π TTS Info:`, ttsInfo); | |
| ttsSupported = ttsInfo.tts_available === true; | |
| if (ttsSupported) { | |
| console.log(`β TTS: ${ttsInfo.tts_engine} - Ready`); | |
| updateTTSStatus('ready'); | |
| } else { | |
| console.error('β TTS not available on server'); | |
| updateTTSStatus('unavailable'); | |
| } | |
| } catch (error) { | |
| console.error('β TTS initialization failed:', error); | |
| updateTTSStatus('error'); | |
| } | |
| // ALWAYS load welcome message (even if TTS fails) | |
| await loadWelcomeMessage(); | |
| } | |
| // Update TTS status display (only in header now) | |
| function updateTTSStatus(status) { | |
| const statusElement = document.getElementById('tts-status'); | |
| if (status === 'ready') { | |
| statusElement.innerHTML = `<span class="status-dot status-online"></span>TTS: Ready`; | |
| } else if (status === 'unavailable') { | |
| statusElement.innerHTML = `<span class="status-dot status-offline"></span>TTS: Unavailable`; | |
| } else if (status === 'error') { | |
| statusElement.innerHTML = `<span class="status-dot status-offline"></span>TTS: Error`; | |
| } else { | |
| statusElement.innerHTML = `<span class="status-dot status-offline"></span>TTS: ${status}`; | |
| } | |
| } | |
| // RELIABLE welcome message loading | |
| async function loadWelcomeMessage() { | |
| try { | |
| console.log('π₯ Loading welcome message...'); | |
| // Give backend a moment to be ready | |
| await new Promise(resolve => setTimeout(resolve, 1000)); | |
| const response = await fetch('/api/welcome'); | |
| const data = await response.json(); | |
| if (data.status === 'success' && data.message) { | |
| const welcomeContainer = document.getElementById('welcome-container'); | |
| const hasAudio = data.has_audio && data.audio_url; | |
| console.log(`π― Welcome loaded. Has audio: ${hasAudio}`); | |
| // Create welcome message | |
| const welcomeHTML = ` | |
| <div class="chat-welcome" data-aos="fade-right"> | |
| <div class="message-header"> | |
| <div class="message-sender"> | |
| <i data-feather="smile" class="w-4 h-4"></i> | |
| Sancia | |
| ${hasAudio ? '<span class="audio-indicator"><i data-feather="volume-2" class="w-3 h-3"></i> TTS</span>' : ''} | |
| </div> | |
| <div class="message-timestamp">Just now</div> | |
| </div> | |
| <div class="message-content">${data.message.replace(/\n/g, '<br>')}</div> | |
| ${hasAudio ? ` | |
| <div class="message-audio"> | |
| <audio controls preload="auto"> | |
| <source src="${data.audio_url}" type="audio/wav"> | |
| Your browser does not support audio playback. | |
| </audio> | |
| <div class="text-xs text-gray-500 mt-1 flex items-center"> | |
| <i data-feather="headphones" class="w-3 h-3 mr-1"></i> | |
| Click play to hear the welcome message | |
| </div> | |
| </div> | |
| ` : ''} | |
| </div> | |
| `; | |
| welcomeContainer.innerHTML = welcomeHTML; | |
| feather.replace(); | |
| // Auto-switch to talking animation when welcome audio plays | |
| const audioElement = welcomeContainer.querySelector('audio'); | |
| if (audioElement && currentAnimation !== 'talking') { | |
| audioElement.addEventListener('play', async () => { | |
| console.log('π΅ Welcome audio playing, switching to talking animation'); | |
| await switchAnimation('talking'); | |
| }); | |
| audioElement.addEventListener('ended', async () => { | |
| console.log('π΅ Welcome audio ended, switching back to idle'); | |
| await switchAnimation('idle'); | |
| }); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('β Failed to load welcome:', error); | |
| // Fallback welcome message | |
| document.getElementById('welcome-container').innerHTML = ` | |
| <div class="chat-welcome"> | |
| <div class="message-header"> | |
| <div class="message-sender"> | |
| <i data-feather="smile" class="w-4 h-4"></i> | |
| Sancia | |
| </div> | |
| <div class="message-timestamp">Just now</div> | |
| </div> | |
| <div class="message-content"> | |
| (Seems something wrong just happended, please visit the page later) | |
| </div> | |
| </div> | |
| `; | |
| feather.replace(); | |
| } | |
| } | |
| // Chat functionality | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const chatMessages = document.getElementById('chat-messages'); | |
| const userInput = document.getElementById('user-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| // Initialize TTS (auto-enabled) | |
| initializeTTS(); | |
| // Event Listeners | |
| sendBtn.addEventListener('click', sendMessage); | |
| userInput.addEventListener('keypress', function(e) { | |
| if (e.key === 'Enter') { | |
| sendMessage(); | |
| } | |
| }); | |
| // Suggestion buttons | |
| document.querySelectorAll('.suggestion-btn').forEach(button => { | |
| button.addEventListener('click', function() { | |
| userInput.value = this.getAttribute('data-message'); | |
| sendMessage(); | |
| }); | |
| }); | |
| async function sendMessage() { | |
| const message = userInput.value.trim(); | |
| if (!message) return; | |
| // Add user message | |
| addUserMessage(message); | |
| userInput.value = ''; | |
| sendBtn.disabled = true; | |
| try { | |
| console.log('π€ Sending to AI...'); | |
| // Switch to talking animation while waiting for response | |
| if (currentAnimation !== 'talking') { | |
| await switchAnimation('talking'); | |
| } | |
| const response = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| message: message, | |
| voice: selectedVoice // Always use default | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| // Add bot response with audio player | |
| addBotMessage(data.response, data.audio_url); | |
| } else { | |
| addBotMessage(`Error: ${data.error || 'Unknown error'}`, null); | |
| // Switch back to idle on error | |
| await switchAnimation('idle'); | |
| } | |
| } catch (error) { | |
| console.error('β Network error:', error); | |
| addBotMessage(`Network error: ${error.message}`, null); | |
| // Switch back to idle on error | |
| await switchAnimation('idle'); | |
| } finally { | |
| sendBtn.disabled = false; | |
| userInput.focus(); | |
| } | |
| } | |
| function addUserMessage(text) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'message user-message rounded-lg p-4 shadow-sm mb-4 ml-auto'; | |
| const timestamp = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); | |
| messageDiv.innerHTML = ` | |
| <div class="message-header"> | |
| <div class="message-sender"> | |
| <i data-feather="user" class="w-4 h-4"></i> | |
| You | |
| </div> | |
| <div class="message-timestamp">${timestamp}</div> | |
| </div> | |
| <div class="message-content">${escapeHtml(text)}</div> | |
| `; | |
| chatMessages.appendChild(messageDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| feather.replace(); | |
| } | |
| function addBotMessage(text, audioUrl) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'message bot-message rounded-lg p-4 shadow-sm mb-4 mr-auto'; | |
| const timestamp = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); | |
| let audioHTML = ''; | |
| if (audioUrl) { | |
| audioHTML = ` | |
| <div class="message-audio"> | |
| <audio controls> | |
| <source src="${audioUrl}" type="audio/wav"> | |
| Your browser does not support audio playback. | |
| </audio> | |
| <div class="text-xs text-gray-500 mt-1 flex items-center"> | |
| <i data-feather="headphones" class="w-3 h-3 mr-1"></i> | |
| Click play to hear the response | |
| </div> | |
| </div> | |
| `; | |
| } | |
| messageDiv.innerHTML = ` | |
| <div class="message-header"> | |
| <div class="message-sender"> | |
| <i data-feather="smile" class="w-4 h-4"></i> | |
| Sancia | |
| ${audioUrl ? '<span class="audio-indicator"><i data-feather="volume-2" class="w-3 h-3"></i> TTS</span>' : ''} | |
| </div> | |
| <div class="message-timestamp">${timestamp}</div> | |
| </div> | |
| <div class="message-content">${escapeHtml(text)}</div> | |
| ${audioHTML} | |
| `; | |
| chatMessages.appendChild(messageDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| feather.replace(); | |
| // Handle TTS audio playback with animation | |
| if (ttsEnabled && audioUrl) { | |
| setTimeout(() => { | |
| const audioElement = messageDiv.querySelector('audio'); | |
| if (audioElement) { | |
| // Switch to talking animation when audio plays | |
| audioElement.addEventListener('play', async () => { | |
| console.log('π΅ Chat TTS playing, ensuring talking animation'); | |
| if (currentAnimation !== 'talking') { | |
| await switchAnimation('talking'); | |
| } | |
| }); | |
| // Switch back to idle when audio ends | |
| audioElement.addEventListener('ended', async () => { | |
| console.log('π΅ Chat TTS ended, switching back to idle'); | |
| // Wait a moment before switching back | |
| setTimeout(async () => { | |
| await switchAnimation('idle'); | |
| }, 500); | |
| }); | |
| // Auto-play if TTS is enabled | |
| audioElement.play().catch(e => { | |
| console.log('Auto-play prevented:', e); | |
| }); | |
| } | |
| }, 500); | |
| } | |
| } | |
| console.log('π Chat system ready with auto-TTS and animation sync'); | |
| }); | |
| </script> | |
| </body> | |
| </html> |