OrbitMC commited on
Commit
be44846
·
verified ·
1 Parent(s): 0247fa0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1656 -437
app.py CHANGED
@@ -1,470 +1,1689 @@
1
- # app.py
2
- # Production script for the FastLLM Space.
3
- # Required dependencies: pip install gradio transformers torch spaces accelerate
4
  import os
 
5
  import json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import torch
7
- import spaces
8
- import gradio as gr
9
- from threading import Thread
10
- from typing import Generator
11
- from fastapi.responses import HTMLResponse
12
- from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
13
-
14
- # --- 1. LOCAL MODEL SPECIFICATION AND INITIAL CRITICAL CPU LOADING ---
15
- # Selection of Qwen2.5-1.5B fits the <4B parameters Tiny Titan bracket
16
- MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"
17
-
18
- # Initialize tokenizer and load base model onto system RAM (CPU) to prevent cold startup allocation errors
19
- tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
20
- model = AutoModelForCausalLM.from_pretrained(
21
- MODEL_ID,
22
- torch_dtype=torch.float16,
23
- device_map="cpu"
24
- )
25
-
26
- # --- 2. THE QUEUED ASYNC SERVERLESS INFERENCE PIPELINE ---
27
- @spaces.GPU(duration=30)
28
- def run_inference(message: str, history_str: str) -> Generator[str, None, None]:
29
- """
30
- Spins up the GPU model instance and runs real-time text streaming
31
- by executing within the ephemeral ZeroGPU scheduling boundary.
32
- """
33
- # Move model parameters to physical GPU context inside the execution function
34
- model.to("cuda")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
- # Establish base system context and constraints
37
- messages =
38
 
39
- # Parse and append past conversational context
40
- if history_str:
41
- try:
42
- history = json.loads(history_str)
43
- for turn in history:
44
- if isinstance(turn, list) and len(turn) == 2:
45
- messages.append({"role": "user", "content": turn})
46
- messages.append({"role": "assistant", "content": turn})
47
- except Exception:
48
- pass
49
-
50
- # Append the current prompt
51
- messages.append({"role": "user", "content": message})
52
 
53
- # Process text sequences, utilizing return_dict to prevent sequence shape errors
54
- inputs = tokenizer.apply_chat_template(
55
- messages,
56
- tokenize=True,
57
- add_generation_prompt=True,
58
- return_tensors="pt",
59
- return_dict=True
60
- ).to("cuda")
 
 
 
61
 
62
- # Set up streaming generators
63
- streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
64
- generation_kwargs = dict(
65
- **inputs,
66
- streamer=streamer,
67
- max_new_tokens=192,
68
- do_sample=True,
69
- temperature=0.7,
70
- top_p=0.9,
71
- )
72
 
73
- # Execute model forward pass on a dedicated worker thread
74
- thread = Thread(target=model.generate, kwargs=generation_kwargs)
75
- thread.start()
 
 
 
 
 
 
76
 
