| <!DOCTYPE html> |
| <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); } |
| } |
| |
| |
| .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; |
| } |
| |
| |
| #three-container { |
| width: 100%; |
| height: 100%; |
| background-color: #f8fafc; |
| border-radius: 10px; |
| position: relative; |
| overflow: hidden; |
| } |
| |
| #three-canvas { |
| width: 100%; |
| height: 100%; |
| display: block; |
| } |
| |
| .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 { |
| 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-dot { |
| display: inline-block; |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| margin-right: 6px; |
| } |
| .status-online { background-color: #10b981; } |
| .status-offline { background-color: #ef4444; } |
| |
| |
| .animation-status-talking { |
| background-color: #3b82f6 !important; |
| animation: pulse 1.5s infinite; |
| } |
| |
| @keyframes pulse { |
| 0% { opacity: 1; } |
| 50% { opacity: 0.5; } |
| 100% { opacity: 1; } |
| } |
| |
| |
| .model-switching { |
| opacity: 0.7; |
| transition: opacity 0.3s ease; |
| } |
| |
| |
| .animation-command { |
| display: inline-block; |
| background: #8b5cf6; |
| color: white; |
| padding: 2px 8px; |
| border-radius: 4px; |
| font-size: 10px; |
| margin-left: 8px; |
| font-weight: 600; |
| animation: pulse 2s infinite; |
| } |
| |
| .animation-message { |
| background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%); |
| border-left: 4px solid #8b5cf6; |
| margin: 10px 0; |
| padding: 12px; |
| border-radius: 8px; |
| font-size: 0.9rem; |
| color: #6b21a8; |
| } |
| |
| .animation-message i { |
| color: #8b5cf6; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-50"> |
| |
| <div id="vanta-bg" class="absolute top-0 left-0 w-full h-screen z-0"></div> |
| |
| <div class="relative z-10"> |
| |
| <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="#" class="text-gray-600 hover:text-blue-600 transition-colors">Home</a> |
| <a href="#" class="text-gray-600 hover:text-blue-600 transition-colors">Features</a> |
| <a href="#" class="text-gray-600 hover:text-blue-600 transition-colors">Pricing</a> |
| <a href="#" class="text-gray-600 hover:text-blue-600 transition-colors">About</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> |
|
|
| |
| <div class="container mx-auto px-4 py-8"> |
| |
| <div class="max-w-4xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden" data-aos="fade-up"> |
| |
| <div class="bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white"> |
| <div class="flex items-center space-x-4"> |
| |
| <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 id="animation-commands-hint" class="text-xs opacity-80 mt-1 hidden"> |
| <i data-feather="zap" class="w-3 h-3 inline mr-1"></i> |
| <span>Try: "dance", "show idle", "talk animation"</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="flex" style="height: 500px;"> |
| |
| <div class="w-1/3 bg-gray-100 border-r border-gray-200"> |
| |
| <div id="three-container" class="h-full"> |
| <canvas id="three-canvas"></canvas> |
| <div class="loading-spinner" id="model-loading"></div> |
| </div> |
| </div> |
| |
| |
| <div class="w-2/3 flex flex-col"> |
| <div class="chat-container p-4 bg-gray-50 flex-1" id="chat-messages"> |
| |
| <div id="welcome-container"></div> |
| |
| |
| <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="Can you dance for me?"> |
| <i data-feather="zap" class="w-4 h-4 inline mr-1"></i> Show me a dance animation |
| </button> |
| </div> |
| </div> |
|
|
| |
| <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 or try: 'dance', 'show idle', 'talk animation'..." |
| 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 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> |
| </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> |
|
|
| |
| <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"> |
| |
| AOS.init({ |
| duration: 800, |
| easing: 'ease-in-out', |
| once: true |
| }); |
| feather.replace(); |
| |
| |
| 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 * as THREE from 'three'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| |
| |
| let scene, camera, renderer, controls; |
| let currentModel = null; |
| let mixer = null; |
| let clock = new THREE.Clock(); |
| |
| |
| 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 animationCommandsHint = document.getElementById('animation-commands-hint'); |
| |
| |
| let isTalking = false; |
| let currentAnimationType = 'idle'; |
| let availableAnimations = {}; |
| let currentAnimationName = 'Idle'; |
| |
| |
| const ANIMATION_KEYWORDS = { |
| idle: ['idle', 'rest', 'calm', 'quiet', 'standby', 'default', 'normal'], |
| talking: ['talk', 'speak', 'chat', 'converse', 'discuss', 'say', 'talking', 'speaking'], |
| dance: ['dance', 'dancing', 'boogie', 'move', 'groove', 'party', 'silly'], |
| show: ['show', 'display', 'demonstrate', 'present', 'play', 'start'], |
| switch: ['switch', 'change', 'toggle', 'alternate', 'go to', 'set to'] |
| }; |
| |
| |
| function initThreeJS() { |
| |
| scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0xf8fafc); |
| |
| |
| 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); |
| |
| |
| renderer = new THREE.WebGLRenderer({ |
| canvas: threeCanvas, |
| antialias: true, |
| alpha: true, |
| preserveDrawingBuffer: true |
| }); |
| renderer.setSize(width, height); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| renderer.outputEncoding = THREE.sRGBEncoding; |
| |
| |
| controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.05; |
| controls.minDistance = 1; |
| controls.maxDistance = 10; |
| controls.maxPolarAngle = Math.PI / 1.5; |
| controls.minPolarAngle = Math.PI / 6; |
| controls.enablePan = true; |
| |
| |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); |
| scene.add(ambientLight); |
| |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); |
| directionalLight.position.set(5, 8, 5); |
| directionalLight.castShadow = true; |
| scene.add(directionalLight); |
| |
| const fillLight = new THREE.DirectionalLight(0xffffff, 0.4); |
| fillLight.position.set(0, 3, 5); |
| scene.add(fillLight); |
| |
| |
| animate(); |
| |
| |
| loadAvailableAnimations(); |
| |
| |
| loadModel('idle'); |
| } |
| |
| |
| async function loadAvailableAnimations() { |
| try { |
| console.log('📋 Loading available animations...'); |
| const response = await fetch('/api/animations/status'); |
| const data = await response.json(); |
| |
| if (data.status === 'running') { |
| |
| availableAnimations = { |
| 'idle': 'idle', |
| 'talking': 'talking' |
| }; |
| |
| |
| await checkForDanceAnimation(); |
| |
| console.log('✅ Available animations:', availableAnimations); |
| animationCommandsHint.classList.remove('hidden'); |
| } |
| } catch (error) { |
| console.error('❌ Failed to load animations list:', error); |
| } |
| } |
| |
| |
| async function checkForDanceAnimation() { |
| try { |
| |
| const response = await fetch('/api/animations/model/dance'); |
| if (response.ok) { |
| availableAnimations['dance'] = 'dance'; |
| console.log('✅ Dance animation available'); |
| } |
| } catch (error) { |
| console.log('⚠ Dance animation not available'); |
| } |
| } |
| |
| |
| async function loadModel(animationType, customModelName = null) { |
| try { |
| modelLoading.style.display = 'block'; |
| currentAnimationName = animationType.charAt(0).toUpperCase() + animationType.slice(1); |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Loading ${currentAnimationName}...`; |
| |
| |
| if (currentModel) { |
| scene.remove(currentModel); |
| currentModel = null; |
| } |
| |
| if (mixer) { |
| mixer.stopAllAction(); |
| mixer = null; |
| } |
| |
| |
| let modelUrl; |
| if (customModelName) { |
| |
| modelUrl = `/api/animations/model/${customModelName}`; |
| } else { |
| modelUrl = `/api/animations/model/${animationType}`; |
| } |
| |
| const loader = new GLTFLoader(); |
| |
| loader.load( |
| modelUrl, |
| (gltf) => { |
| console.log(`✅ ${animationType} model loaded successfully`); |
| |
| currentModel = gltf.scene; |
| currentModel.userData.isCharacter = true; |
| currentAnimationType = animationType; |
| |
| |
| currentModel.traverse((node) => { |
| if (node.isMesh) { |
| node.frustumCulled = false; |
| node.castShadow = true; |
| node.receiveShadow = true; |
| |
| if (node.material) { |
| node.material.transparent = false; |
| node.material.opacity = 1.0; |
| node.material.depthWrite = true; |
| node.material.depthTest = true; |
| node.material.needsUpdate = true; |
| } |
| } |
| }); |
| |
| |
| currentModel.position.set(0, -1, 0); |
| |
| |
| const bbox = new THREE.Box3().setFromObject(currentModel); |
| const center = bbox.getCenter(new THREE.Vector3()); |
| const size = bbox.getSize(new THREE.Vector3()); |
| |
| const maxSize = Math.max(size.x, size.y, size.z); |
| let scaleFactor; |
| |
| if (maxSize > 10) { |
| scaleFactor = 2 / maxSize; |
| } else if (maxSize < 1) { |
| scaleFactor = 2 / maxSize; |
| } else { |
| scaleFactor = 1.5 / maxSize; |
| } |
| |
| currentModel.scale.setScalar(scaleFactor); |
| |
| |
| const newBbox = new THREE.Box3().setFromObject(currentModel); |
| const newCenter = newBbox.getCenter(new THREE.Vector3()); |
| currentModel.position.sub(newCenter); |
| currentModel.position.y += 0.5; |
| |
| scene.add(currentModel); |
| |
| |
| if (gltf.animations && gltf.animations.length > 0) { |
| mixer = new THREE.AnimationMixer(currentModel); |
| console.log(`🎬 Found ${gltf.animations.length} animation(s) in ${animationType} model`); |
| |
| |
| if (gltf.animations[0]) { |
| const action = mixer.clipAction(gltf.animations[0]); |
| action.clampWhenFinished = true; |
| action.loop = THREE.LoopRepeat; |
| |
| |
| if (animationType === 'talking') { |
| action.timeScale = 1.2; |
| animationStatus.innerHTML = `<span class="status-dot animation-status-talking"></span>Animation: Talking`; |
| } else if (animationType === 'dance') { |
| action.timeScale = 1.0; |
| animationStatus.innerHTML = `<span class="status-dot" style="background-color: #8b5cf6;"></span>Animation: Dancing`; |
| } else { |
| action.timeScale = 1.0; |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: ${currentAnimationName}`; |
| } |
| |
| action.play(); |
| } |
| } else { |
| console.log(`⚠ No animations found in ${animationType} model`); |
| animationStatus.innerHTML = `<span class="status-dot ${animationType === 'talking' ? 'animation-status-talking' : (animationType === 'dance' ? '" style="background-color: #8b5cf6;"' : 'status-online')}"></span>Animation: ${currentAnimationName}`; |
| } |
| |
| modelLoading.style.display = 'none'; |
| |
| |
| const finalBbox = new THREE.Box3().setFromObject(currentModel); |
| const finalCenter = finalBbox.getCenter(new THREE.Vector3()); |
| controls.target.copy(finalCenter); |
| controls.update(); |
| |
| |
| const maxDim = Math.max(size.x, size.y, size.z); |
| const fov = camera.fov * (Math.PI / 180); |
| let cameraDistance = maxDim / (2 * Math.tan(fov / 2)); |
| cameraDistance *= 1.5; |
| cameraDistance = Math.max(cameraDistance, 2); |
| cameraDistance = Math.min(cameraDistance, 10); |
| |
| camera.position.set(cameraDistance, cameraDistance * 0.5, cameraDistance); |
| camera.lookAt(finalCenter); |
| |
| console.log(`📷 Camera positioned for ${animationType} model`); |
| |
| |
| showAnimationNotification(`Now playing: ${currentAnimationName} animation`); |
| |
| }, |
| (xhr) => { |
| const percent = (xhr.loaded / xhr.total * 100).toFixed(2); |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Loading ${currentAnimationName} ${percent}%`; |
| }, |
| (error) => { |
| console.error(`❌ Error loading ${animationType} model:`, error); |
| modelLoading.style.display = 'none'; |
| |
| if (animationType === 'talking') { |
| |
| console.log('⚠ Talking model failed, loading idle as fallback'); |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Failed to load talking`; |
| loadModel('idle'); |
| } else if (animationType === 'dance') { |
| |
| console.log('⚠ Dance model not found'); |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Dance not available`; |
| showNotification('Dance animation not available', 'warning'); |
| } else { |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Error`; |
| createFallbackModel(); |
| } |
| } |
| ); |
| } catch (error) { |
| console.error(`❌ Error in loadModel for ${animationType}:`, error); |
| modelLoading.style.display = 'none'; |
| animationStatus.innerHTML = `<span class="status-dot status-offline"></span>Animation: Error`; |
| |
| if (animationType === 'talking' || animationType === 'dance') { |
| loadModel('idle'); |
| } else { |
| createFallbackModel(); |
| } |
| } |
| } |
| |
| |
| function interpretAnimationCommand(message) { |
| const lowerMessage = message.toLowerCase(); |
| |
| |
| let detectedAnimation = null; |
| let confidence = 0; |
| |
| |
| ANIMATION_KEYWORDS.dance.forEach(keyword => { |
| if (lowerMessage.includes(keyword) && confidence < 0.9) { |
| detectedAnimation = 'dance'; |
| confidence = 0.9; |
| } |
| }); |
| |
| |
| ANIMATION_KEYWORDS.idle.forEach(keyword => { |
| if (lowerMessage.includes(keyword) && confidence < 0.8) { |
| detectedAnimation = 'idle'; |
| confidence = 0.8; |
| } |
| }); |
| |
| |
| ANIMATION_KEYWORDS.talking.forEach(keyword => { |
| if (lowerMessage.includes(keyword) && confidence < 0.8) { |
| detectedAnimation = 'talking'; |
| confidence = 0.8; |
| } |
| }); |
| |
| |
| const hasShowCommand = ANIMATION_KEYWORDS.show.some(keyword => lowerMessage.includes(keyword)); |
| const hasSwitchCommand = ANIMATION_KEYWORDS.switch.some(keyword => lowerMessage.includes(keyword)); |
| |
| |
| if ((hasShowCommand || hasSwitchCommand) && !detectedAnimation) { |
| |
| if (currentAnimationType === 'talking') { |
| detectedAnimation = 'idle'; |
| confidence = 0.7; |
| } |
| |
| else if (currentAnimationType === 'idle') { |
| detectedAnimation = 'talking'; |
| confidence = 0.7; |
| } |
| |
| else if (currentAnimationType === 'dance') { |
| detectedAnimation = 'idle'; |
| confidence = 0.7; |
| } |
| } |
| |
| return { |
| animation: detectedAnimation, |
| confidence: confidence, |
| hasCommand: detectedAnimation !== null |
| }; |
| } |
| |
| |
| async function handleAnimationCommand(commandResult, userMessage) { |
| if (!commandResult.hasCommand) return false; |
| |
| const animationType = commandResult.animation; |
| |
| |
| addAnimationMessage(`Playing ${animationType} animation...`, animationType); |
| |
| |
| if (animationType === 'dance') { |
| |
| try { |
| await loadModel('dance', 'Silly Dancing'); |
| } catch (error) { |
| console.log('⚠ Could not load dance animation'); |
| addAnimationMessage(`Dance animation is not currently available.`, 'error'); |
| return false; |
| } |
| } else { |
| await loadModel(animationType); |
| } |
| |
| |
| try { |
| if (animationType === 'talking') { |
| await fetch('/api/animation/talk', { method: 'POST' }); |
| } else if (animationType === 'idle') { |
| await fetch('/api/animation/idle', { method: 'POST' }); |
| } |
| |
| } catch (error) { |
| console.error('❌ Error updating animation state:', error); |
| } |
| |
| return true; |
| } |
| |
| |
| function addAnimationMessage(message, animationType = 'info') { |
| const chatMessages = document.getElementById('chat-messages'); |
| const messageDiv = document.createElement('div'); |
| |
| let icon = 'play'; |
| let colorClass = 'bg-blue-100 text-blue-800'; |
| |
| switch(animationType) { |
| case 'dance': |
| icon = 'zap'; |
| colorClass = 'bg-purple-100 text-purple-800'; |
| break; |
| case 'talking': |
| icon = 'message-circle'; |
| colorClass = 'bg-green-100 text-green-800'; |
| break; |
| case 'idle': |
| icon = 'moon'; |
| colorClass = 'bg-indigo-100 text-indigo-800'; |
| break; |
| case 'error': |
| icon = 'alert-circle'; |
| colorClass = 'bg-red-100 text-red-800'; |
| break; |
| } |
| |
| messageDiv.className = `animation-message ${colorClass}`; |
| messageDiv.innerHTML = ` |
| <div class="flex items-center"> |
| <i data-feather="${icon}" class="w-4 h-4 mr-2"></i> |
| <span>${message}</span> |
| </div> |
| `; |
| |
| chatMessages.appendChild(messageDiv); |
| chatMessages.scrollTop = chatMessages.scrollHeight; |
| feather.replace(); |
| } |
| |
| |
| function showAnimationNotification(message) { |
| const existingNotification = document.querySelector('.notification'); |
| if (existingNotification) { |
| existingNotification.remove(); |
| } |
| |
| const notification = document.createElement('div'); |
| notification.className = 'notification notification-info'; |
| notification.innerHTML = ` |
| <div class="notification-content"> |
| <i data-feather="play-circle"></i> |
| <span>${message}</span> |
| </div> |
| `; |
| |
| document.body.appendChild(notification); |
| feather.replace(); |
| |
| setTimeout(() => { |
| notification.style.animation = 'slideOut 0.3s ease-out forwards'; |
| setTimeout(() => notification.remove(), 300); |
| }, 3000); |
| } |
| |
| |
| async function startTalkingAnimation() { |
| if (isTalking || currentAnimationType === 'talking') return; |
| |
| console.log('🎤 Switching to talking animation'); |
| isTalking = true; |
| |
| |
| try { |
| await fetch('/api/animation/talk', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'} |
| }); |
| |
| |
| await loadModel('talking'); |
| |
| } catch (error) { |
| console.error('❌ Error starting talking animation:', error); |
| isTalking = false; |
| } |
| } |
| |
| |
| async function stopTalkingAnimation() { |
| if (!isTalking || currentAnimationType === 'idle') return; |
| |
| console.log('🎤 Switching to idle animation'); |
| isTalking = false; |
| |
| |
| try { |
| await fetch('/api/animation/idle', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'} |
| }); |
| |
| |
| await loadModel('idle'); |
| |
| } catch (error) { |
| console.error('❌ Error stopping talking animation:', error); |
| } |
| } |
| |
| |
| 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); |
| |
| currentModel = cube; |
| animationStatus.innerHTML = `<span class="status-dot status-online"></span>Animation: Fallback Model`; |
| } |
| |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| |
| const delta = clock.getDelta(); |
| |
| |
| if (mixer) { |
| mixer.update(delta); |
| } |
| |
| |
| if (controls) { |
| controls.update(); |
| } |
| |
| |
| if (renderer && scene && camera) { |
| renderer.render(scene, camera); |
| } |
| } |
| |
| |
| function onWindowResize() { |
| const width = threeContainer.clientWidth; |
| const height = threeContainer.clientHeight; |
| |
| if (camera) { |
| camera.aspect = width / height; |
| camera.updateProjectionMatrix(); |
| } |
| |
| if (renderer) { |
| renderer.setSize(width, height); |
| } |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', function() { |
| |
| initThreeJS(); |
| |
| |
| window.addEventListener('resize', onWindowResize); |
| |
| |
| const resizeObserver = new ResizeObserver(onWindowResize); |
| if (threeContainer) { |
| resizeObserver.observe(threeContainer); |
| } |
| }); |
| |
| |
| |
| |
| |
| |
| let ttsEnabled = true; |
| let ttsSupported = false; |
| let selectedVoice = 'default'; |
| |
| |
| 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; |
| } |
| |
| |
| function setupAudioEventListeners(audioElement, messageElement) { |
| if (!audioElement) return; |
| |
| |
| audioElement.addEventListener('play', function() { |
| console.log('🎵 Audio playback started'); |
| startTalkingAnimation(); |
| messageElement.classList.add('playing-audio'); |
| }); |
| |
| audioElement.addEventListener('pause', function() { |
| console.log('🎵 Audio playback paused'); |
| stopTalkingAnimation(); |
| messageElement.classList.remove('playing-audio'); |
| }); |
| |
| audioElement.addEventListener('ended', function() { |
| console.log('🎵 Audio playback ended'); |
| stopTalkingAnimation(); |
| messageElement.classList.remove('playing-audio'); |
| }); |
| |
| audioElement.addEventListener('error', function(e) { |
| console.error('🎵 Audio playback error:', e); |
| stopTalkingAnimation(); |
| messageElement.classList.remove('playing-audio'); |
| }); |
| } |
| |
| |
| async function initializeTTS() { |
| try { |
| console.log('🚀 Initializing TTS System...'); |
| |
| |
| 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'); |
| } |
| |
| |
| await loadWelcomeMessage(); |
| } |
| |
| |
| 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}`; |
| } |
| } |
| |
| |
| async function loadWelcomeMessage() { |
| try { |
| console.log('📥 Loading welcome message...'); |
| |
| |
| 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}`); |
| |
| |
| 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(); |
| |
| |
| if (hasAudio) { |
| const audioElement = welcomeContainer.querySelector('audio'); |
| const messageElement = welcomeContainer.querySelector('.chat-welcome'); |
| setupAudioEventListeners(audioElement, messageElement); |
| } |
| |
| } |
| } catch (error) { |
| console.error('❌ Failed to load welcome:', error); |
| |
| 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(); |
| } |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', function() { |
| const chatMessages = document.getElementById('chat-messages'); |
| const userInput = document.getElementById('user-input'); |
| const sendBtn = document.getElementById('send-btn'); |
| |
| |
| initializeTTS(); |
| |
| |
| sendBtn.addEventListener('click', sendMessage); |
| userInput.addEventListener('keypress', function(e) { |
| if (e.key === 'Enter') { |
| sendMessage(); |
| } |
| }); |
| |
| |
| 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; |
| |
| |
| const animationCommand = interpretAnimationCommand(message); |
| |
| if (animationCommand.hasCommand && animationCommand.confidence > 0.6) { |
| |
| userInput.value = ''; |
| |
| |
| addUserMessage(message, true); |
| |
| |
| const animationHandled = await handleAnimationCommand(animationCommand, message); |
| |
| if (animationHandled) { |
| |
| setTimeout(() => { |
| addBotMessage(`I'm now showing the ${animationCommand.animation} animation! What else would you like to see?`, null); |
| }, 1000); |
| } |
| |
| return; |
| } |
| |
| |
| |
| addUserMessage(message); |
| userInput.value = ''; |
| sendBtn.disabled = true; |
| |
| try { |
| console.log('📤 Sending to AI...'); |
| |
| const response = await fetch('/api/chat', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ |
| message: message, |
| voice: selectedVoice |
| }) |
| }); |
| |
| const data = await response.json(); |
| |
| if (response.ok) { |
| |
| addBotMessage(data.response, data.audio_url); |
| } else { |
| addBotMessage(`Error: ${data.error || 'Unknown error'}`, null); |
| } |
| } catch (error) { |
| console.error('❌ Network error:', error); |
| addBotMessage(`Network error: ${error.message}`, null); |
| } finally { |
| sendBtn.disabled = false; |
| userInput.focus(); |
| } |
| } |
| |
| function addUserMessage(text, isAnimationCommand = false) { |
| 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'}); |
| |
| let animationIndicator = ''; |
| if (isAnimationCommand) { |
| animationIndicator = '<span class="animation-command"><i data-feather="play" class="w-2 h-2"></i> ANIMATION</span>'; |
| } |
| |
| messageDiv.innerHTML = ` |
| <div class="message-header"> |
| <div class="message-sender"> |
| <i data-feather="user" class="w-4 h-4"></i> |
| You |
| ${animationIndicator} |
| </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(); |
| |
| |
| if (ttsEnabled && audioUrl) { |
| const audioElement = messageDiv.querySelector('audio'); |
| if (audioElement) { |
| |
| setupAudioEventListeners(audioElement, messageDiv); |
| |
| |
| setTimeout(() => { |
| audioElement.play().catch(e => { |
| console.log('Auto-play prevented:', e); |
| }); |
| }, 500); |
| } |
| } |
| } |
| |
| console.log('🚀 Chat system ready with animation commands and auto-TTS'); |
| }); |
| </script> |
| </body> |
| </html> |