77
- # Yield incremental text updates as they are generated
78
- accumulated_text = ""
79
- for new_text in streamer:
80
- accumulated_text += new_text
81
- yield accumulated_text
82
-
83
- # --- 3. THE 98% CUSTOM FRONTEND SYSTEM (FRONTEND_HTML) ---
84
- FRONTEND_HTML = """
85
- <!DOCTYPE html>
86
- <html lang="en">
87
- <head>
88
- <meta charset="UTF-8">
89
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
90
- <title>FastLLM Companion</title>
91
- <script src="https://cdn.tailwindcss.com"></script>
92
- <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
93
- <style>
94
- body {
95
- background-color: #ffe082; /* Gold/yellow background from specifications */
96
- margin: 0;
97
- overflow: hidden;
98
- font-family: system-ui, -apple-system, sans-serif;
99
- -webkit-user-select: none;
100
- user-select: none;
101
- -webkit-tap-highlight-color: transparent;
102
  }
103
- #c {
104
- position: fixed;
105
- top: 0;
106
- left: 0;
107
- width: 100%;
108
- height: 100%;
109
- z-index: 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
- .glass-panel {
112
- background: rgba(8, 10, 22, 0.93);
113
- backdrop-filter: blur(12px);
114
- -webkit-backdrop-filter: blur(12px);
115
- border: 1px solid rgba(255, 255, 255, 0.08);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  }
117
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </head>
119
- <body class="text-white relative w-screen h-screen">
120
 
121
- <canvas id="c"></canvas>
122
-
123
- <div id="drop" class="absolute inset-0 flex flex-col items-center justify-center border-4 border-dashed border-cyan-500/50 m-10 rounded-3xl z-10 pointer-events-none transition-opacity duration-300 opacity-0">
124
- <h2 class="text-3xl font-extrabold text-[#6cf] mb-2">Drop VRM Model</h2>
125
- <p class="text-sm text-gray-100 opacity-60">Upload custom characters directly into viewport</p>
126
- </div>
127
-
128
- <div id="vrmPanel" class="absolute top-20 left-4 w-80 glass-panel p-4 rounded-2xl z-20 hidden flex-col gap-3">
129
- <h3 class="font-bold text-sm text-cyan-400 uppercase tracking-widest">Available Companions</h3>
130
- <div id="vrmList" class="flex-grow overflow-y-auto max-h-48 pr-1">
131
- <div class="vrmItem flex items-center justify-between p-2 hover:bg-slate-800/60 rounded-xl cursor-pointer">
132
- <span class="text-xs">Procedural Cyber-Core v1.0</span>
133
- <span class="dot w-2 h-2 rounded-full bg-emerald-500 shadow-md"></span>
134
- </div>
135
- </div>
136
- <button id="vrmPanelClose" class="text-center text-xs py-2 bg-slate-800 hover:bg-slate-700 rounded-xl mt-2 transition-all">Close Panel</button>
 
 
 
 
 
 
 
 
137
  </div>
138
 
139
- <div id="speakDot" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 hidden flex items-center gap-1.5 bg-cyan-950/90 px-5 py-2.5 rounded-full border border-cyan-500/30">
140
- <span class="text-xs text-cyan-400 mr-2 font-mono uppercase tracking-widest">Active</span>
141
- <div class="sdot w-2 h-2 rounded-full bg-cyan-400 animate-bounce"></div>
142
- <div class="sdot w-2 h-2 rounded-full bg-cyan-400 animate-bounce" style="animation-delay: 0.15s"></div>
143
- <div class="sdot w-2 h-2 rounded-full bg-cyan-400 animate-bounce" style="animation-delay: 0.3s"></div>
144
  </div>
145
-
146
- <div class="absolute bottom-24 left-1/2 -translate-x-1/2 z-10 w-[90%] max-w-2xl px-6 py-4 rounded-2xl glass-panel text-center hidden pointer-events-auto border-t-2 border-cyan-500/20 shadow-lg" id="subtitle-panel">
147
- <p id="subtitle-text" class="text-sm text-gray-100 leading-relaxed text-left"></p>
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
 
150
- <div id="vrmaQueue" class="absolute bottom-28 right-4 w-64 max-h-32 overflow-y-auto glass-panel p-2 rounded-xl text-[10px] font-mono text-gray-400 hidden flex flex-col gap-1 z-10">
151
- <div class="qitem border-b border-gray-800/30 pb-1">Queue: Syncing bones...</div>
 
 
 
 
 
 
 
 
 
152
  </div>
153
 
154
- <div id="bar" class="absolute left-1/2 -translate-x-1/2 z-10 w-[95%] max-w-4xl p-2 rounded-3xl glass-panel flex items-center gap-2 pointer-events-auto shadow-2xl">
155
- <button id="mb" class="p-3 rounded-2xl bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-cyan-400 transition-all flex-shrink-0 flex items-center justify-center" onclick="toggleMenu()">
156
- <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path></svg>
157
- </button>
158
-
159
- <input type="text" id="ti" class="flex-1 py-3 px-4 rounded-2xl bg-slate-900/95 border border-slate-700/60 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 transition-all" placeholder="Enter message to local AI...">
160
-
161
- <button id="sb" class="p-3 rounded-2xl bg-gradient-to-r from-[#6cf] to-[#3ae] hover:opacity-95 text-white font-semibold transition-all flex-shrink-0" onclick="handleSend()">
162
- <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
163
- </button>
164
  </div>
165
-
166
- <div id="load" class="absolute top-20 left-1/2 -translate-x-1/2 glass-panel px-4 py-2 rounded-full text-xs text-amber-400 font-mono tracking-widest uppercase transition-opacity duration-300 opacity-0 z-20">Syncing Local Engine...</div>
167
- <div id="err" class="absolute top-4 left-4 right-4 bg-red-950/90 border border-red-500/30 text-red-200 px-4 py-2 rounded-xl text-xs font-mono text-center hidden z-30">GPU assignment latency detected. Retrying connection...</div>
168
- <div id="info" class="absolute top-20 right-4 w-72 glass-panel p-3 rounded-xl border border-blue-500/20 text-xs text-blue-200 hidden z-20">Notice: Running local weights on serverless hardware.</div>
169
- <div id="fps" class="absolute top-4 right-4 text-xs font-mono text-emerald-400 bg-slate-950/90 px-3 py-1.5 rounded border border-emerald-500/20 z-20">FPS: --</div>
170
-
171
- <div id="menu" class="absolute bottom-0 left-0 right-0 glass-panel p-6 rounded-t-3xl z-30 transform translate-y-full transition-transform duration-300 max-h-[60vh] overflow-y-auto">
172
- <div class="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
173
- <h3 class="font-bold text-cyan-400 uppercase tracking-widest text-sm">Customization Panel</h3>
174
- <button class="text-gray-500 hover:text-white text-xl" onclick="toggleMenu()">&times;</button>
175
- </div>
176
- <div class="flex flex-col gap-4">
177
- <div class="row flex justify-between items-center text-sm">
178
- <span class="text-gray-300">Ambient Lighting</span>
179
- <input type="range" min="0.5" max="3" step="0.1" value="1.5" class="accent-cyan-500" oninput="updateGlowIntensity(this.value)">
180
- </div>
181
- <div class="row flex justify-between items-center text-sm">
182
- <span class="text-gray-300">Companion Eye Tint</span>
183
- <input type="color" value="#06b6d4" class="w-8 h-8 rounded border-none bg-transparent cursor-pointer" onchange="updateEyeColor(this.value)">
184
- </div>
185
- <div class="row flex justify-between items-center text-sm">
186
- <span class="text-gray-300">Key Registration</span>
187
- <input type="password" placeholder="Key token..." class="bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-white">
188
- </div>
189
- <div class="row flex justify-between items-center text-sm">
190
- <span class="text-gray-300">Mesh Designation</span>
191
- <input type="text" value="Aya-Companion" class="bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-white">
192
- </div>
193
- <div class="row flex justify-between items-center text-sm">
194
- <span class="text-gray-300">Interaction Node</span>
195
- <select class="bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-white">
196
- <option>Empathetic</option>
197
- <option>Analytical</option>
198
- </select>
199
- </div>
200
- <div class="chips flex gap-2 flex-wrap">
201
- <span class="chip bg-cyan-950 text-cyan-300 px-3 py-1 rounded-full text-xs cursor-pointer border border-cyan-500/20">Voice Sync</span>
202
- <span class="chip bg-slate-800 text-slate-300 px-3 py-1 rounded-full text-xs cursor-pointer">Local Text</span>
203
- </div>
204
- <div class="fbtn flex gap-2 mt-2">
205
- <button class="flex-1 py-2 bg-rose-950/50 hover:bg-rose-950 border border-rose-500/30 text-rose-300 rounded-xl text-xs font-semibold">Clear Profile</button>
206
- <button class="flex-1 py-2 bg-cyan-950/50 hover:bg-cyan-950 border border-cyan-500/30 text-cyan-300 rounded-xl text-xs font-semibold">Save Profile</button>
207
- </div>
208
  </div>
 
 
209
  </div>
 
210
 
211
- <div class="voice-locked absolute top-4 left-1/2 -translate-x-1/2 bg-slate-950/90 border border-amber-500/30 text-amber-200 px-4 py-2 rounded-xl text-xs font-mono hidden flex items-center gap-2 z-30 shadow-lg">
212
- <svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
213
- Voice function locked. Upgrade to premium.
214
- </div>
215
 
216
- <script type="module">
217
- import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
218
-
219
- let client;
220
- let isGenerating = false;
221
- let chatHistory =;
222
-
223
- // Setup control bar positioning for mobile Apple safe area inset
224
- const inputBar = document.getElementById('bar');
225
- inputBar.style.bottom = `calc(16px + env(safe-area-inset-bottom, 0px))`;
226
-
227
- async function connectEngine() {
228
- const loader = document.getElementById('load');
229
- loader.style.opacity = '1';
230
- try {
231
- // Connect utilizing the local window origin to ensure ZeroGPU token handshakes are verified
232
- client = await Client.connect(window.location.origin);
233
- loader.style.opacity = '0';
234
- } catch (err) {
235
- console.error("Gradio initialization failure:", err);
236
- document.getElementById('err').classList.remove('hidden');
237
- }
238
- }
239
-
240
- window.handleSend = async function() {
241
- const inputField = document.getElementById('ti');
242
- const messageText = inputField.value.trim();
243
- if (!messageText || isGenerating) return;
244
-
245
- inputField.value = '';
246
- isGenerating = true;
247
-
248
- // Show subtitle panel and active speaking indicator
249
- const subtitlePanel = document.getElementById('subtitle-panel');
250
- const subtitleText = document.getElementById('subtitle-text');
251
- const speakIndicator = document.getElementById('speakDot');
252
-
253
- subtitlePanel.classList.remove('hidden');
254
- speakIndicator.classList.remove('hidden');
255
- subtitleText.textContent = "Processing message...";
256
-
257
- try {
258
- // Submit request to the local serverless execution queue
259
- const job = client.submit("/chat",);
260
-
261
- job.on("data", (event) => {
262
- const latestChunk = event.data;
263
- subtitleText.textContent = latestChunk;
264
-
265
- // Trigger character jaw scaling based on active streaming
266
- speakingIntensity = 1.0;
267
- });
268
-
269
- job.on("status", (status) => {
270
- if (status.stage === "complete") {
271
- const finalResponse = subtitleText.textContent;
272
- chatHistory.push();
273
- if (chatHistory.length > 8) chatHistory.shift();
274
-
275
- isGenerating = false;
276
- speakIndicator.classList.add('hidden');
277
-
278
- setTimeout(() => {
279
- if (!isGenerating) subtitlePanel.classList.add('hidden');
280
- }, 5000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  }
282
- });
283
- } catch (err) {
284
- console.error("Inference execution failure:", err);
285
- subtitleText.textContent = "Pipeline error. Retrying...";
286
- isGenerating = false;
287
- speakIndicator.classList.add('hidden');
288
- }
289
- };
290
-
291
- document.getElementById('ti').addEventListener('keypress', (e) => {
292
- if (e.key === 'Enter') handleSend();
293
- });
294
-
295
- window.toggleMenu = function() {
296
- const menu = document.getElementById('menu');
297
- if (menu.style.transform === 'translateY(0%)') {
298
- menu.style.transform = 'translateY(100%)';
299
- } else {
300
- menu.style.transform = 'translateY(0%)';
301
  }
302
- };
303
-
304
- // --- 4. PROCEDURAL THREE.JS WEBGL RENDER LOOP ---
305
- let scene, camera, renderer;
306
- let headMesh, leftEye, rightEye, mouthMesh;
307
- let baseEyeColor, targetEyeColor;
308
- let mouseX = 0, mouseY = 0;
309
- let speakingIntensity = 0;
310
- let clock = new THREE.Clock();
311
- let fpsLastTime = performance.now();
312
- let fpsFrames = 0;
313
-
314
- function initWebGLScene() {
315
- const canvas = document.getElementById('c');
316
- scene = new THREE.Scene();
317
- scene.fog = new THREE.FogExp2(0xffe082, 0.05);
318
-
319
- camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
320
- camera.position.set(0, 0.2, 4.5);
321
-
322
- renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
323
- renderer.setSize(window.innerWidth, window.innerHeight);
324
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
325
- renderer.setClearColor(0x000000, 0); // Transparent WebGL overlay
326
-
327
- // Lighting Configuration
328
- const ambient = new THREE.AmbientLight(0xfffbeb, 1.0);
329
- scene.add(ambient);
330
-
331
- const direction = new THREE.DirectionalLight(0x06b6d4, 1.8);
332
- direction.position.set(5, 5, 5);
333
- scene.add(direction);
334
-
335
- // Cybernetic companion structure
336
- const metalMat = new THREE.MeshStandardMaterial({
337
- color: 0x1e293b,
338
- roughness: 0.12,
339
- metalness: 0.88
340
- });
341
-
342
- headMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 0.6, 1.2, 8), metalMat);
343
- headMesh.position.set(0, 0, 0);
344
- scene.add(headMesh);
345
-
346
- // Expressive glowing eye spheres
347
- baseEyeColor = new THREE.Color(0x06b6d4);
348
- targetEyeColor = new THREE.Color(0x06b6d4);
349
- const eyeMat = new THREE.MeshBasicMaterial({ color: baseEyeColor });
350
- const eyeGeo = new THREE.SphereGeometry(0.14, 32, 32);
351
-
352
- leftEye = new THREE.Mesh(eyeGeo, eyeMat);
353
- leftEye.position.set(-0.28, 0.15, 0.58);
354
- headMesh.add(leftEye);
355
-
356
- rightEye = new THREE.Mesh(eyeGeo, eyeMat);
357
- rightEye.position.set(0.28, 0.15, 0.58);
358
- headMesh.add(rightEye);
359
-
360
- // Dynamic speaking mesh
361
- mouthMesh = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.04, 0.06), new THREE.MeshBasicMaterial({ color: 0x06b6d4 }));
362
- mouthMesh.position.set(0, -0.28, 0.61);
363
- headMesh.add(mouthMesh);
364
-
365
- // Floating halo ring
366
- const ringGeo = new THREE.TorusGeometry(1.2, 0.03, 8, 48);
367
- ringGeo.rotateX(Math.PI / 2);
368
- const ringMesh = new THREE.Mesh(ringGeo, new THREE.MeshStandardMaterial({
369
- color: 0x06b6d4,
370
- emissive: 0x06b6d4,
371
- emissiveIntensity: 0.8
372
- }));
373
- ringMesh.position.y = 0.8;
374
- headMesh.add(ringMesh);
375
-
376
- window.addEventListener('resize', onResize);
377
- window.addEventListener('mousemove', (e) => {
378
- mouseX = (e.clientX / window.innerWidth) * 2 - 1;
379
- mouseY = -(e.clientY / window.innerHeight) * 2 + 1;
380
- });
381
-
382
- connectEngine();
383
- animate();
384
- }
385
-
386
- function onResize() {
387
- camera.aspect = window.innerWidth / window.innerHeight;
388
- camera.updateProjectionMatrix();
389
- renderer.setSize(window.innerWidth, window.innerHeight);
390
  }
391
-
392
- window.updateEyeColor = function(colorHex) {
393
- baseEyeColor.set(colorHex);
394
- targetEyeColor.set(colorHex);
395
- mouthMesh.material.color.set(colorHex);
396
- };
397
-
398
- window.updateGlowIntensity = function(val) {
399
- scene.children.forEach(c => {
400
- if (c.isDirectionalLight) c.intensity = parseFloat(val);
401
- });
402
- };
403
-
404
- function animate() {
405
- requestAnimationFrame(animate);
406
- const time = clock.getElapsedTime();
407
-
408
- // Dynamic idle float movements
409
- headMesh.position.y = Math.sin(time * 1.8) * 0.06;
410
-
411
- // Head rotation mechanics
412
- const targetRotY = mouseX * 0.35;
413
- const targetRotX = -mouseY * 0.18;
414
- headMesh.rotation.y += (targetRotY - headMesh.rotation.y) * 0.1;
415
- headMesh.rotation.x += (targetRotX - headMesh.rotation.x) * 0.1;
416
-
417
- // Linear jaw scaling during token streaming
418
- if (speakingIntensity > 0.01) {
419
- speakingIntensity *= 0.90;
420
- const mouthY = 1 + Math.sin(time * 28) * 3.0 * speakingIntensity;
421
- mouthMesh.scale.set(1.0, mouthY, 1.0);
422
- } else {
423
- mouthMesh.scale.set(1.0, 1.0, 1.0);
424
- }
425
-
426
- // Expressive blinks
427
- const isBlink = Math.floor(time) % 6 === 0 && (time - Math.floor(time)) < 0.15;
428
- leftEye.scale.y = isBlink? 0.15 : 1.0;
429
- rightEye.scale.y = isBlink? 0.15 : 1.0;
430
-
431
- renderer.render(scene, camera);
432
-
433
- // Track client render metrics
434
- fpsFrames++;
435
- const now = performance.now();
436
- if (now >= fpsLastTime + 1000) {
437
- document.getElementById('fps').textContent = `FPS: ${fpsFrames}`;
438
- fpsFrames = 0;
439
- fpsLastTime = now;
440
  }
441
  }
442
-
443
- initWebGLScene();
444
- </script>
445
- </body>
446
- </html>
447
- """
448
 
449
- # --- 5. SYSTEM REGISTRATION AND COMPOSITION ---
450
- # Initialize the custom Server Mode FastAPI app
451
- app = gr.Server()
452
-
453
- @app.get("/", response_class=HTMLResponse)
454
- async def homepage() -> HTMLResponse:
455
- """
456
- Serves the custom HTML single-page application and its embedded WebGL engine.
457
- """
458
- return HTMLResponse(content=FRONTEND_HTML, status_code=200)
459
-
460
- @app.api(name="chat")
461
- def chat(message: str, history_str: str) -> Generator[str, None, None]:
462
- """
463
- API endpoint wrapped in Gradio's serialized concurrency queue, supporting spaces.GPU.
464
- """
465
- for chunk in run_inference(message, history_str):
466
- yield chunk
467
-
468
- # Launch the unified server
469
  if __name__ == "__main__":
470
- app.launch()
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import spaces
 
3
  import os
4
+ import sys
5
  import json
6
+ import re
7
+ import zipfile
8
+ import asyncio
9
+ import tempfile
10
+ import base64
11
+ import threading
12
+ import time
13
+ import random
14
+ from pathlib import Path
15
+ import subprocess
16
+
17
+ # ── Install deps ──────────────────────────────────────────────────────────────
18
+ def install_packages():
19
+ packages = [
20
+ "transformers>=4.40.0",
21
+ "torch>=2.1.0",
22
+ "accelerate>=0.27.0",
23
+ "edge-tts",
24
+ "sentencepiece",
25
+ "bitsandbytes",
26
+ ]
27
+ for pkg in packages:
28
+ subprocess.run([sys.executable, "-m", "pip", "install", pkg, "-q"], check=False)
29
+
30
+ install_packages()
31
+
32
  import torch
33
+ from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer
34
+ import edge_tts
35
+
36
+ # ── Constants ─────────────────────────────────────────────────────────────────
37
+ MODEL_ID = "google/gemma-2-2b-it"
38
+ VRM_PATH = "model/Ani.vrm"
39
+ ANIM_ZIP = "animation/all_vrma.zip"
40
+ TTS_VOICE = "en-US-AriaNeural" # fallback if XiaoyiNeural unavailable
41
+ TTS_VOICE_PRIMARY = "zh-CN-XiaoyiNeural"
42
+
43
+ EMOTIONS_MAP = {
44
+ "happy": {"blinking": True, "description": "joyful, positive"},
45
+ "sad": {"blinking": True, "description": "sorrowful, melancholic"},
46
+ "angry": {"blinking": False, "description": "furious, irritated"},
47
+ "surprised": {"blinking": False, "description": "shocked, astonished"},
48
+ "fearful": {"blinking": True, "description": "scared, anxious"},
49
+ "disgusted": {"blinking": False, "description": "revolted, displeased"},
50
+ "neutral": {"blinking": True, "description": "calm, normal"},
51
+ "excited": {"blinking": True, "description": "enthusiastic, energetic"},
52
+ "confused": {"blinking": True, "description": "puzzled, uncertain"},
53
+ "thinking": {"blinking": True, "description": "contemplating, processing"},
54
+ "laughing": {"blinking": False, "description": "amused, giggling"},
55
+ "crying": {"blinking": False, "description": "weeping, tears"},
56
+ "love": {"blinking": True, "description": "affectionate, caring"},
57
+ "shy": {"blinking": True, "description": "bashful, embarrassed"},
58
+ "bored": {"blinking": True, "description": "uninterested, tired"},
59
+ "sleepy": {"blinking": False, "description": "drowsy, fatigued"},
60
+ "determined":{"blinking": True, "description": "resolute, focused"},
61
+ "proud": {"blinking": True, "description": "accomplished, confident"},
62
+ "embarrassed":{"blinking":True, "description": "flustered, awkward"},
63
+ "grateful": {"blinking": True, "description": "thankful, appreciative"},
64
+ "playful": {"blinking": True, "description": "mischievous, fun"},
65
+ "serious": {"blinking": True, "description": "solemn, focused"},
66
+ }
67
+
68
+ # ── Extract VRMA list ─────────────────────────────────────────────────────────
69
+ def get_vrma_list():
70
+ vrma_files = []
71
+ if os.path.exists(ANIM_ZIP):
72
+ with zipfile.ZipFile(ANIM_ZIP, 'r') as z:
73
+ vrma_files = [f for f in z.namelist() if f.endswith('.vrma')]
74
+ return vrma_files
75
+
76
+ VRMA_LIST = get_vrma_list()
77
+
78
+ # ── Model loading ─────────────────────────────────────────────────────────────
79
+ tokenizer = None
80
+ model = None
81
+ model_lock = threading.Lock()
82
+
83
+ def load_model():
84
+ global tokenizer, model
85
+ if model is not None:
86
+ return
87
+ with model_lock:
88
+ if model is not None:
89
+ return
90
+ print(f"Loading {MODEL_ID}...")
91
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
92
+ model = AutoModelForCausalLM.from_pretrained(
93
+ MODEL_ID,
94
+ torch_dtype=torch.bfloat16,
95
+ device_map="auto",
96
+ low_cpu_mem_usage=True,
97
+ )
98
+ model.eval()
99
+ print("Model loaded!")
100
+
101
+ # ── AI inference ──────────────────────────────────────────────────────────────
102
+ SYSTEM_PROMPT = """You are Ani, a helpful and expressive AI anime companion.
103
+ Respond ONLY with valid JSON in this exact format:
104
+ {{
105
+ "response": "your conversational reply here",
106
+ "emotion": "one emotion from the list",
107
+ "animation": "one vrma filename from the list or null",
108
+ "intensity": 0.8
109
+ }}
110
+
111
+ Available emotions: {emotions}
112
+ Available animations: {animations}
113
+
114
+ Choose the most fitting emotion and animation based on context.
115
+ Keep responses friendly, natural, and concise (1-3 sentences)."""
116
+
117
+ @spaces.GPU(duration=60)
118
+ def generate_response(user_message: str, chat_history: list) -> dict:
119
+ load_model()
120
 
121
+ emotions_str = ", ".join(EMOTIONS_MAP.keys())
122
+ animations_str = ", ".join(VRMA_LIST[:30]) if VRMA_LIST else "null"
123
 
124
+ system = SYSTEM_PROMPT.format(
125
+ emotions=emotions_str,
126
+ animations=animations_str
127
+ )
 
 
 
 
 
 
 
 
 
128
 
129
+ messages = [{"role": "system", "content": system}]
130
+ for human, assistant in chat_history[-4:]:
131
+ if human:
132
+ messages.append({"role": "user", "content": human})
133
+ if assistant:
134
+ try:
135
+ parsed = json.loads(assistant)
136
+ messages.append({"role": "assistant", "content": parsed.get("response", assistant)})
137
+ except:
138
+ messages.append({"role": "assistant", "content": assistant})
139
+ messages.append({"role": "user", "content": user_message})
140
 
141
+ text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
142
+ inputs = tokenizer(text, return_tensors="pt").to(model.device)
 
 
 
 
 
 
 
 
143
 
144
+ with torch.no_grad():
145
+ outputs = model.generate(
146
+ **inputs,
147
+ max_new_tokens=256,
148
+ temperature=0.7,
149
+ top_p=0.9,
150
+ do_sample=True,
151
+ pad_token_id=tokenizer.eos_token_id,
152
+ )
153
 
154
+ generated = outputs[0][inputs["input_ids"].shape[1]:]
155
+ raw = tokenizer.decode(generated, skip_special_tokens=True).strip()
156
+
157
+ # Parse JSON
158
+ try:
159
+ json_match = re.search(r'\{.*\}', raw, re.DOTALL)
160
+ if json_match:
161
+ result = json.loads(json_match.group())
162
+ else:
163
+ raise ValueError("No JSON found")
164
+ except Exception:
165
+ result = {
166
+ "response": raw if raw else "Hello! How can I help you?",
167
+ "emotion": "neutral",
168
+ "animation": None,
169
+ "intensity": 0.7
 
 
 
 
 
 
 
 
 
170
  }
171
+
172
+ # Validate
173
+ if result.get("emotion") not in EMOTIONS_MAP:
174
+ result["emotion"] = "neutral"
175
+
176
+ return result
177
+
178
+ # ── TTS ───────────────────────────────────────────────────────────────────────
179
+ async def _tts_async(text: str, voice: str, output_path: str):
180
+ communicate = edge_tts.Communicate(text, voice)
181
+ await communicate.save(output_path)
182
+
183
+ def run_tts(text: str) -> str | None:
184
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
185
+ tmp.close()
186
+ for voice in [TTS_VOICE_PRIMARY, TTS_VOICE]:
187
+ try:
188
+ asyncio.run(_tts_async(text, voice, tmp.name))
189
+ if os.path.getsize(tmp.name) > 0:
190
+ with open(tmp.name, "rb") as f:
191
+ audio_b64 = base64.b64encode(f.read()).decode()
192
+ os.unlink(tmp.name)
193
+ return audio_b64
194
+ except Exception as e:
195
+ print(f"TTS failed with {voice}: {e}")
196
+ return None
197
+
198
+ # ── VRM/VRMA helpers ──────────────────────────────────────────────────────────
199
+ def get_vrm_base64():
200
+ if os.path.exists(VRM_PATH):
201
+ with open(VRM_PATH, "rb") as f:
202
+ return base64.b64encode(f.read()).decode()
203
+ return None
204
+
205
+ def get_vrma_base64(vrma_name: str):
206
+ if not os.path.exists(ANIM_ZIP):
207
+ return None
208
+ with zipfile.ZipFile(ANIM_ZIP, 'r') as z:
209
+ for name in z.namelist():
210
+ if name.endswith(vrma_name) or name == vrma_name:
211
+ data = z.read(name)
212
+ return base64.b64encode(data).decode()
213
+ return None
214
+
215
+ def get_idle_animation():
216
+ """Get natural2.vrma or fallback"""
217
+ priorities = ["natural2.vrma", "natural.vrma", "idle.vrma", "stand.vrma"]
218
+ for p in priorities:
219
+ b64 = get_vrma_base64(p)
220
+ if b64:
221
+ return b64, p
222
+ if VRMA_LIST:
223
+ name = VRMA_LIST[0]
224
+ b64 = get_vrma_base64(name)
225
+ return b64, name
226
+ return None, None
227
+
228
+ VRM_B64 = get_vrm_base64()
229
+ IDLE_ANIM_B64, IDLE_ANIM_NAME = get_idle_animation()
230
+
231
+ # ── Chat processing ───────────────────────────────────────────────────────────
232
+ def process_chat(user_message: str, chat_history: list, state: dict):
233
+ if not user_message.strip():
234
+ return chat_history, state, None, "{}"
235
+
236
+ # Generate AI response
237
+ try:
238
+ result = generate_response(user_message, chat_history)
239
+ except Exception as e:
240
+ result = {
241
+ "response": f"Sorry, I encountered an error: {str(e)[:100]}",
242
+ "emotion": "sad",
243
+ "animation": None,
244
+ "intensity": 0.5
245
  }
246
+
247
+ response_text = result.get("response", "...")
248
+ emotion = result.get("emotion", "neutral")
249
+ animation = result.get("animation")
250
+ intensity = result.get("intensity", 0.7)
251
+
252
+ # TTS
253
+ audio_b64 = run_tts(response_text)
254
+
255
+ # Animation data
256
+ anim_b64 = None
257
+ if animation:
258
+ anim_b64 = get_vrma_base64(animation)
259
+
260
+ # Build update payload
261
+ emotion_data = EMOTIONS_MAP.get(emotion, EMOTIONS_MAP["neutral"])
262
+ payload = json.dumps({
263
+ "type": "chat_response",
264
+ "emotion": emotion,
265
+ "blinking": emotion_data["blinking"],
266
+ "animation_b64": anim_b64,
267
+ "animation_name": animation,
268
+ "audio_b64": audio_b64,
269
+ "intensity": intensity,
270
+ "response": response_text,
271
+ })
272
+
273
+ new_history = chat_history + [[user_message, response_text]]
274
+ return new_history, state, audio_b64, payload
275
+
276
+
277
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
278
+ CSS = """
279
+ /* ── Reset & base ── */
280
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
281
+
282
+ body, .gradio-container {
283
+ background: #0a0a0f !important;
284
+ font-family: 'Segoe UI', system-ui, sans-serif;
285
+ color: #e8e8f0;
286
+ overflow: hidden;
287
+ height: 100vh;
288
+ width: 100vw;
289
+ }
290
+
291
+ /* Hide default Gradio chrome */
292
+ .gradio-container > .main > .wrap { padding: 0 !important; }
293
+ footer, .built-with { display: none !important; }
294
+ #component-0 { height: 100vh !important; padding: 0 !important; }
295
+
296
+ /* ── Layout ── */
297
+ #ani-root {
298
+ display: grid;
299
+ grid-template-columns: 1fr 380px;
300
+ grid-template-rows: 100vh;
301
+ width: 100vw;
302
+ height: 100vh;
303
+ overflow: hidden;
304
+ position: fixed;
305
+ top: 0; left: 0;
306
+ }
307
+
308
+ /* ── VRM Canvas Side ── */
309
+ #vrm-side {
310
+ position: relative;
311
+ background: linear-gradient(135deg, #0d0d1a 0%, #0a0a14 50%, #0d0d1a 100%);
312
+ overflow: hidden;
313
+ }
314
+
315
+ #vrm-side::before {
316
+ content: '';
317
+ position: absolute;
318
+ inset: 0;
319
+ background:
320
+ radial-gradient(ellipse 60% 40% at 50% 100%, rgba(99,102,241,0.12) 0%, transparent 70%),
321
+ radial-gradient(ellipse 40% 30% at 20% 80%, rgba(139,92,246,0.08) 0%, transparent 60%);
322
+ pointer-events: none;
323
+ z-index: 1;
324
+ }
325
+
326
+ /* Floor reflection */
327
+ #vrm-side::after {
328
+ content: '';
329
+ position: absolute;
330
+ bottom: 0; left: 0; right: 0;
331
+ height: 35%;
332
+ background: linear-gradient(to top, rgba(99,102,241,0.06) 0%, transparent 100%);
333
+ pointer-events: none;
334
+ z-index: 1;
335
+ }
336
+
337
+ #vrm-canvas-wrap {
338
+ position: absolute;
339
+ inset: 0;
340
+ z-index: 2;
341
+ }
342
+
343
+ #vrm-canvas-wrap canvas {
344
+ width: 100% !important;
345
+ height: 100% !important;
346
+ display: block;
347
+ }
348
+
349
+ /* Ambient particles */
350
+ .particle {
351
+ position: absolute;
352
+ border-radius: 50%;
353
+ pointer-events: none;
354
+ animation: float-particle linear infinite;
355
+ z-index: 3;
356
+ }
357
+ @keyframes float-particle {
358
+ 0% { transform: translateY(100vh) scale(0); opacity: 0; }
359
+ 10% { opacity: 1; }
360
+ 90% { opacity: 0.6; }
361
+ 100% { transform: translateY(-10vh) scale(1); opacity: 0; }
362
+ }
363
+
364
+ /* ── Status bar ── */
365
+ #status-bar {
366
+ position: absolute;
367
+ top: 20px; left: 20px;
368
+ display: flex; align-items: center; gap: 10px;
369
+ z-index: 10;
370
+ background: rgba(10,10,20,0.6);
371
+ backdrop-filter: blur(12px);
372
+ border: 1px solid rgba(255,255,255,0.08);
373
+ border-radius: 24px;
374
+ padding: 8px 16px;
375
+ }
376
+
377
+ #status-dot {
378
+ width: 8px; height: 8px;
379
+ border-radius: 50%;
380
+ background: #4ade80;
381
+ box-shadow: 0 0 8px #4ade80;
382
+ animation: pulse-dot 2s ease-in-out infinite;
383
+ }
384
+ @keyframes pulse-dot {
385
+ 0%, 100% { box-shadow: 0 0 8px #4ade80; }
386
+ 50% { box-shadow: 0 0 16px #4ade80, 0 0 24px rgba(74,222,128,0.4); }
387
+ }
388
+
389
+ #status-text {
390
+ font-size: 12px;
391
+ color: rgba(255,255,255,0.7);
392
+ letter-spacing: 0.5px;
393
+ }
394
+
395
+ /* ── Emotion badge ── */
396
+ #emotion-badge {
397
+ position: absolute;
398
+ bottom: 24px; left: 24px;
399
+ z-index: 10;
400
+ background: rgba(10,10,20,0.7);
401
+ backdrop-filter: blur(16px);
402
+ border: 1px solid rgba(99,102,241,0.3);
403
+ border-radius: 16px;
404
+ padding: 10px 18px;
405
+ display: flex; align-items: center; gap: 10px;
406
+ transition: all 0.4s ease;
407
+ }
408
+
409
+ #emotion-icon { font-size: 22px; transition: all 0.3s ease; }
410
+ #emotion-label {
411
+ font-size: 13px;
412
+ font-weight: 600;
413
+ color: rgba(255,255,255,0.9);
414
+ text-transform: capitalize;
415
+ letter-spacing: 0.5px;
416
+ }
417
+
418
+ /* Waveform visualizer */
419
+ #audio-visualizer {
420
+ position: absolute;
421
+ bottom: 24px; right: 24px;
422
+ z-index: 10;
423
+ display: flex; align-items: center; gap: 3px;
424
+ height: 40px;
425
+ opacity: 0;
426
+ transition: opacity 0.3s;
427
+ }
428
+ #audio-visualizer.active { opacity: 1; }
429
+ .wave-bar {
430
+ width: 3px;
431
+ background: linear-gradient(to top, #6366f1, #a78bfa);
432
+ border-radius: 2px;
433
+ animation: wave-anim 0.6s ease-in-out infinite alternate;
434
+ }
435
+ @keyframes wave-anim {
436
+ from { transform: scaleY(0.2); }
437
+ to { transform: scaleY(1); }
438
+ }
439
+
440
+ /* ── Chat Side ── */
441
+ #chat-side {
442
+ display: flex;
443
+ flex-direction: column;
444
+ background: rgba(8,8,16,0.95);
445
+ border-left: 1px solid rgba(255,255,255,0.06);
446
+ backdrop-filter: blur(20px);
447
+ position: relative;
448
+ overflow: hidden;
449
+ }
450
+
451
+ /* Top glow */
452
+ #chat-side::before {
453
+ content: '';
454
+ position: absolute;
455
+ top: -60px; left: 50%;
456
+ transform: translateX(-50%);
457
+ width: 200px; height: 120px;
458
+ background: radial-gradient(ellipse, rgba(99,102,241,0.3) 0%, transparent 70%);
459
+ pointer-events: none;
460
+ z-index: 0;
461
+ }
462
+
463
+ /* ── Chat Header ── */
464
+ #chat-header {
465
+ position: relative;
466
+ z-index: 5;
467
+ padding: 20px 24px 16px;
468
+ border-bottom: 1px solid rgba(255,255,255,0.06);
469
+ background: rgba(12,12,24,0.8);
470
+ flex-shrink: 0;
471
+ }
472
+
473
+ #chat-title {
474
+ font-size: 18px;
475
+ font-weight: 700;
476
+ background: linear-gradient(135deg, #a78bfa, #6366f1, #60a5fa);
477
+ -webkit-background-clip: text;
478
+ -webkit-text-fill-color: transparent;
479
+ background-clip: text;
480
+ letter-spacing: 0.5px;
481
+ }
482
+
483
+ #chat-subtitle {
484
+ font-size: 11px;
485
+ color: rgba(255,255,255,0.4);
486
+ margin-top: 3px;
487
+ letter-spacing: 0.5px;
488
+ }
489
+
490
+ #model-tag {
491
+ display: inline-flex;
492
+ align-items: center;
493
+ gap: 5px;
494
+ margin-top: 8px;
495
+ background: rgba(99,102,241,0.15);
496
+ border: 1px solid rgba(99,102,241,0.3);
497
+ border-radius: 20px;
498
+ padding: 3px 10px;
499
+ font-size: 10px;
500
+ color: #a78bfa;
501
+ }
502
+
503
+ /* ── Messages ── */
504
+ #messages-wrap {
505
+ flex: 1;
506
+ overflow-y: auto;
507
+ padding: 20px 16px;
508
+ display: flex;
509
+ flex-direction: column;
510
+ gap: 14px;
511
+ position: relative;
512
+ z-index: 2;
513
+ scroll-behavior: smooth;
514
+ }
515
+
516
+ #messages-wrap::-webkit-scrollbar { width: 4px; }
517
+ #messages-wrap::-webkit-scrollbar-track { background: transparent; }
518
+ #messages-wrap::-webkit-scrollbar-thumb {
519
+ background: rgba(99,102,241,0.3);
520
+ border-radius: 2px;
521
+ }
522
+
523
+ .msg-row {
524
+ display: flex;
525
+ gap: 10px;
526
+ animation: msg-in 0.3s ease;
527
+ }
528
+ @keyframes msg-in {
529
+ from { opacity: 0; transform: translateY(8px); }
530
+ to { opacity: 1; transform: translateY(0); }
531
+ }
532
+
533
+ .msg-row.user { flex-direction: row-reverse; }
534
+
535
+ .msg-avatar {
536
+ width: 30px; height: 30px;
537
+ border-radius: 50%;
538
+ flex-shrink: 0;
539
+ display: flex; align-items: center; justify-content: center;
540
+ font-size: 14px;
541
+ }
542
+
543
+ .msg-avatar.ai {
544
+ background: linear-gradient(135deg, #6366f1, #a78bfa);
545
+ box-shadow: 0 0 12px rgba(99,102,241,0.4);
546
+ }
547
+
548
+ .msg-avatar.user-av {
549
+ background: linear-gradient(135deg, #374151, #4b5563);
550
+ }
551
+
552
+ .msg-bubble {
553
+ max-width: 82%;
554
+ padding: 10px 14px;
555
+ border-radius: 16px;
556
+ font-size: 13.5px;
557
+ line-height: 1.6;
558
+ position: relative;
559
+ }
560
+
561
+ .msg-bubble.ai {
562
+ background: rgba(99,102,241,0.12);
563
+ border: 1px solid rgba(99,102,241,0.2);
564
+ border-radius: 4px 16px 16px 16px;
565
+ color: rgba(255,255,255,0.9);
566
+ }
567
+
568
+ .msg-bubble.user {
569
+ background: linear-gradient(135deg, rgba(99,102,241,0.25), rgba(139,92,246,0.2));
570
+ border: 1px solid rgba(139,92,246,0.25);
571
+ border-radius: 16px 4px 16px 16px;
572
+ color: rgba(255,255,255,0.95);
573
+ }
574
+
575
+ .msg-time {
576
+ font-size: 10px;
577
+ color: rgba(255,255,255,0.3);
578
+ margin-top: 4px;
579
+ text-align: right;
580
+ }
581
+
582
+ /* Typing indicator */
583
+ #typing-indicator {
584
+ display: none;
585
+ align-items: center;
586
+ gap: 10px;
587
+ padding: 4px 0;
588
+ }
589
+ #typing-indicator.visible { display: flex; }
590
+ .typing-dot {
591
+ width: 6px; height: 6px;
592
+ border-radius: 50%;
593
+ background: #6366f1;
594
+ animation: typing-bounce 1.2s ease-in-out infinite;
595
+ }
596
+ .typing-dot:nth-child(2) { animation-delay: 0.2s; }
597
+ .typing-dot:nth-child(3) { animation-delay: 0.4s; }
598
+ @keyframes typing-bounce {
599
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
600
+ 30% { transform: translateY(-6px); opacity: 1; }
601
+ }
602
+
603
+ /* ── Input Area ── */
604
+ #input-area {
605
+ position: relative;
606
+ z-index: 5;
607
+ padding: 16px;
608
+ border-top: 1px solid rgba(255,255,255,0.06);
609
+ background: rgba(10,10,20,0.9);
610
+ flex-shrink: 0;
611
+ }
612
+
613
+ #input-row {
614
+ display: flex;
615
+ gap: 10px;
616
+ align-items: flex-end;
617
+ }
618
+
619
+ #user-input-wrap {
620
+ flex: 1;
621
+ position: relative;
622
+ }
623
+
624
+ #user-input {
625
+ width: 100%;
626
+ background: rgba(255,255,255,0.05);
627
+ border: 1px solid rgba(255,255,255,0.1);
628
+ border-radius: 20px;
629
+ padding: 12px 18px;
630
+ color: rgba(255,255,255,0.9);
631
+ font-size: 14px;
632
+ resize: none;
633
+ outline: none;
634
+ font-family: inherit;
635
+ line-height: 1.5;
636
+ max-height: 120px;
637
+ transition: border-color 0.2s, box-shadow 0.2s;
638
+ min-height: 46px;
639
+ }
640
+
641
+ #user-input:focus {
642
+ border-color: rgba(99,102,241,0.5);
643
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
644
+ }
645
+
646
+ #user-input::placeholder { color: rgba(255,255,255,0.3); }
647
+
648
+ #send-btn {
649
+ width: 46px; height: 46px;
650
+ border-radius: 50%;
651
+ border: none;
652
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
653
+ color: white;
654
+ font-size: 18px;
655
+ cursor: pointer;
656
+ display: flex; align-items: center; justify-content: center;
657
+ flex-shrink: 0;
658
+ transition: all 0.2s;
659
+ box-shadow: 0 4px 12px rgba(99,102,241,0.4);
660
+ }
661
+
662
+ #send-btn:hover {
663
+ transform: scale(1.05);
664
+ box-shadow: 0 6px 20px rgba(99,102,241,0.6);
665
+ }
666
+
667
+ #send-btn:active { transform: scale(0.95); }
668
+ #send-btn:disabled {
669
+ opacity: 0.5;
670
+ cursor: not-allowed;
671
+ transform: none;
672
+ }
673
+
674
+ /* Loading overlay */
675
+ #loading-overlay {
676
+ position: fixed;
677
+ inset: 0;
678
+ background: rgba(8,8,16,0.97);
679
+ z-index: 1000;
680
+ display: flex;
681
+ flex-direction: column;
682
+ align-items: center;
683
+ justify-content: center;
684
+ gap: 24px;
685
+ transition: opacity 0.8s ease;
686
+ }
687
+
688
+ #loading-overlay.hidden {
689
+ opacity: 0;
690
+ pointer-events: none;
691
+ }
692
+
693
+ .loading-logo {
694
+ font-size: 64px;
695
+ animation: logo-pulse 2s ease-in-out infinite;
696
+ }
697
+ @keyframes logo-pulse {
698
+ 0%, 100% { transform: scale(1); filter: drop-shadow(0 0 20px rgba(99,102,241,0.6)); }
699
+ 50% { transform: scale(1.05); filter: drop-shadow(0 0 40px rgba(167,139,250,0.8)); }
700
+ }
701
+
702
+ .loading-title {
703
+ font-size: 28px;
704
+ font-weight: 700;
705
+ background: linear-gradient(135deg, #a78bfa, #6366f1, #60a5fa);
706
+ -webkit-background-clip: text;
707
+ -webkit-text-fill-color: transparent;
708
+ background-clip: text;
709
+ }
710
+
711
+ .loading-bar-wrap {
712
+ width: 200px;
713
+ height: 3px;
714
+ background: rgba(255,255,255,0.1);
715
+ border-radius: 2px;
716
+ overflow: hidden;
717
+ }
718
+
719
+ .loading-bar {
720
+ height: 100%;
721
+ background: linear-gradient(90deg, #6366f1, #a78bfa, #60a5fa);
722
+ border-radius: 2px;
723
+ animation: loading-sweep 1.8s ease-in-out infinite;
724
+ }
725
+ @keyframes loading-sweep {
726
+ 0% { width: 0%; margin-left: 0%; }
727
+ 50% { width: 60%; margin-left: 20%; }
728
+ 100% { width: 0%; margin-left: 100%; }
729
+ }
730
+
731
+ .loading-status {
732
+ font-size: 13px;
733
+ color: rgba(255,255,255,0.5);
734
+ letter-spacing: 0.5px;
735
+ }
736
+
737
+ /* Gradio hidden elements */
738
+ #hidden-output, #hidden-payload { display: none !important; }
739
+ .gradio-chatbot { display: none !important; }
740
+
741
+ /* Audio player hidden */
742
+ #hidden-audio { display: none !important; }
743
+
744
+ /* Lip sync ring */
745
+ #lipsync-ring {
746
+ position: absolute;
747
+ bottom: 70px; right: 20px;
748
+ width: 50px; height: 50px;
749
+ border-radius: 50%;
750
+ border: 2px solid rgba(99,102,241,0.3);
751
+ display: flex; align-items: center; justify-content: center;
752
+ z-index: 10;
753
+ opacity: 0;
754
+ transition: opacity 0.3s;
755
+ }
756
+ #lipsync-ring.active { opacity: 1; }
757
+ #lipsync-ring::before {
758
+ content: '🎙';
759
+ font-size: 20px;
760
+ animation: mic-pulse 0.5s ease-in-out infinite alternate;
761
+ }
762
+ @keyframes mic-pulse {
763
+ from { transform: scale(0.9); }
764
+ to { transform: scale(1.1); }
765
+ }
766
+
767
+ /* Mobile responsiveness */
768
+ @media (max-width: 768px) {
769
+ #ani-root {
770
+ grid-template-columns: 1fr;
771
+ grid-template-rows: 50vh 50vh;
772
+ }
773
+ #chat-side { border-left: none; border-top: 1px solid rgba(255,255,255,0.06); }
774
+ }
775
+ """
776
+
777
+ # ── Three.js + @pixiv/three-vrm HTML ─────────────────────────────────────────
778
+ VRM_JS = """
779
+ <script type="importmap">
780
+ {
781
+ "imports": {
782
+ "three": "https://cdn.jsdelivr.net/npm/three@0.168.0/build/three.module.js",
783
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.168.0/examples/jsm/",
784
+ "@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3.1.3/lib/three-vrm.module.js",
785
+ "@pixiv/three-vrm-animation": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm-animation@3.1.3/lib/three-vrm-animation.module.js"
786
+ }
787
+ }
788
+ </script>
789
+
790
+ <script type="module">
791
+ import * as THREE from 'three';
792
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
793
+ import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
794
+ import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation';
795
+
796
+ // ── Globals ──────────────────────────────────────────────────────────────────
797
+ let renderer, scene, camera, clock;
798
+ let currentVRM = null;
799
+ let currentMixer = null;
800
+ let currentAction = null;
801
+ let idleAction = null;
802
+ let blinkInterval = null;
803
+ let isBlinking = false;
804
+ let autoBlinkEnabled = true;
805
+ let currentEmotion = 'neutral';
806
+ let isProcessing = false;
807
+ let lipSyncActive = false;
808
+ let lipSyncInterval = null;
809
+ let audioCtx = null;
810
+ let audioAnalyser = null;
811
+ let audioSource = null;
812
+
813
+ const EMOTION_EXPRESSIONS = {
814
+ happy: { name: 'happy', weight: 1.0 },
815
+ sad: { name: 'sad', weight: 1.0 },
816
+ angry: { name: 'angry', weight: 1.0 },
817
+ surprised: { name: 'surprised',weight: 1.0 },
818
+ fearful: { name: 'sad', weight: 0.8 },
819
+ disgusted: { name: 'angry', weight: 0.7 },
820
+ neutral: { name: 'neutral', weight: 0.0 },
821
+ excited: { name: 'happy', weight: 1.0 },
822
+ confused: { name: 'surprised',weight: 0.6 },
823
+ thinking: { name: 'neutral', weight: 0.0 },
824
+ laughing: { name: 'happy', weight: 1.0 },
825
+ crying: { name: 'sad', weight: 1.0 },
826
+ love: { name: 'happy', weight: 0.9 },
827
+ shy: { name: 'happy', weight: 0.5 },
828
+ bored: { name: 'neutral', weight: 0.0 },
829
+ sleepy: { name: 'relaxed', weight: 0.8 },
830
+ determined: { name: 'angry', weight: 0.4 },
831
+ proud: { name: 'happy', weight: 0.8 },
832
+ embarrassed:{ name: 'sad', weight: 0.5 },
833
+ grateful: { name: 'happy', weight: 0.7 },
834
+ playful: { name: 'happy', weight: 0.9 },
835
+ serious: { name: 'neutral', weight: 0.0 },
836
+ };
837
+
838
+ const BLINK_DISABLED_EMOTIONS = new Set([
839
+ 'angry', 'surprised', 'laughing', 'crying', 'sleepy'
840
+ ]);
841
+
842
+ // ── Init Three.js ─────────────────────────────────────────────────────────────
843
+ function initThree() {
844
+ const wrap = document.getElementById('vrm-canvas-wrap');
845
+ if (!wrap) return;
846
+
847
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
848
+ renderer.setPixelRatio(window.devicePixelRatio);
849
+ renderer.setSize(wrap.clientWidth, wrap.clientHeight);
850
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
851
+ renderer.shadowMap.enabled = true;
852
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
853
+ wrap.appendChild(renderer.domElement);
854
+
855
+ scene = new THREE.Scene();
856
+ scene.fog = new THREE.Fog(0x0a0a14, 8, 20);
857
+
858
+ // Camera
859
+ camera = new THREE.PerspectiveCamera(30, wrap.clientWidth / wrap.clientHeight, 0.1, 100);
860
+ camera.position.set(0, 1.4, 3.2);
861
+ camera.lookAt(0, 1.0, 0);
862
+
863
+ // Lights
864
+ const ambient = new THREE.AmbientLight(0x6366f1, 0.4);
865
+ scene.add(ambient);
866
+
867
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.2);
868
+ keyLight.position.set(2, 3, 2);
869
+ keyLight.castShadow = true;
870
+ scene.add(keyLight);
871
+
872
+ const fillLight = new THREE.DirectionalLight(0xa78bfa, 0.5);
873
+ fillLight.position.set(-2, 2, 1);
874
+ scene.add(fillLight);
875
+
876
+ const rimLight = new THREE.DirectionalLight(0x60a5fa, 0.4);
877
+ rimLight.position.set(0, 2, -3);
878
+ scene.add(rimLight);
879
+
880
+ const pointLight = new THREE.PointLight(0x6366f1, 0.6, 5);
881
+ pointLight.position.set(0, 0.5, 1.5);
882
+ scene.add(pointLight);
883
+
884
+ // Floor
885
+ const floorGeo = new THREE.PlaneGeometry(10, 10);
886
+ const floorMat = new THREE.MeshStandardMaterial({
887
+ color: 0x0d0d1a,
888
+ roughness: 0.8,
889
+ metalness: 0.1,
890
+ transparent: true,
891
+ opacity: 0.6,
892
+ });
893
+ const floor = new THREE.Mesh(floorGeo, floorMat);
894
+ floor.rotation.x = -Math.PI / 2;
895
+ floor.receiveShadow = true;
896
+ scene.add(floor);
897
+
898
+ // Grid helper
899
+ const grid = new THREE.GridHelper(8, 20, 0x6366f1, 0x1a1a2e);
900
+ grid.material.opacity = 0.15;
901
+ grid.material.transparent = true;
902
+ scene.add(grid);
903
+
904
+ clock = new THREE.Clock();
905
+
906
+ // Resize handler
907
+ window.addEventListener('resize', () => {
908
+ if (!wrap) return;
909
+ camera.aspect = wrap.clientWidth / wrap.clientHeight;
910
+ camera.updateProjectionMatrix();
911
+ renderer.setSize(wrap.clientWidth, wrap.clientHeight);
912
+ });
913
+
914
+ animate();
915
+ }
916
+
917
+ // ── Load VRM ──────────────────────────────────────────────────────────────────
918
+ function loadVRMFromBase64(b64) {
919
+ const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
920
+ const blob = new Blob([bytes], { type: 'application/octet-stream' });
921
+ const url = URL.createObjectURL(blob);
922
+
923
+ const loader = new GLTFLoader();
924
+ loader.register(parser => new VRMLoaderPlugin(parser));
925
+ loader.register(parser => new VRMAnimationLoaderPlugin(parser));
926
+
927
+ loader.load(url, (gltf) => {
928
+ const vrm = gltf.userData.vrm;
929
+ if (!vrm) { console.error('No VRM found'); return; }
930
+
931
+ VRMUtils.removeUnnecessaryJoints(vrm.scene);
932
+ VRMUtils.removeUnnecessaryVertices(vrm.scene);
933
+
934
+ if (currentVRM) {
935
+ scene.remove(currentVRM.scene);
936
+ VRMUtils.deepDispose(currentVRM.scene);
937
+ }
938
+
939
+ currentVRM = vrm;
940
+ scene.add(vrm.scene);
941
+
942
+ // Face camera
943
+ vrm.scene.rotation.y = Math.PI;
944
+
945
+ // Center model
946
+ const box = new THREE.Box3().setFromObject(vrm.scene);
947
+ const center = box.getCenter(new THREE.Vector3());
948
+ vrm.scene.position.sub(center);
949
+ vrm.scene.position.y = 0;
950
+
951
+ URL.revokeObjectURL(url);
952
+
953
+ setStatus('Ani is ready ✨');
954
+ hideLoadingOverlay();
955
+ startAutoBlink();
956
+
957
+ // Load idle animation via JS custom event
958
+ document.dispatchEvent(new CustomEvent('vrm-load-idle'));
959
+
960
+ }, undefined, (err) => {
961
+ console.error('VRM load error:', err);
962
+ setStatus('VRM load failed');
963
+ hideLoadingOverlay();
964
+ });
965
+ }
966
+
967
+ // ── Load VRMA ─────────────────────────────────────────────────────────────────
968
+ function loadVRMAFromBase64(b64, loop = true, onDone = null) {
969
+ if (!currentVRM) return;
970
+
971
+ const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
972
+ const blob = new Blob([bytes], { type: 'application/octet-stream' });
973
+ const url = URL.createObjectURL(blob);
974
+
975
+ const loader = new GLTFLoader();
976
+ loader.register(parser => new VRMAnimationLoaderPlugin(parser));
977
+
978
+ loader.load(url, (gltf) => {
979
+ const vrmAnimations = gltf.userData.vrmAnimations;
980
+ if (!vrmAnimations || vrmAnimations.length === 0) {
981
+ URL.revokeObjectURL(url);
982
+ return;
983
+ }
984
+
985
+ const clip = createVRMAnimationClip(vrmAnimations[0], currentVRM);
986
+
987
+ if (currentMixer) {
988
+ currentMixer.stopAllAction();
989
  }
990
+ currentMixer = new THREE.AnimationMixer(currentVRM.scene);
991
+ currentAction = currentMixer.clipAction(clip);
992
+ currentAction.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
993
+ currentAction.clampWhenFinished = !loop;
994
+ currentAction.play();
995
+
996
+ if (onDone && !loop) {
997
+ currentMixer.addEventListener('finished', () => {
998
+ onDone();
999
+ URL.revokeObjectURL(url);
1000
+ });
1001
+ } else {
1002
+ URL.revokeObjectURL(url);
1003
+ }
1004
+
1005
+ }, undefined, (err) => {
1006
+ console.error('VRMA load error:', err);
1007
+ URL.revokeObjectURL(url);
1008
+ });
1009
+ }
1010
+
1011
+ // ── Auto Blink ────────────────────────────────────────────────────────────────
1012
+ function startAutoBlink() {
1013
+ if (blinkInterval) clearInterval(blinkInterval);
1014
+ blinkInterval = setInterval(() => {
1015
+ if (!autoBlinkEnabled || !currentVRM || isBlinking) return;
1016
+ doBlink();
1017
+ }, 2500 + Math.random() * 2000);
1018
+ }
1019
+
1020
+ function doBlink() {
1021
+ if (!currentVRM || isBlinking) return;
1022
+ const expr = currentVRM.expressionManager;
1023
+ if (!expr) return;
1024
+
1025
+ isBlinking = true;
1026
+ const blinkName = 'blink';
1027
+
1028
+ let t = 0;
1029
+ const dur = 120;
1030
+ const step = 16;
1031
+
1032
+ const blink = setInterval(() => {
1033
+ t += step;
1034
+ const prog = t / dur;
1035
+ const w = prog < 0.5
1036
+ ? Math.sin(prog * Math.PI * 2)
1037
+ : Math.sin(prog * Math.PI * 2);
1038
+ const weight = Math.max(0, Math.sin(prog * Math.PI));
1039
+ try { expr.setValue(blinkName, weight); } catch(e){}
1040
+ if (t >= dur) {
1041
+ clearInterval(blink);
1042
+ try { expr.setValue(blinkName, 0); } catch(e){}
1043
+ isBlinking = false;
1044
+ }
1045
+ }, step);
1046
+ }
1047
+
1048
+ // ── Emotion ───────────────────────────────────────────────────────────────────
1049
+ function applyEmotion(emotionName, intensity = 1.0) {
1050
+ if (!currentVRM) return;
1051
+ const expr = currentVRM.expressionManager;
1052
+ if (!expr) return;
1053
+
1054
+ // Reset all
1055
+ const allExprs = ['happy','sad','angry','surprised','relaxed','neutral'];
1056
+ allExprs.forEach(e => { try { expr.setValue(e, 0); } catch(ex){} });
1057
+
1058
+ const mapping = EMOTION_EXPRESSIONS[emotionName] || EMOTION_EXPRESSIONS.neutral;
1059
+ if (mapping.weight > 0) {
1060
+ try { expr.setValue(mapping.name, mapping.weight * intensity); } catch(e){}
1061
+ }
1062
+
1063
+ // Blink control
1064
+ autoBlinkEnabled = !BLINK_DISABLED_EMOTIONS.has(emotionName);
1065
+
1066
+ // Update badge
1067
+ updateEmotionBadge(emotionName);
1068
+ currentEmotion = emotionName;
1069
+ }
1070
+
1071
+ const EMOTION_ICONS = {
1072
+ happy:'😊', sad:'😢', angry:'😠', surprised:'😲', fearful:'😨',
1073
+ disgusted:'🤢', neutral:'😐', excited:'🤩', confused:'😕', thinking:'🤔',
1074
+ laughing:'😄', crying:'😭', love:'🥰', shy:'😊', bored:'😑',
1075
+ sleepy:'😴', determined:'💪', proud:'🎉', embarrassed:'😳',
1076
+ grateful:'🙏', playful:'😜', serious:'😤',
1077
+ };
1078
+
1079
+ function updateEmotionBadge(emotion) {
1080
+ const icon = document.getElementById('emotion-icon');
1081
+ const label = document.getElementById('emotion-label');
1082
+ if (icon) icon.textContent = EMOTION_ICONS[emotion] || '😐';
1083
+ if (label) label.textContent = emotion;
1084
+ }
1085
+
1086
+ // ── Lip Sync ──────────────────────────────────────────────────────────────────
1087
+ function startLipSync(audioBuffer) {
1088
+ if (!currentVRM) return;
1089
+ const expr = currentVRM.expressionManager;
1090
+ if (!expr) return;
1091
+
1092
+ stopLipSync();
1093
+
1094
+ // Create audio context and analyse
1095
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1096
+
1097
+ audioCtx.decodeAudioData(audioBuffer.slice(0), (decoded) => {
1098
+ audioSource = audioCtx.createBufferSource();
1099
+ audioSource.buffer = decoded;
1100
+
1101
+ audioAnalyser = audioCtx.createAnalyser();
1102
+ audioAnalyser.fftSize = 256;
1103
+
1104
+ audioSource.connect(audioAnalyser);
1105
+ audioAnalyser.connect(audioCtx.destination);
1106
+ audioSource.start(0);
1107
+
1108
+ const dataArray = new Uint8Array(audioAnalyser.frequencyBinCount);
1109
+ const ringEl = document.getElementById('lipsync-ring');
1110
+ const vizEl = document.getElementById('audio-visualizer');
1111
+ if (ringEl) ringEl.classList.add('active');
1112
+ if (vizEl) vizEl.classList.add('active');
1113
+
1114
+ lipSyncInterval = setInterval(() => {
1115
+ audioAnalyser.getByteFrequencyData(dataArray);
1116
+ // Voice frequencies ~300-3000Hz
1117
+ const voiceBins = dataArray.slice(2, 20);
1118
+ const avg = voiceBins.reduce((a,b) => a+b, 0) / voiceBins.length;
1119
+ const mouth = Math.min(1.0, avg / 80);
1120
+
1121
+ try { expr.setValue('aa', mouth * 0.8); } catch(e){}
1122
+ try { expr.setValue('oh', mouth * 0.3); } catch(e){}
1123
+
1124
+ // Disable auto blink during speaking
1125
+ autoBlinkEnabled = false;
1126
+
1127
+ }, 33);
1128
+
1129
+ audioSource.addEventListener('ended', () => {
1130
+ stopLipSync();
1131
+ autoBlinkEnabled = !BLINK_DISABLED_EMOTIONS.has(currentEmotion);
1132
+ });
1133
+ }, () => {
1134
+ // Fallback: manual lip sync
1135
+ manualLipSync();
1136
+ });
1137
+ }
1138
+
1139
+ function manualLipSync() {
1140
+ if (!currentVRM) return;
1141
+ const expr = currentVRM.expressionManager;
1142
+ if (!expr) return;
1143
+
1144
+ let t = 0;
1145
+ const ringEl = document.getElementById('lipsync-ring');
1146
+ const vizEl = document.getElementById('audio-visualizer');
1147
+ if (ringEl) ringEl.classList.add('active');
1148
+ if (vizEl) vizEl.classList.add('active');
1149
+
1150
+ lipSyncInterval = setInterval(() => {
1151
+ t += 0.1;
1152
+ const mouth = 0.3 + 0.5 * Math.abs(Math.sin(t * 8)) * Math.abs(Math.sin(t * 3));
1153
+ try { expr.setValue('aa', mouth); } catch(e){}
1154
+ }, 50);
1155
+ }
1156
+
1157
+ function stopLipSync() {
1158
+ if (lipSyncInterval) { clearInterval(lipSyncInterval); lipSyncInterval = null; }
1159
+ if (audioSource) { try { audioSource.stop(); } catch(e){} audioSource = null; }
1160
+
1161
+ if (currentVRM) {
1162
+ const expr = currentVRM.expressionManager;
1163
+ if (expr) {
1164
+ try { expr.setValue('aa', 0); } catch(e){}
1165
+ try { expr.setValue('oh', 0); } catch(e){}
1166
+ }
1167
+ }
1168
+ const ringEl = document.getElementById('lipsync-ring');
1169
+ const vizEl = document.getElementById('audio-visualizer');
1170
+ if (ringEl) ringEl.classList.remove('active');
1171
+ if (vizEl) vizEl.classList.remove('active');
1172
+ }
1173
+
1174
+ // ── Animate loop ──────────────────────────────────────────────────────────────
1175
+ function animate() {
1176
+ requestAnimationFrame(animate);
1177
+ const delta = clock.getDelta();
1178
+
1179
+ if (currentMixer) currentMixer.update(delta);
1180
+ if (currentVRM) {
1181
+ // Gentle breathing / idle movement
1182
+ const t = clock.getElapsedTime();
1183
+ if (currentVRM.humanoid) {
1184
+ try {
1185
+ const spine = currentVRM.humanoid.getNormalizedBoneNode('spine');
1186
+ if (spine) {
1187
+ spine.rotation.z = Math.sin(t * 0.5) * 0.01;
1188
+ spine.rotation.x = Math.sin(t * 0.3) * 0.008;
1189
+ }
1190
+ const head = currentVRM.humanoid.getNormalizedBoneNode('head');
1191
+ if (head) {
1192
+ head.rotation.y = Math.sin(t * 0.4) * 0.03;
1193
+ head.rotation.x = Math.sin(t * 0.25) * 0.015;
1194
+ }
1195
+ } catch(e){}
1196
+ }
1197
+ currentVRM.update(delta);
1198
+ }
1199
+
1200
+ renderer.render(scene, camera);
1201
+ }
1202
+
1203
+ // ── UI helpers ────────────────────────────────────────────────────────────────
1204
+ function setStatus(text) {
1205
+ const el = document.getElementById('status-text');
1206
+ if (el) el.textContent = text;
1207
+ }
1208
+
1209
+ function hideLoadingOverlay() {
1210
+ const overlay = document.getElementById('loading-overlay');
1211
+ if (overlay) {
1212
+ setTimeout(() => overlay.classList.add('hidden'), 500);
1213
+ }
1214
+ }
1215
+
1216
+ // ── Gradio bridge ─────────────────────────────────────────────────────────────
1217
+ function handlePayload(payload) {
1218
+ if (!payload) return;
1219
+ let data;
1220
+ try { data = JSON.parse(payload); } catch(e) { return; }
1221
+ if (!data || data.type !== 'chat_response') return;
1222
+
1223
+ // Emotion
1224
+ if (data.emotion) applyEmotion(data.emotion, data.intensity || 0.8);
1225
+
1226
+ // Animation
1227
+ if (data.animation_b64) {
1228
+ loadVRMAFromBase64(data.animation_b64, false, () => {
1229
+ // Return to idle
1230
+ document.dispatchEvent(new CustomEvent('vrm-load-idle'));
1231
+ });
1232
+ }
1233
+
1234
+ // Audio + lip sync
1235
+ if (data.audio_b64) {
1236
+ const binary = atob(data.audio_b64);
1237
+ const bytes = new Uint8Array(binary.length);
1238
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1239
+ startLipSync(bytes.buffer);
1240
+ }
1241
+ }
1242
+
1243
+ // Watch for payload changes via MutationObserver on hidden element
1244
+ function watchPayload() {
1245
+ const el = document.getElementById('payload-output');
1246
+ if (!el) { setTimeout(watchPayload, 500); return; }
1247
+
1248
+ let lastVal = '';
1249
+ setInterval(() => {
1250
+ const val = el.textContent || el.value || '';
1251
+ if (val && val !== lastVal) {
1252
+ lastVal = val;
1253
+ handlePayload(val);
1254
+ }
1255
+ }, 200);
1256
+ }
1257
+
1258
+ // ── Boot ──────────────────────────────────────────────────────────────────────
1259
+ document.addEventListener('DOMContentLoaded', () => {
1260
+ initThree();
1261
+ watchPayload();
1262
+ createParticles();
1263
+
1264
+ // Wait for VRM base64 from hidden element
1265
+ const waitVRM = setInterval(() => {
1266
+ const el = document.getElementById('vrm-data');
1267
+ if (el && el.textContent && el.textContent.length > 100) {
1268
+ clearInterval(waitVRM);
1269
+ setStatus('Loading Ani...');
1270
+ loadVRMFromBase64(el.textContent.trim());
1271
+ }
1272
+ }, 300);
1273
+ });
1274
+
1275
+ // Load idle animation when requested
1276
+ document.addEventListener('vrm-load-idle', () => {
1277
+ const el = document.getElementById('idle-anim-data');
1278
+ if (el && el.textContent && el.textContent.length > 100) {
1279
+ loadVRMAFromBase64(el.textContent.trim(), true);
1280
+ }
1281
+ });
1282
+
1283
+ // Particles
1284
+ function createParticles() {
1285
+ const side = document.getElementById('vrm-side');
1286
+ if (!side) return;
1287
+ const colors = ['rgba(99,102,241,', 'rgba(139,92,246,', 'rgba(96,165,250,'];
1288
+ for (let i = 0; i < 15; i++) {
1289
+ const p = document.createElement('div');
1290
+ p.className = 'particle';
1291
+ const size = 2 + Math.random() * 4;
1292
+ const color = colors[Math.floor(Math.random() * colors.length)];
1293
+ p.style.cssText = `
1294
+ width:${size}px; height:${size}px;
1295
+ left:${Math.random()*100}%;
1296
+ background:${color}${0.3 + Math.random()*0.4});
1297
+ box-shadow:0 0 ${size*2}px ${color}0.6);
1298
+ animation-duration:${8 + Math.random()*12}s;
1299
+ animation-delay:${Math.random()*10}s;
1300
+ `;
1301
+ side.appendChild(p);
1302
+ }
1303
+ }
1304
+
1305
+ // Wave bars
1306
+ document.addEventListener('DOMContentLoaded', () => {
1307
+ const viz = document.getElementById('audio-visualizer');
1308
+ if (viz) {
1309
+ for (let i = 0; i < 8; i++) {
1310
+ const bar = document.createElement('div');
1311
+ bar.className = 'wave-bar';
1312
+ bar.style.height = (15 + Math.random()*25) + 'px';
1313
+ bar.style.animationDelay = (i * 0.08) + 's';
1314
+ bar.style.animationDuration = (0.4 + Math.random()*0.3) + 's';
1315
+ viz.appendChild(bar);
1316
+ }
1317
+ }
1318
+ });
1319
+ </script>
1320
+ """
1321
+
1322
+ # ── Build HTML for VRM viewer side ────────────────────────────────────────────
1323
+ VRM_B64_SAFE = VRM_B64 or ""
1324
+ IDLE_B64_SAFE = IDLE_ANIM_B64 or ""
1325
+
1326
+ CUSTOM_HTML = f"""
1327
+ <!DOCTYPE html>
1328
+ <html lang="en">
1329
+ <head>
1330
+ <meta charset="UTF-8"/>
1331
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
1332
+ <title>Ani – AI Companion</title>
1333
+ <style>
1334
+ {CSS}
1335
+ /* Extra scoped styles */
1336
+ #vrm-canvas-wrap canvas {{ border-radius: 0; }}
1337
+ </style>
1338
  </head>
1339
+ <body>
1340
 
1341
+ <!-- Loading overlay -->
1342
+ <div id="loading-overlay">
1343
+ <div class="loading-logo">🌸</div>
1344
+ <div class="loading-title">Ani AI Companion</div>
1345
+ <div class="loading-bar-wrap"><div class="loading-bar"></div></div>
1346
+ <div class="loading-status" id="loading-status-text">Initializing...</div>
1347
+ </div>
1348
+
1349
+ <!-- Hidden data elements for JS -->
1350
+ <div id="vrm-data" style="display:none">{VRM_B64_SAFE}</div>
1351
+ <div id="idle-anim-data" style="display:none">{IDLE_B64_SAFE}</div>
1352
+ <div id="payload-output" style="display:none"></div>
1353
+
1354
+ <!-- Main Layout -->
1355
+ <div id="ani-root">
1356
+
1357
+ <!-- VRM Viewer -->
1358
+ <div id="vrm-side">
1359
+ <div id="vrm-canvas-wrap"></div>
1360
+
1361
+ <!-- Status bar -->
1362
+ <div id="status-bar">
1363
+ <div id="status-dot"></div>
1364
+ <span id="status-text">Loading model...</span>
1365
  </div>
1366
 
1367
+ <!-- Emotion badge -->
1368
+ <div id="emotion-badge">
1369
+ <span id="emotion-icon">😐</span>
1370
+ <span id="emotion-label">neutral</span>
 
1371
  </div>
1372
+
1373
+ <!-- Audio visualizer -->
1374
+ <div id="audio-visualizer"></div>
1375
+
1376
+ <!-- Lip sync ring -->
1377
+ <div id="lipsync-ring"></div>
1378
+ </div>
1379
+
1380
+ <!-- Chat Panel -->
1381
+ <div id="chat-side">
1382
+ <div id="chat-header">
1383
+ <div id="chat-title">✨ Ani</div>
1384
+ <div id="chat-subtitle">Your AI Anime Companion</div>
1385
+ <div id="model-tag">🤖 Gemma 2B · ZeroGPU</div>
1386
  </div>
1387
 
1388
+ <div id="messages-wrap" id="messages-container">
1389
+ <!-- Welcome message -->
1390
+ <div class="msg-row">
1391
+ <div class="msg-avatar ai">🌸</div>
1392
+ <div>
1393
+ <div class="msg-bubble ai">
1394
+ Hello! I'm Ani, your AI companion! ✨ How can I help you today?
1395
+ </div>
1396
+ <div class="msg-time">just now</div>
1397
+ </div>
1398
+ </div>
1399
  </div>
1400
 
1401
+ <div id="typing-indicator">
1402
+ <div class="msg-avatar ai">🌸</div>
1403
+ <div style="display:flex;align-items:center;gap:8px;padding:10px 14px;background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.2);border-radius:4px 16px 16px 16px;">
1404
+ <div class="typing-dot"></div>
1405
+ <div class="typing-dot"></div>
1406
+ <div class="typing-dot"></div>
1407
+ </div>
 
 
 
1408
  </div>
1409
+
1410
+ <div id="input-area">
1411
+ <div id="input-row">
1412
+ <div id="user-input-wrap">
1413
+ <textarea
1414
+ id="user-input"
1415
+ placeholder="Talk to Ani..."
1416
+ rows="1"
1417
+ ></textarea>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1418
  </div>
1419
+ <button id="send-btn" title="Send">➤</button>
1420
+ </div>
1421
  </div>
1422
+ </div>
1423
 
1424
+ </div>
 
 
 
1425
 
1426
+ {VRM_JS}
1427
+
1428
+ <script>
1429
+ // ── Chat UI logic ─────────────────────────────────────────────────────────────
1430
+ const messagesWrap = document.getElementById('messages-wrap');
1431
+ const userInput = document.getElementById('user-input');
1432
+ const sendBtn = document.getElementById('send-btn');
1433
+ const typingInd = document.getElementById('typing-indicator');
1434
+
1435
+ function getTime() {{
1436
+ const d = new Date();
1437
+ return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');
1438
+ }}
1439
+
1440
+ function addMessage(text, role) {{
1441
+ const row = document.createElement('div');
1442
+ row.className = `msg-row ${{role === 'user' ? 'user' : ''}}`;
1443
+
1444
+ const avatar = document.createElement('div');
1445
+ avatar.className = `msg-avatar ${{role === 'user' ? 'user-av' : 'ai'}}`;
1446
+ avatar.textContent = role === 'user' ? '👤' : '🌸';
1447
+
1448
+ const bWrap = document.createElement('div');
1449
+ const bubble = document.createElement('div');
1450
+ bubble.className = `msg-bubble ${{role}}`;
1451
+ bubble.textContent = text;
1452
+
1453
+ const time = document.createElement('div');
1454
+ time.className = 'msg-time';
1455
+ time.textContent = getTime();
1456
+
1457
+ bWrap.appendChild(bubble);
1458
+ bWrap.appendChild(time);
1459
+
1460
+ if (role === 'user') {{
1461
+ row.appendChild(bWrap);
1462
+ row.appendChild(avatar);
1463
+ }} else {{
1464
+ row.appendChild(avatar);
1465
+ row.appendChild(bWrap);
1466
+ }}
1467
+
1468
+ messagesWrap.appendChild(row);
1469
+ messagesWrap.scrollTop = messagesWrap.scrollHeight;
1470
+ }}
1471
+
1472
+ function showTyping() {{
1473
+ typingInd.classList.add('visible');
1474
+ messagesWrap.scrollTop = messagesWrap.scrollHeight;
1475
+ }}
1476
+ function hideTyping() {{
1477
+ typingInd.classList.remove('visible');
1478
+ }}
1479
+
1480
+ // Auto-resize textarea
1481
+ userInput.addEventListener('input', () => {{
1482
+ userInput.style.height = 'auto';
1483
+ userInput.style.height = Math.min(userInput.scrollHeight, 120) + 'px';
1484
+ }});
1485
+
1486
+ // Send on Enter (Shift+Enter = newline)
1487
+ userInput.addEventListener('keydown', (e) => {{
1488
+ if (e.key === 'Enter' && !e.shiftKey) {{
1489
+ e.preventDefault();
1490
+ sendMessage();
1491
+ }}
1492
+ }});
1493
+
1494
+ sendBtn.addEventListener('click', sendMessage);
1495
+
1496
+ function sendMessage() {{
1497
+ const text = userInput.value.trim();
1498
+ if (!text || sendBtn.disabled) return;
1499
+
1500
+ addMessage(text, 'user');
1501
+ userInput.value = '';
1502
+ userInput.style.height = 'auto';
1503
+
1504
+ sendBtn.disabled = true;
1505
+ showTyping();
1506
+
1507
+ // Trigger Gradio via the hidden textbox
1508
+ const hiddenInput = document.querySelector('#hidden-msg-input textarea') ||
1509
+ document.querySelector('#hidden-msg-input input');
1510
+ if (hiddenInput) {{
1511
+ hiddenInput.value = text;
1512
+ hiddenInput.dispatchEvent(new Event('input', {{bubbles:true}}));
1513
+ // Click the hidden submit button
1514
+ setTimeout(() => {{
1515
+ const btn = document.getElementById('hidden-submit-btn');
1516
+ if (btn) btn.click();
1517
+ }}, 50);
1518
+ }}
1519
+
1520
+ // Watch for response
1521
+ const observer = new MutationObserver(() => {{
1522
+ const payloadEl = document.getElementById('payload-output');
1523
+ if (!payloadEl) return;
1524
+ const val = payloadEl.textContent.trim();
1525
+ if (val && val.includes('chat_response')) {{
1526
+ try {{
1527
+ const data = JSON.parse(val);
1528
+ if (data.response) {{
1529
+ hideTyping();
1530
+ addMessage(data.response, 'ai');
1531
+ sendBtn.disabled = false;
1532
+ observer.disconnect();
1533
+ }}
1534
+ }} catch(e) {{}}
1535
+ }}
1536
+ }});
1537
+
1538
+ // Fallback timeout
1539
+ setTimeout(() => {{
1540
+ hideTyping();
1541
+ sendBtn.disabled = false;
1542
+ observer.disconnect();
1543
+ }}, 60000);
1544
+ }}
1545
+
1546
+ // Watch Gradio output and push to payload-output div
1547
+ function watchGradioOutput() {{
1548
+ setInterval(() => {{
1549
+ const el = document.querySelector('#gradio-payload-display') ||
1550
+ document.querySelector('#payload-display');
1551
+ if (!el) return;
1552
+ const txt = el.textContent || el.innerText || '';
1553
+ if (txt && txt.length > 2) {{
1554
+ const pEl = document.getElementById('payload-output');
1555
+ if (pEl && pEl.textContent !== txt) {{
1556
+ pEl.textContent = txt;
1557
+ }}
1558
+ }}
1559
+ }}, 300);
1560
+ }}
1561
+
1562
+ document.addEventListener('DOMContentLoaded', watchGradioOutput);
1563
+ </script>
1564
+
1565
+ </body>
1566
+ </html>
1567
+ """
1568
+
1569
+ # ── Gradio App ────────────────────────────────────────────────────────────────
1570
+ with gr.Blocks(
1571
+ css="""
1572
+ .gradio-container { padding: 0 !important; margin: 0 !important; }
1573
+ #hidden-controls { position:fixed; bottom:-200px; left:-200px; opacity:0; pointer-events:none; }
1574
+ """,
1575
+ title="Ani AI Companion",
1576
+ theme=gr.themes.Base(
1577
+ primary_hue="violet",
1578
+ neutral_hue="slate",
1579
+ ),
1580
+ ) as demo:
1581
+
1582
+ # State
1583
+ chat_state = gr.State([])
1584
+ payload_state = gr.State("{}")
1585
+
1586
+ # ── Main viewer (custom HTML) ──
1587
+ gr.HTML(CUSTOM_HTML)
1588
+
1589
+ # ── Hidden Gradio controls ──
1590
+ with gr.Group(elem_id="hidden-controls", visible=True):
1591
+ hidden_msg = gr.Textbox(
1592
+ label="",
1593
+ elem_id="hidden-msg-input",
1594
+ visible=False,
1595
+ )
1596
+ hidden_submit = gr.Button(
1597
+ "Send",
1598
+ elem_id="hidden-submit-btn",
1599
+ visible=False,
1600
+ )
1601
+ hidden_chatbot = gr.Chatbot(
1602
+ label="",
1603
+ elem_id="hidden-chatbot",
1604
+ visible=False,
1605
+ )
1606
+ payload_display = gr.Textbox(
1607
+ label="",
1608
+ elem_id="gradio-payload-display",
1609
+ visible=False,
1610
+ )
1611
+ audio_out = gr.Audio(
1612
+ label="",
1613
+ elem_id="hidden-audio",
1614
+ visible=False,
1615
+ autoplay=True,
1616
+ )
1617
+
1618
+ # ── Event: submit ──
1619
+ def on_submit(message, history, state):
1620
+ if not message or not message.strip():
1621
+ return history, state, "{}", None
1622
+
1623
+ new_history, new_state, audio_b64, payload = process_chat(
1624
+ message, history, state
1625
+ )
1626
+
1627
+ # Return audio as file if available
1628
+ audio_file = None
1629
+ if audio_b64:
1630
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
1631
+ tmp.write(base64.b64decode(audio_b64))
1632
+ tmp.close()
1633
+ audio_file = tmp.name
1634
+
1635
+ return new_history, new_state, payload, audio_file
1636
+
1637
+ hidden_submit.click(
1638
+ fn=on_submit,
1639
+ inputs=[hidden_msg, hidden_chatbot, chat_state],
1640
+ outputs=[hidden_chatbot, chat_state, payload_display, audio_out],
1641
+ api_name="chat",
1642
+ )
1643
+
1644
+ # JS bridge: copy payload_display → payload-output div
1645
+ payload_display.change(
1646
+ fn=None,
1647
+ inputs=[payload_display],
1648
+ outputs=None,
1649
+ js="""
1650
+ (payload) => {
1651
+ const el = document.getElementById('payload-output');
1652
+ if (el && payload) {
1653
+ el.textContent = payload;
1654
+ // Also handle audio in payload
1655
+ try {
1656
+ const data = JSON.parse(payload);
1657
+ if (data.audio_b64) {
1658
+ const audio = new Audio('data:audio/mp3;base64,' + data.audio_b64);
1659
+ audio.play().catch(e => console.log('Audio play:', e));
1660
  }
1661
+ } catch(e) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1662
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1663
  }
1664
+ """,
1665
+ )
1666
+
1667
+ # Also use audio_out change for audio playback
1668
+ audio_out.change(
1669
+ fn=None,
1670
+ inputs=[],
1671
+ outputs=None,
1672
+ js="""
1673
+ () => {
1674
+ const el = document.getElementById('payload-output');
1675
+ if (el) {
1676
+ const event = new Event('payload-updated');
1677
+ el.dispatchEvent(event);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1678
  }
1679
  }
1680
+ """,
1681
+ )
 
 
 
 
1682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1683
  if __name__ == "__main__":
1684
+ demo.launch(
1685
+ server_name="0.0.0.0",
1686
+ server_port=7860,
1687
+ show_api=False,
1688
+ share=False,
1689
+ )