OrbitMC commited on
Commit
8f9d44b
Β·
verified Β·
1 Parent(s): be44846

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +789 -1272
app.py CHANGED
@@ -22,7 +22,6 @@ def install_packages():
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)
@@ -30,39 +29,39 @@ def install_packages():
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 ─────────────────────────────────────────────────────────
@@ -98,8 +97,11 @@ def load_model():
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",
@@ -108,25 +110,19 @@ Respond ONLY with valid JSON in this exact format:
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})
@@ -134,13 +130,14 @@ def generate_response(user_message: str, chat_history: list) -> dict:
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,
@@ -150,11 +147,10 @@ def generate_response(user_message: str, chat_history: list) -> dict:
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:
@@ -166,13 +162,12 @@ def generate_response(user_message: str, chat_history: list) -> dict:
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 ───────────────────────────────────────────────────────────────────────
@@ -180,16 +175,19 @@ 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}")
@@ -200,20 +198,18 @@ 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)
@@ -223,1467 +219,988 @@ def get_idle_animation():
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
  )
 
22
  "accelerate>=0.27.0",
23
  "edge-tts",
24
  "sentencepiece",
 
25
  ]
26
  for pkg in packages:
27
  subprocess.run([sys.executable, "-m", "pip", "install", pkg, "-q"], check=False)
 
29
  install_packages()
30
 
31
  import torch
32
+ from transformers import AutoTokenizer, AutoModelForCausalLM
33
  import edge_tts
34
 
35
  # ── Constants ─────────────────────────────────────────────────────────────────
36
  MODEL_ID = "google/gemma-2-2b-it"
37
  VRM_PATH = "model/Ani.vrm"
38
  ANIM_ZIP = "animation/all_vrma.zip"
 
39
  TTS_VOICE_PRIMARY = "zh-CN-XiaoyiNeural"
40
+ TTS_VOICE_FALLBACK = "en-US-AriaNeural"
41
 
42
  EMOTIONS_MAP = {
43
+ "happy": {"blinking": True, "icon": "😊"},
44
+ "sad": {"blinking": True, "icon": "😒"},
45
+ "angry": {"blinking": False, "icon": "😠"},
46
+ "surprised": {"blinking": False, "icon": "😲"},
47
+ "fearful": {"blinking": True, "icon": "😨"},
48
+ "disgusted": {"blinking": False, "icon": "🀒"},
49
+ "neutral": {"blinking": True, "icon": "😐"},
50
+ "excited": {"blinking": True, "icon": "🀩"},
51
+ "confused": {"blinking": True, "icon": "πŸ˜•"},
52
+ "thinking": {"blinking": True, "icon": "πŸ€”"},
53
+ "laughing": {"blinking": False, "icon": "πŸ˜„"},
54
+ "crying": {"blinking": False, "icon": "😭"},
55
+ "love": {"blinking": True, "icon": "πŸ₯°"},
56
+ "shy": {"blinking": True, "icon": "😊"},
57
+ "bored": {"blinking": True, "icon": "πŸ˜‘"},
58
+ "sleepy": {"blinking": False, "icon": "😴"},
59
+ "determined": {"blinking": True, "icon": "πŸ’ͺ"},
60
+ "proud": {"blinking": True, "icon": "πŸŽ‰"},
61
+ "embarrassed":{"blinking": True, "icon": "😳"},
62
+ "grateful": {"blinking": True, "icon": "πŸ™"},
63
+ "playful": {"blinking": True, "icon": "😜"},
64
+ "serious": {"blinking": True, "icon": "😀"},
65
  }
66
 
67
  # ── Extract VRMA list ─────────────────────────────────────────────────────────
 
97
  model.eval()
98
  print("Model loaded!")
99
 
100
+ # ── System prompt ─────────────────────────────────────────────────────────────
101
+ def build_system_prompt():
102
+ emotions_str = ", ".join(EMOTIONS_MAP.keys())
103
+ anims_str = ", ".join(VRMA_LIST[:40]) if VRMA_LIST else "null"
104
+ return f"""You are Ani, a helpful and expressive AI anime companion.
105
  Respond ONLY with valid JSON in this exact format:
106
  {{
107
  "response": "your conversational reply here",
 
110
  "intensity": 0.8
111
  }}
112
 
113
+ Available emotions: {emotions_str}
114
+ Available animations: {anims_str}
115
 
116
+ Choose the most fitting emotion and animation. Keep responses friendly and concise (1-3 sentences)."""
 
117
 
118
+ # ── AI inference ──────────────────────────────────────────────────────────────
119
  @spaces.GPU(duration=60)
120
  def generate_response(user_message: str, chat_history: list) -> dict:
121
  load_model()
122
+
123
+ system = build_system_prompt()
 
 
 
 
 
 
 
124
  messages = [{"role": "system", "content": system}]
125
+
126
  for human, assistant in chat_history[-4:]:
127
  if human:
128
  messages.append({"role": "user", "content": human})
 
130
  try:
131
  parsed = json.loads(assistant)
132
  messages.append({"role": "assistant", "content": parsed.get("response", assistant)})
133
+ except Exception:
134
  messages.append({"role": "assistant", "content": assistant})
135
+
136
  messages.append({"role": "user", "content": user_message})
137
+
138
  text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
139
  inputs = tokenizer(text, return_tensors="pt").to(model.device)
140
+
141
  with torch.no_grad():
142
  outputs = model.generate(
143
  **inputs,
 
147
  do_sample=True,
148
  pad_token_id=tokenizer.eos_token_id,
149
  )
150
+
151
  generated = outputs[0][inputs["input_ids"].shape[1]:]
152
  raw = tokenizer.decode(generated, skip_special_tokens=True).strip()
153
+
 
154
  try:
155
  json_match = re.search(r'\{.*\}', raw, re.DOTALL)
156
  if json_match:
 
162
  "response": raw if raw else "Hello! How can I help you?",
163
  "emotion": "neutral",
164
  "animation": None,
165
+ "intensity": 0.7,
166
  }
167
+
 
168
  if result.get("emotion") not in EMOTIONS_MAP:
169
  result["emotion"] = "neutral"
170
+
171
  return result
172
 
173
  # ── TTS ───────────────────────────────────────────────────────────────────────
 
175
  communicate = edge_tts.Communicate(text, voice)
176
  await communicate.save(output_path)
177
 
178
+ def run_tts(text: str):
179
  tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
180
  tmp.close()
181
+ for voice in [TTS_VOICE_PRIMARY, TTS_VOICE_FALLBACK]:
182
  try:
183
  asyncio.run(_tts_async(text, voice, tmp.name))
184
+ if os.path.exists(tmp.name) and os.path.getsize(tmp.name) > 0:
185
  with open(tmp.name, "rb") as f:
186
  audio_b64 = base64.b64encode(f.read()).decode()
187
+ try:
188
+ os.unlink(tmp.name)
189
+ except Exception:
190
+ pass
191
  return audio_b64
192
  except Exception as e:
193
  print(f"TTS failed with {voice}: {e}")
 
198
  if os.path.exists(VRM_PATH):
199
  with open(VRM_PATH, "rb") as f:
200
  return base64.b64encode(f.read()).decode()
201
+ return ""
202
 
203
  def get_vrma_base64(vrma_name: str):
204
+ if not vrma_name or not os.path.exists(ANIM_ZIP):
205
+ return ""
206
  with zipfile.ZipFile(ANIM_ZIP, 'r') as z:
207
  for name in z.namelist():
208
+ if name == vrma_name or name.endswith("/" + vrma_name) or os.path.basename(name) == vrma_name:
209
+ return base64.b64encode(z.read(name)).decode()
210
+ return ""
 
211
 
212
  def get_idle_animation():
 
213
  priorities = ["natural2.vrma", "natural.vrma", "idle.vrma", "stand.vrma"]
214
  for p in priorities:
215
  b64 = get_vrma_base64(p)
 
219
  name = VRMA_LIST[0]
220
  b64 = get_vrma_base64(name)
221
  return b64, name
222
+ return "", ""
223
 
224
+ VRM_B64 = get_vrm_base64()
225
  IDLE_ANIM_B64, IDLE_ANIM_NAME = get_idle_animation()
226
 
227
  # ── Chat processing ───────────────────────────────────────────────────────────
228
+ def process_chat(user_message: str, history: list):
229
  if not user_message.strip():
230
+ return history, "{}", None
231
 
 
232
  try:
233
+ result = generate_response(user_message, history)
234
  except Exception as e:
235
  result = {
236
+ "response": f"Sorry, I encountered an error. Please try again.",
237
  "emotion": "sad",
238
  "animation": None,
239
+ "intensity": 0.5,
240
  }
241
 
242
  response_text = result.get("response", "...")
243
+ emotion = result.get("emotion", "neutral")
244
+ animation = result.get("animation")
245
+ intensity = result.get("intensity", 0.7)
 
 
 
246
 
247
+ audio_b64 = run_tts(response_text) or ""
248
+ anim_b64 = get_vrma_base64(animation) if animation else ""
249
+ blink_flag = EMOTIONS_MAP.get(emotion, EMOTIONS_MAP["neutral"])["blinking"]
 
250
 
 
 
251
  payload = json.dumps({
252
+ "type": "chat_response",
253
+ "response": response_text,
254
+ "emotion": emotion,
255
+ "blinking": blink_flag,
256
  "animation_b64": anim_b64,
257
+ "animation_name":animation,
258
+ "audio_b64": audio_b64,
259
+ "intensity": intensity,
 
260
  })
261
 
262
+ new_history = history + [[user_message, json.dumps(result)]]
263
+ return new_history, payload, audio_b64 or None
264
 
265
+ # ── CSS ───────────────────────────────────────────────────────────────────────
266
+ APP_CSS = """
267
+ /* ── Reset ── */
 
268
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
269
 
270
+ html, body { width: 100%; height: 100%; overflow: hidden; }
271
+
272
+ .gradio-container {
273
  background: #0a0a0f !important;
274
+ font-family: 'Segoe UI', system-ui, sans-serif !important;
275
+ padding: 0 !important;
276
+ margin: 0 !important;
277
+ max-width: 100% !important;
278
+ width: 100vw !important;
279
+ height: 100vh !important;
280
+ overflow: hidden !important;
281
  }
282
 
283
+ footer { display: none !important; }
284
+ .built-with { display: none !important; }
 
 
285
 
286
+ /* Hide all default gradio chrome we don't need */
287
+ #hidden-controls { position: fixed; bottom: -9999px; left: -9999px; opacity: 0; pointer-events: none; }
288
+
289
+ /* ── Root layout ── */
290
  #ani-root {
291
  display: grid;
292
  grid-template-columns: 1fr 380px;
 
293
  width: 100vw;
294
  height: 100vh;
295
  overflow: hidden;
296
  position: fixed;
297
  top: 0; left: 0;
298
+ background: #0a0a0f;
299
  }
300
 
301
+ /* ── VRM side ── */
302
  #vrm-side {
303
  position: relative;
304
+ background: linear-gradient(160deg, #0d0d1a 0%, #080810 60%, #0d0d1a 100%);
305
  overflow: hidden;
306
  }
307
 
308
  #vrm-side::before {
309
  content: '';
310
+ position: absolute; inset: 0;
 
311
  background:
312
+ radial-gradient(ellipse 70% 50% at 50% 110%, rgba(99,102,241,0.14) 0%, transparent 70%),
313
+ radial-gradient(ellipse 40% 30% at 15% 85%, rgba(139,92,246,0.09) 0%, transparent 60%);
 
 
 
 
 
 
 
 
 
 
 
314
  pointer-events: none;
315
  z-index: 1;
316
  }
317
 
318
  #vrm-canvas-wrap {
319
+ position: absolute; inset: 0; z-index: 2;
 
 
320
  }
 
321
  #vrm-canvas-wrap canvas {
322
  width: 100% !important;
323
  height: 100% !important;
324
  display: block;
325
  }
326
 
327
+ /* Particles */
328
  .particle {
329
  position: absolute;
330
  border-radius: 50%;
331
  pointer-events: none;
332
+ animation: float-up linear infinite;
333
  z-index: 3;
334
  }
335
+ @keyframes float-up {
336
+ 0% { transform: translateY(110vh) scale(0); opacity: 0; }
337
+ 8% { opacity: 1; }
338
+ 92% { opacity: 0.5; }
339
+ 100% { transform: translateY(-10vh) scale(1.2); opacity: 0; }
340
  }
341
 
342
+ /* Status bar */
343
  #status-bar {
344
+ position: absolute; top: 18px; left: 18px;
345
+ display: flex; align-items: center; gap: 8px;
 
346
  z-index: 10;
347
+ background: rgba(8,8,18,0.65);
348
+ backdrop-filter: blur(14px);
349
+ border: 1px solid rgba(255,255,255,0.07);
350
+ border-radius: 20px;
351
+ padding: 7px 14px;
352
  }
 
353
  #status-dot {
354
+ width: 7px; height: 7px;
355
  border-radius: 50%;
356
  background: #4ade80;
357
  box-shadow: 0 0 8px #4ade80;
358
+ animation: dot-pulse 2.2s ease-in-out infinite;
359
  }
360
+ @keyframes dot-pulse {
361
+ 0%,100% { box-shadow: 0 0 6px #4ade80; }
362
+ 50% { box-shadow: 0 0 14px #4ade80, 0 0 22px rgba(74,222,128,0.4); }
363
  }
364
+ #status-text { font-size: 11.5px; color: rgba(255,255,255,0.65); letter-spacing: 0.4px; }
365
 
366
+ /* Emotion badge */
 
 
 
 
 
 
367
  #emotion-badge {
368
+ position: absolute; bottom: 22px; left: 22px;
 
369
  z-index: 10;
370
+ background: rgba(8,8,18,0.7);
371
+ backdrop-filter: blur(14px);
372
+ border: 1px solid rgba(99,102,241,0.28);
373
+ border-radius: 14px;
374
+ padding: 9px 16px;
375
+ display: flex; align-items: center; gap: 9px;
376
  transition: all 0.4s ease;
377
  }
378
+ #emotion-icon { font-size: 20px; transition: all 0.3s; }
379
+ #emotion-label { font-size: 12.5px; font-weight: 600; color: rgba(255,255,255,0.88); text-transform: capitalize; letter-spacing: 0.4px; }
380
 
381
+ /* Audio visualizer */
382
+ #audio-viz {
383
+ position: absolute; bottom: 22px; right: 22px;
 
 
 
 
 
 
 
 
 
 
384
  z-index: 10;
385
  display: flex; align-items: center; gap: 3px;
386
+ height: 36px;
387
  opacity: 0;
388
  transition: opacity 0.3s;
389
  }
390
+ #audio-viz.active { opacity: 1; }
391
+ .wbar {
392
  width: 3px;
393
  background: linear-gradient(to top, #6366f1, #a78bfa);
394
  border-radius: 2px;
395
+ transform-origin: bottom;
396
+ animation: wbar-anim ease-in-out infinite alternate;
 
 
 
397
  }
398
+ @keyframes wbar-anim { from { transform: scaleY(0.15); } to { transform: scaleY(1); } }
399
 
400
+ /* ── Chat side ── */
401
  #chat-side {
402
  display: flex;
403
  flex-direction: column;
404
+ background: rgba(7,7,15,0.97);
405
+ border-left: 1px solid rgba(255,255,255,0.055);
 
 
406
  overflow: hidden;
407
+ position: relative;
408
  }
 
 
409
  #chat-side::before {
410
  content: '';
411
  position: absolute;
412
+ top: -50px; left: 50%; transform: translateX(-50%);
413
+ width: 180px; height: 100px;
414
+ background: radial-gradient(ellipse, rgba(99,102,241,0.28) 0%, transparent 70%);
415
+ pointer-events: none; z-index: 0;
 
 
416
  }
417
 
418
+ /* Header */
419
  #chat-header {
420
+ position: relative; z-index: 5;
421
+ padding: 18px 22px 14px;
422
+ border-bottom: 1px solid rgba(255,255,255,0.055);
423
+ background: rgba(10,10,20,0.85);
 
424
  flex-shrink: 0;
425
  }
 
426
  #chat-title {
427
+ font-size: 17px; font-weight: 700;
 
428
  background: linear-gradient(135deg, #a78bfa, #6366f1, #60a5fa);
429
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
430
+ background-clip: text; letter-spacing: 0.5px;
 
 
 
 
 
 
 
 
 
431
  }
432
+ #chat-subtitle { font-size: 10.5px; color: rgba(255,255,255,0.38); margin-top: 3px; letter-spacing: 0.4px; }
433
  #model-tag {
434
+ display: inline-flex; align-items: center; gap: 5px;
435
+ margin-top: 7px;
436
+ background: rgba(99,102,241,0.13);
437
+ border: 1px solid rgba(99,102,241,0.28);
438
+ border-radius: 18px; padding: 2px 9px;
439
+ font-size: 9.5px; color: #a78bfa;
 
 
 
 
440
  }
441
 
442
+ /* Messages */
443
  #messages-wrap {
444
  flex: 1;
445
  overflow-y: auto;
446
+ padding: 18px 14px;
447
+ display: flex; flex-direction: column; gap: 13px;
448
+ position: relative; z-index: 2;
 
 
 
449
  scroll-behavior: smooth;
450
  }
451
+ #messages-wrap::-webkit-scrollbar { width: 3px; }
452
+ #messages-wrap::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.28); border-radius: 2px; }
453
 
454
+ .msg-row { display: flex; gap: 9px; animation: msg-slide 0.28s ease; }
455
+ .msg-row.user-row { flex-direction: row-reverse; }
456
+ @keyframes msg-slide { from { opacity:0; transform:translateY(7px); } to { opacity:1; transform:translateY(0); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
 
458
  .msg-avatar {
459
+ width: 28px; height: 28px; border-radius: 50%;
 
 
460
  display: flex; align-items: center; justify-content: center;
461
+ font-size: 13px; flex-shrink: 0;
 
 
 
 
 
462
  }
463
+ .msg-avatar.ai-av { background: linear-gradient(135deg,#6366f1,#a78bfa); box-shadow: 0 0 10px rgba(99,102,241,0.4); }
464
+ .msg-avatar.user-av { background: linear-gradient(135deg,#374151,#4b5563); }
465
 
466
+ .msg-wrap { display: flex; flex-direction: column; max-width: 83%; }
467
+ .user-row .msg-wrap { align-items: flex-end; }
 
468
 
469
  .msg-bubble {
470
+ padding: 9px 13px;
471
+ font-size: 13px; line-height: 1.6;
472
+ word-break: break-word;
473
+ }
474
+ .msg-bubble.ai-bubble {
475
+ background: rgba(99,102,241,0.11);
476
+ border: 1px solid rgba(99,102,241,0.18);
477
+ border-radius: 3px 14px 14px 14px;
478
+ color: rgba(255,255,255,0.88);
479
+ }
480
+ .msg-bubble.user-bubble {
481
+ background: linear-gradient(135deg,rgba(99,102,241,0.22),rgba(139,92,246,0.18));
482
+ border: 1px solid rgba(139,92,246,0.22);
483
+ border-radius: 14px 3px 14px 14px;
484
+ color: rgba(255,255,255,0.93);
485
+ }
486
+ .msg-time { font-size: 9.5px; color: rgba(255,255,255,0.28); margin-top: 3px; }
487
+
488
+ /* Typing */
489
+ #typing-row { display: none; gap: 9px; }
490
+ #typing-row.show { display: flex; }
491
+ .tydot {
492
+ width: 6px; height: 6px; border-radius: 50%; background: #6366f1;
493
+ animation: ty-bounce 1.1s ease-in-out infinite;
494
+ }
495
+ .tydot:nth-child(2){animation-delay:.18s;} .tydot:nth-child(3){animation-delay:.36s;}
496
+ @keyframes ty-bounce { 0%,60%,100%{transform:translateY(0);opacity:.4;} 30%{transform:translateY(-5px);opacity:1;} }
497
+
498
+ /* Input area */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  #input-area {
500
+ position: relative; z-index: 5;
501
+ padding: 14px;
502
+ border-top: 1px solid rgba(255,255,255,0.055);
503
+ background: rgba(8,8,18,0.92);
 
504
  flex-shrink: 0;
505
  }
506
+ #input-row { display: flex; gap: 9px; align-items: flex-end; }
 
 
 
 
 
 
 
 
 
 
507
 
508
  #user-input {
509
+ flex: 1;
510
  background: rgba(255,255,255,0.05);
511
+ border: 1px solid rgba(255,255,255,0.09);
512
+ border-radius: 18px;
513
+ padding: 11px 16px;
514
  color: rgba(255,255,255,0.9);
515
+ font-size: 13.5px; font-family: inherit;
516
+ resize: none; outline: none;
517
+ line-height: 1.5; min-height: 44px; max-height: 110px;
 
 
 
518
  transition: border-color 0.2s, box-shadow 0.2s;
 
519
  }
 
520
  #user-input:focus {
521
+ border-color: rgba(99,102,241,0.48);
522
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.09);
523
  }
524
+ #user-input::placeholder { color: rgba(255,255,255,0.28); }
 
525
 
526
  #send-btn {
527
+ width: 44px; height: 44px; border-radius: 50%;
528
+ border: none; cursor: pointer; flex-shrink: 0;
529
+ background: linear-gradient(135deg,#6366f1,#8b5cf6);
530
+ color: #fff; font-size: 16px;
 
 
 
531
  display: flex; align-items: center; justify-content: center;
 
532
  transition: all 0.2s;
533
+ box-shadow: 0 3px 10px rgba(99,102,241,0.4);
 
 
 
 
 
 
 
 
 
 
 
 
534
  }
535
+ #send-btn:hover:not(:disabled) { transform: scale(1.07); box-shadow: 0 5px 18px rgba(99,102,241,0.6); }
536
+ #send-btn:active:not(:disabled){ transform: scale(0.94); }
537
+ #send-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none !important; }
538
 
539
  /* Loading overlay */
540
  #loading-overlay {
541
+ position: fixed; inset: 0;
542
+ background: rgba(7,7,14,0.97);
543
+ z-index: 9999;
544
+ display: flex; flex-direction: column;
545
+ align-items: center; justify-content: center; gap: 20px;
546
+ transition: opacity 0.9s ease;
547
+ }
548
+ #loading-overlay.gone { opacity: 0; pointer-events: none; }
549
+
550
+ .ld-logo { font-size: 60px; animation: ld-pulse 2s ease-in-out infinite; }
551
+ @keyframes ld-pulse {
552
+ 0%,100%{ transform:scale(1); filter:drop-shadow(0 0 18px rgba(99,102,241,0.6)); }
553
+ 50% { transform:scale(1.06);filter:drop-shadow(0 0 34px rgba(167,139,250,0.8)); }
554
+ }
555
+ .ld-title {
556
+ font-size: 26px; font-weight: 700;
557
+ background: linear-gradient(135deg,#a78bfa,#6366f1,#60a5fa);
558
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
559
+ background-clip: text;
560
  }
561
+ .ld-bar-wrap { width: 180px; height: 2px; background: rgba(255,255,255,0.09); border-radius: 1px; overflow: hidden; }
562
+ .ld-bar {
563
+ height: 100%;
564
+ background: linear-gradient(90deg,#6366f1,#a78bfa,#60a5fa);
565
+ border-radius: 1px;
566
+ animation: ld-sweep 1.7s ease-in-out infinite;
567
  }
568
+ @keyframes ld-sweep {
569
+ 0% { width:0%; margin-left:0%; }
570
+ 50% { width:55%; margin-left:22%; }
571
+ 100%{ width:0%; margin-left:100%; }
572
  }
573
+ .ld-status { font-size: 12px; color: rgba(255,255,255,0.45); letter-spacing: 0.4px; }
574
 
575
+ /* Mobile */
576
+ @media(max-width:700px){
577
+ #ani-root{ grid-template-columns:1fr; grid-template-rows:50vh 50vh; }
578
+ #chat-side{ border-left:none; border-top:1px solid rgba(255,255,255,0.055); }
 
 
 
579
  }
580
+ """
581
 
582
+ # ── Three.js / VRM viewer HTML ────────────────────────────────────────────────
583
+ VRM_VIEWER_HTML = f"""
584
+ <div id="loading-overlay">
585
+ <div class="ld-logo">🌸</div>
586
+ <div class="ld-title">Ani AI Companion</div>
587
+ <div class="ld-bar-wrap"><div class="ld-bar"></div></div>
588
+ <div class="ld-status" id="ld-status">Initializing…</div>
589
+ </div>
590
 
591
+ <div id="ani-root">
 
 
 
 
 
 
 
 
 
 
592
 
593
+ <!-- VRM viewer -->
594
+ <div id="vrm-side">
595
+ <div id="vrm-canvas-wrap"></div>
596
+ <div id="status-bar">
597
+ <div id="status-dot"></div>
598
+ <span id="status-text">Loading…</span>
599
+ </div>
600
+ <div id="emotion-badge">
601
+ <span id="emotion-icon">😐</span>
602
+ <span id="emotion-label">neutral</span>
603
+ </div>
604
+ <div id="audio-viz"></div>
605
+ </div>
606
 
607
+ <!-- Chat panel -->
608
+ <div id="chat-side">
609
+ <div id="chat-header">
610
+ <div id="chat-title">✨ Ani</div>
611
+ <div id="chat-subtitle">AI Anime Companion</div>
612
+ <div id="model-tag">πŸ€– Gemma 2B Β· ZeroGPU</div>
613
+ </div>
614
 
615
+ <div id="messages-wrap">
616
+ <div class="msg-row">
617
+ <div class="msg-avatar ai-av">🌸</div>
618
+ <div class="msg-wrap">
619
+ <div class="msg-bubble ai-bubble">Hello! I'm Ani, your AI companion ✨ How can I help you today?</div>
620
+ <div class="msg-time">just now</div>
621
+ </div>
622
+ </div>
623
+ </div>
624
 
625
+ <div id="typing-row">
626
+ <div class="msg-avatar ai-av">🌸</div>
627
+ <div class="msg-wrap">
628
+ <div class="msg-bubble ai-bubble" style="display:flex;align-items:center;gap:6px;padding:10px 14px;">
629
+ <div class="tydot"></div><div class="tydot"></div><div class="tydot"></div>
630
+ </div>
631
+ </div>
632
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
 
634
+ <div id="input-area">
635
+ <div id="input-row">
636
+ <textarea id="user-input" placeholder="Talk to Ani…" rows="1"></textarea>
637
+ <button id="send-btn" title="Send">➀</button>
638
+ </div>
639
+ </div>
640
+ </div>
641
+ </div>
642
+
643
+ <!-- Hidden data stores -->
644
+ <div id="vrm-b64" style="display:none">{VRM_B64}</div>
645
+ <div id="idle-anim-b64" style="display:none">{IDLE_ANIM_B64}</div>
646
 
 
 
647
  <script type="importmap">
648
+ {{
649
+ "imports": {{
650
+ "three": "https://cdn.jsdelivr.net/npm/three@0.168.0/build/three.module.js",
651
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.168.0/examples/jsm/",
652
+ "@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3.1.3/lib/three-vrm.module.js",
653
+ "@pixiv/three-vrm-animation":"https://cdn.jsdelivr.net/npm/@pixiv/three-vrm-animation@3.1.3/lib/three-vrm-animation.module.js"
654
+ }}
655
+ }}
656
  </script>
657
 
658
  <script type="module">
659
  import * as THREE from 'three';
660
+ import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js';
661
+ import {{ VRMLoaderPlugin, VRMUtils }} from '@pixiv/three-vrm';
662
+ import {{ VRMAnimationLoaderPlugin, createVRMAnimationClip }} from '@pixiv/three-vrm-animation';
663
 
664
+ // ─── globals ─────────────────────────────────────────────────────────────────
665
  let renderer, scene, camera, clock;
666
+ let vrm = null;
667
+ let mixer = null;
668
+ let idleB64 = '';
669
+ let autoBlinkOn = true;
670
+ let isBlinking = false;
671
+ let blinkTimer = null;
672
+ let lipInterval = null;
673
+ let audioCtx = null;
674
+ let currentEmo = 'neutral';
675
+
676
+ const EMO_EXPR = {{
677
+ happy: {{n:'happy', w:1.0}},
678
+ sad: {{n:'sad', w:1.0}},
679
+ angry: {{n:'angry', w:1.0}},
680
+ surprised: {{n:'surprised',w:1.0}},
681
+ fearful: {{n:'sad', w:0.8}},
682
+ disgusted: {{n:'angry', w:0.7}},
683
+ neutral: {{n:'neutral', w:0.0}},
684
+ excited: {{n:'happy', w:1.0}},
685
+ confused: {{n:'surprised',w:0.6}},
686
+ thinking: {{n:'neutral', w:0.0}},
687
+ laughing: {{n:'happy', w:1.0}},
688
+ crying: {{n:'sad', w:1.0}},
689
+ love: {{n:'happy', w:0.9}},
690
+ shy: {{n:'happy', w:0.5}},
691
+ bored: {{n:'neutral', w:0.0}},
692
+ sleepy: {{n:'relaxed', w:0.8}},
693
+ determined: {{n:'angry', w:0.4}},
694
+ proud: {{n:'happy', w:0.8}},
695
+ embarrassed:{{n:'sad', w:0.5}},
696
+ grateful: {{n:'happy', w:0.7}},
697
+ playful: {{n:'happy', w:0.9}},
698
+ serious: {{n:'neutral', w:0.0}},
699
+ }};
700
+
701
+ const NO_BLINK = new Set(['angry','surprised','laughing','crying','sleepy','disgusted']);
702
+
703
+ const EMO_ICON = {{
704
+ happy:'😊',sad:'😒',angry:'😠',surprised:'😲',fearful:'😨',disgusted:'🀒',
705
+ neutral:'😐',excited:'🀩',confused:'πŸ˜•',thinking:'πŸ€”',laughing:'πŸ˜„',crying:'😭',
706
+ love:'πŸ₯°',shy:'😊',bored:'πŸ˜‘',sleepy:'😴',determined:'πŸ’ͺ',proud:'πŸŽ‰',
707
+ embarrassed:'😳',grateful:'πŸ™',playful:'😜',serious:'😀',
708
+ }};
709
+
710
+ // ─── Three.js setup ──────────────────────────────────────────────────────────
711
+ function initThree(){{
712
  const wrap = document.getElementById('vrm-canvas-wrap');
713
+ renderer = new THREE.WebGLRenderer({{antialias:true, alpha:true}});
714
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));
 
 
715
  renderer.setSize(wrap.clientWidth, wrap.clientHeight);
716
  renderer.outputColorSpace = THREE.SRGBColorSpace;
717
  renderer.shadowMap.enabled = true;
 
718
  wrap.appendChild(renderer.domElement);
719
 
720
  scene = new THREE.Scene();
721
+ scene.fog = new THREE.Fog(0x0a0a14, 10, 22);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
 
723
+ camera = new THREE.PerspectiveCamera(28, wrap.clientWidth/wrap.clientHeight, 0.1, 100);
724
+ camera.position.set(0,1.38,3.0);
725
+ camera.lookAt(0,1.0,0);
726
 
727
+ scene.add(new THREE.AmbientLight(0x6366f1, 0.45));
 
 
 
 
 
 
728
 
729
+ const key = new THREE.DirectionalLight(0xffffff,1.15);
730
+ key.position.set(2,3,2); key.castShadow=true; scene.add(key);
731
 
732
+ const fill = new THREE.DirectionalLight(0xa78bfa,0.5);
733
+ fill.position.set(-2,2,1); scene.add(fill);
 
 
 
734
 
735
+ const rim = new THREE.DirectionalLight(0x60a5fa,0.4);
736
+ rim.position.set(0,2,-3); scene.add(rim);
 
737
 
738
+ const pt = new THREE.PointLight(0x6366f1,0.55,5);
739
+ pt.position.set(0,0.5,1.5); scene.add(pt);
 
740
 
741
+ const floor = new THREE.Mesh(
742
+ new THREE.PlaneGeometry(10,10),
743
+ new THREE.MeshStandardMaterial({{color:0x0d0d1a, roughness:0.85, metalness:0.1, transparent:true, opacity:0.55}})
744
+ );
745
+ floor.rotation.x=-Math.PI/2; floor.receiveShadow=true; scene.add(floor);
746
 
747
+ const grid = new THREE.GridHelper(8,20,0x6366f1,0x1a1a2e);
748
+ grid.material.opacity=0.13; grid.material.transparent=true; scene.add(grid);
 
 
749
 
750
+ clock = new THREE.Clock();
751
+ window.addEventListener('resize',()=>{{
752
+ camera.aspect=wrap.clientWidth/wrap.clientHeight;
753
+ camera.updateProjectionMatrix();
754
+ renderer.setSize(wrap.clientWidth,wrap.clientHeight);
755
+ }});
756
+ animate();
757
+ }}
758
 
759
+ function animate(){{
760
+ requestAnimationFrame(animate);
761
+ const dt = clock.getDelta();
762
+ if(mixer) mixer.update(dt);
763
+ if(vrm){{
764
+ const t = clock.getElapsedTime();
765
+ try{{
766
+ const spine = vrm.humanoid.getNormalizedBoneNode('spine');
767
+ if(spine){{ spine.rotation.z=Math.sin(t*.5)*.01; spine.rotation.x=Math.sin(t*.3)*.008; }}
768
+ const head = vrm.humanoid.getNormalizedBoneNode('head');
769
+ if(head){{ head.rotation.y=Math.sin(t*.4)*.03; head.rotation.x=Math.sin(t*.25)*.014; }}
770
+ }}catch(e){{}}
771
+ vrm.update(dt);
772
+ }}
773
+ renderer.render(scene,camera);
774
+ }}
775
+
776
+ // ─── Load VRM ────────────────────────────────────────────────────────────────
777
+ function b64ToBlob(b64, mime){{
778
+ const bytes = Uint8Array.from(atob(b64), c=>c.charCodeAt(0));
779
+ return new Blob([bytes],{{type:mime}});
780
+ }}
781
 
782
+ function loadVRM(b64){{
783
+ const url = URL.createObjectURL(b64ToBlob(b64,'application/octet-stream'));
784
+ const loader = new GLTFLoader();
785
+ loader.register(p=>new VRMLoaderPlugin(p));
786
+ loader.register(p=>new VRMAnimationLoaderPlugin(p));
787
+ loader.load(url, gltf=>{{
788
+ const v = gltf.userData.vrm;
789
+ if(!v){{ console.error('No VRM'); return; }}
790
+ VRMUtils.removeUnnecessaryJoints(v.scene);
791
+ VRMUtils.removeUnnecessaryVertices(v.scene);
792
+ if(vrm){{ scene.remove(vrm.scene); VRMUtils.deepDispose(vrm.scene); }}
793
+ vrm = v;
794
+ scene.add(vrm.scene);
795
+ vrm.scene.rotation.y = Math.PI;
796
  const box = new THREE.Box3().setFromObject(vrm.scene);
797
+ const c = box.getCenter(new THREE.Vector3());
798
+ vrm.scene.position.sub(c);
799
  vrm.scene.position.y = 0;
 
800
  URL.revokeObjectURL(url);
 
801
  setStatus('Ani is ready ✨');
802
+ hideLoader();
803
+ startBlink();
804
+ // play idle
805
+ if(idleB64) loadVRMA(idleB64, true);
806
+ }}, undefined, e=>{{
807
+ console.error(e);
 
 
808
  setStatus('VRM load failed');
809
+ hideLoader();
810
+ }});
811
+ }}
 
 
 
 
 
 
 
 
812
 
813
+ function loadVRMA(b64, loop=true, onDone=null){{
814
+ if(!vrm||!b64) return;
815
+ const url = URL.createObjectURL(b64ToBlob(b64,'application/octet-stream'));
816
  const loader = new GLTFLoader();
817
+ loader.register(p=>new VRMAnimationLoaderPlugin(p));
818
+ loader.load(url, gltf=>{{
819
+ const anims = gltf.userData.vrmAnimations;
820
+ if(!anims||!anims.length){{ URL.revokeObjectURL(url); return; }}
821
+ const clip = createVRMAnimationClip(anims[0], vrm);
822
+ if(mixer) mixer.stopAllAction();
823
+ mixer = new THREE.AnimationMixer(vrm.scene);
824
+ const action = mixer.clipAction(clip);
825
+ action.setLoop(loop?THREE.LoopRepeat:THREE.LoopOnce, Infinity);
826
+ action.clampWhenFinished = !loop;
827
+ action.play();
828
+ if(onDone&&!loop){{
829
+ mixer.addEventListener('finished',()=>{{ onDone(); URL.revokeObjectURL(url); }});
830
+ }}else{{ URL.revokeObjectURL(url); }}
831
+ }}, undefined, ()=>URL.revokeObjectURL(url));
832
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
 
834
+ // ─── Blink ───────────────────────────────────────────────────────────────────
835
+ function startBlink(){{
836
+ if(blinkTimer) clearInterval(blinkTimer);
837
+ blinkTimer = setInterval(()=>{{
838
+ if(!autoBlinkOn||!vrm||isBlinking) return;
839
  doBlink();
840
+ }}, 2200+Math.random()*2200);
841
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
842
 
843
+ function doBlink(){{
844
+ if(!vrm||isBlinking) return;
845
+ const em = vrm.expressionManager; if(!em) return;
846
+ isBlinking=true;
847
+ let t=0; const step=16, dur=130;
848
+ const id=setInterval(()=>{{
849
+ t+=step;
850
+ const w=Math.sin((t/dur)*Math.PI);
851
+ try{{em.setValue('blink',Math.max(0,w));}}catch(e){{}}
852
+ if(t>=dur){{
853
+ clearInterval(id);
854
+ try{{em.setValue('blink',0);}}catch(e){{}}
855
+ isBlinking=false;
856
+ }}
857
+ }},step);
858
+ }}
859
 
860
+ // ─── Emotion ──────────────────────────────────────────────────────────────────
861
+ function applyEmotion(name, intensity=1.0){{
862
+ if(!vrm) return;
863
+ const em=vrm.expressionManager; if(!em) return;
864
+ ['happy','sad','angry','surprised','relaxed','neutral'].forEach(e=>{{try{{em.setValue(e,0);}}catch(x){{}}}});
865
+ const m=EMO_EXPR[name]||EMO_EXPR.neutral;
866
+ if(m.w>0)try{{em.setValue(m.n, m.w*intensity);}}catch(e){{}}
867
+ autoBlinkOn = !NO_BLINK.has(name);
868
+ currentEmo = name;
869
+ const icon=document.getElementById('emotion-icon');
870
+ const lbl =document.getElementById('emotion-label');
871
+ if(icon) icon.textContent = EMO_ICON[name]||'😐';
872
+ if(lbl) lbl.textContent = name;
873
+ }}
874
 
875
+ // ─── Lip sync ────────────────────────────────────────────────────────────────
876
+ function startLipSync(arrayBuf){{
877
+ if(!vrm) return;
878
+ const em=vrm.expressionManager; if(!em) return;
879
  stopLipSync();
880
+ if(!audioCtx) audioCtx=new (window.AudioContext||window.webkitAudioContext)();
881
+ audioCtx.decodeAudioData(arrayBuf, decoded=>{{
882
+ const src = audioCtx.createBufferSource();
883
+ src.buffer=decoded;
884
+ const analyser=audioCtx.createAnalyser(); analyser.fftSize=256;
885
+ src.connect(analyser); analyser.connect(audioCtx.destination);
886
+ src.start(0);
887
+ showViz(true);
888
+ const data=new Uint8Array(analyser.frequencyBinCount);
889
+ lipInterval=setInterval(()=>{{
890
+ analyser.getByteFrequencyData(data);
891
+ const voice=data.slice(2,22);
892
+ const avg=voice.reduce((a,b)=>a+b,0)/voice.length;
893
+ const m=Math.min(1.0,avg/75);
894
+ try{{em.setValue('aa',m*.85);}}catch(e){{}}
895
+ }},33);
896
+ src.addEventListener('ended',()=>{{stopLipSync(); autoBlinkOn=!NO_BLINK.has(currentEmo);}});
897
+ }}, ()=>fallbackLip());
898
+ }}
899
 
900
+ function fallbackLip(){{
901
+ if(!vrm) return;
902
+ const em=vrm.expressionManager; if(!em) return;
903
+ showViz(true); let t=0;
904
+ lipInterval=setInterval(()=>{{
905
+ t+=.1;
906
+ const m=.25+.45*Math.abs(Math.sin(t*7))*Math.abs(Math.sin(t*2.7));
907
+ try{{em.setValue('aa',m);}}catch(e){{}}
908
+ }},50);
909
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
910
 
911
+ function stopLipSync(){{
912
+ if(lipInterval){{clearInterval(lipInterval);lipInterval=null;}}
913
+ if(vrm){{const em=vrm.expressionManager;if(em){{try{{em.setValue('aa',0);}}catch(e){{}}}}}}
914
+ showViz(false);
915
+ }}
916
 
917
+ function showViz(on){{
918
+ const el=document.getElementById('audio-viz');
919
+ if(el) on?el.classList.add('active'):el.classList.remove('active');
920
+ }}
 
 
 
 
 
 
 
 
921
 
922
+ // ─── Payload handler ─────────────────────────────────────────────────────────
923
+ window._handlePayload = function(payload){{
924
+ if(!payload||payload==='{{}}') return;
925
+ let d; try{{d=JSON.parse(payload);}}catch(e){{return;}}
926
+ if(!d||d.type!=='chat_response') return;
927
 
928
+ if(d.emotion) applyEmotion(d.emotion, d.intensity||0.8);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
 
930
+ if(d.animation_b64&&d.animation_b64.length>10){{
931
+ loadVRMA(d.animation_b64, false, ()=>{{
932
+ if(idleB64) loadVRMA(idleB64,true);
933
+ }});
934
+ }}
935
 
936
+ if(d.audio_b64&&d.audio_b64.length>10){{
937
+ const bin=atob(d.audio_b64);
938
+ const buf=new Uint8Array(bin.length);
939
+ for(let i=0;i<bin.length;i++) buf[i]=bin.charCodeAt(i);
940
+ startLipSync(buf.buffer.slice(0));
941
+ // Also play via Audio element as backup
942
+ try{{
943
+ const au=new Audio('data:audio/mp3;base64,'+d.audio_b64);
944
+ au.play().catch(()=>{{}});
945
+ }}catch(e){{}}
946
+ }}
947
+ }};
948
+
949
+ // ─── Gradio output watcher ───────────────────────────────────────────────────
950
+ let _lastPayload='';
951
+ function watchOutput(){{
952
+ setInterval(()=>{{
953
+ // Try to read from the Gradio textbox with id gradio-payload-out
954
+ const selectors=[
955
+ '#gradio-payload-out textarea',
956
+ '#gradio-payload-out input',
957
+ 'textarea[data-testid="gradio-payload-out"]',
958
+ ];
959
+ let val='';
960
+ for(const s of selectors){{
961
+ const el=document.querySelector(s);
962
+ if(el&&el.value){{val=el.value;break;}}
963
+ }}
964
+ if(!val){{
965
+ // Fallback: look for any textarea with JSON payload
966
+ document.querySelectorAll('textarea').forEach(t=>{{
967
+ if(t.value&&t.value.includes('chat_response')) val=t.value;
968
+ }});
969
+ }}
970
+ if(val&&val!==_lastPayload){{
971
+ _lastPayload=val;
972
+ window._handlePayload(val);
973
+ }}
974
+ }},250);
975
+ }}
976
 
977
+ // ─── Boot ───────────────────────────────────────────��────────────────────────
978
+ function setStatus(t){{const el=document.getElementById('status-text');if(el)el.textContent=t;}}
979
+ function setLdStatus(t){{const el=document.getElementById('ld-status');if(el)el.textContent=t;}}
980
+ function hideLoader(){{
981
+ const el=document.getElementById('loading-overlay');
982
+ if(el)setTimeout(()=>el.classList.add('gone'),600);
983
+ }}
984
 
985
+ function createParticles(){{
986
+ const side=document.getElementById('vrm-side'); if(!side) return;
987
+ const cols=['rgba(99,102,241,','rgba(139,92,246,','rgba(96,165,250,'];
988
+ for(let i=0;i<14;i++){{
989
+ const p=document.createElement('div'); p.className='particle';
990
+ const sz=2+Math.random()*4, col=cols[i%3];
991
+ p.style.cssText=`width:${{sz}}px;height:${{sz}}px;left:${{Math.random()*100}}%;`+
992
+ `background:${{col}}${{.3+Math.random()*.4}});`+
993
+ `box-shadow:0 0 ${{sz*2}}px ${{col}}.6);`+
994
+ `animation-duration:${{9+Math.random()*13}}s;animation-delay:${{Math.random()*11}}s;`;
995
+ side.appendChild(p);
996
+ }}
997
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
 
998
 
999
+ function createVizBars(){{
1000
+ const v=document.getElementById('audio-viz'); if(!v) return;
1001
+ for(let i=0;i<9;i++){{
1002
+ const b=document.createElement('div'); b.className='wbar';
1003
+ b.style.height=(14+Math.random()*24)+'px';
1004
+ b.style.animationDelay=(i*.09)+'s';
1005
+ b.style.animationDuration=(.38+Math.random()*.28)+'s';
1006
+ v.appendChild(b);
1007
+ }}
1008
+ }}
 
 
 
 
1009
 
1010
+ (function boot(){{
 
1011
  initThree();
 
1012
  createParticles();
1013
+ createVizBars();
1014
+ setLdStatus('Loading 3D model…');
1015
+
1016
+ // Read stored base64
1017
+ const vrmEl = document.getElementById('vrm-b64');
1018
+ const animEl = document.getElementById('idle-anim-b64');
1019
+ idleB64 = (animEl&&animEl.textContent.length>100)?animEl.textContent.trim():'';
1020
+
1021
+ if(vrmEl&&vrmEl.textContent.length>100){{
1022
+ loadVRM(vrmEl.textContent.trim());
1023
+ }}else{{
1024
+ setStatus('No VRM file found');
1025
+ hideLoader();
1026
+ }}
1027
 
1028
+ watchOutput();
1029
+ }})();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1031
 
1032
  <script>
1033
+ // ─── Chat UI (vanilla JS, no module) ─────────────────────────���───────────────
1034
+ (function(){{
1035
+ const wrap = document.getElementById('messages-wrap');
1036
+ const input = document.getElementById('user-input');
1037
+ const btn = document.getElementById('send-btn');
1038
+ const typing = document.getElementById('typing-row');
1039
+
1040
+ function now(){{
1041
+ const d=new Date();
1042
+ return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');
1043
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
1044
 
1045
+ function addMsg(text,role){{
1046
+ const row=document.createElement('div');
1047
+ row.className='msg-row'+(role==='user'?' user-row':'');
1048
 
1049
+ const av=document.createElement('div');
1050
+ av.className='msg-avatar '+(role==='user'?'user-av':'ai-av');
1051
+ av.textContent=role==='user'?'πŸ‘€':'🌸';
1052
 
1053
+ const mw=document.createElement('div'); mw.className='msg-wrap';
1054
+ const b=document.createElement('div');
1055
+ b.className='msg-bubble '+(role==='user'?'user-bubble':'ai-bubble');
1056
+ b.textContent=text;
1057
+ const t=document.createElement('div'); t.className='msg-time'; t.textContent=now();
1058
+ mw.appendChild(b); mw.appendChild(t);
 
1059
 
1060
+ if(role==='user'){{row.appendChild(mw);row.appendChild(av);}}
1061
+ else{{row.appendChild(av);row.appendChild(mw);}}
 
 
 
 
 
 
 
 
 
1062
 
1063
+ wrap.insertBefore(row, typing);
1064
+ wrap.scrollTop=wrap.scrollHeight;
1065
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1066
 
1067
+ function showTyping(on){{
1068
+ typing.classList.toggle('show',on);
1069
+ wrap.scrollTop=wrap.scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
 
1070
  }}
 
 
 
 
 
 
 
 
 
1071
 
1072
+ input.addEventListener('input',()=>{{
1073
+ input.style.height='auto';
1074
+ input.style.height=Math.min(input.scrollHeight,108)+'px';
1075
+ }});
1076
+ input.addEventListener('keydown',e=>{{
1077
+ if(e.key==='Enter'&&!e.shiftKey){{e.preventDefault();send();}}
1078
+ }});
1079
+ btn.addEventListener('click',send);
1080
+
1081
+ function send(){{
1082
+ const txt=input.value.trim();
1083
+ if(!txt||btn.disabled) return;
1084
+ addMsg(txt,'user');
1085
+ input.value=''; input.style.height='auto';
1086
+ btn.disabled=true; showTyping(true);
1087
+
1088
+ // Push to Gradio hidden textbox
1089
+ const gInput = document.querySelector('#gr-hidden-msg textarea')||
1090
+ document.querySelector('#gr-hidden-msg input');
1091
+ if(gInput){{
1092
+ const nativeSetter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value')||
1093
+ Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value');
1094
+ if(nativeSetter&&nativeSetter.set) nativeSetter.set.call(gInput,txt);
1095
+ gInput.dispatchEvent(new Event('input',{{bubbles:true}}));
1096
+ }}
1097
+ setTimeout(()=>{{
1098
+ const sb=document.getElementById('gr-hidden-btn');
1099
+ if(sb) sb.click();
1100
+ }},80);
1101
+
1102
+ // Watch for response via payload
1103
+ let waited=0;
1104
+ const poll=setInterval(()=>{{
1105
+ waited+=300;
1106
+ const sel=[
1107
+ '#gr-payload-out textarea',
1108
+ '#gr-payload-out input',
1109
+ ];
1110
+ let val='';
1111
+ for(const s of sel){{const el=document.querySelector(s);if(el&&el.value){{val=el.value;break;}}}}
1112
+ if(!val)document.querySelectorAll('textarea').forEach(ta=>{{if(ta.value&&ta.value.includes('chat_response'))val=ta.value;}});
1113
+
1114
+ if(val&&val.includes('chat_response')){{
1115
+ try{{
1116
+ const data=JSON.parse(val);
1117
+ if(data.response&&data.response!==window._lastResponse){{
1118
+ window._lastResponse=data.response;
1119
+ clearInterval(poll);
1120
+ showTyping(false);
1121
+ addMsg(data.response,'ai');
1122
+ btn.disabled=false;
1123
+ }}
1124
+ }}catch(e){{}}
1125
+ }}
1126
+ if(waited>65000){{clearInterval(poll);showTyping(false);btn.disabled=false;}}
1127
+ }},300);
1128
  }}
 
 
1129
 
1130
+ window._chatAddMsg=addMsg;
1131
+ }})();
1132
  </script>
 
 
 
1133
  """
1134
 
1135
  # ── Gradio App ────────────────────────────────────────────────────────────────
1136
+ with gr.Blocks(css=APP_CSS, title="Ani AI Companion") as demo:
1137
+
1138
+ chat_history = gr.State([])
1139
+
1140
+ # ── Custom viewer ──
1141
+ gr.HTML(VRM_VIEWER_HTML)
 
 
 
 
 
 
 
 
 
 
 
 
1142
 
1143
  # ── Hidden Gradio controls ──
1144
+ with gr.Group(visible=True, elem_id="hidden-controls"):
1145
  hidden_msg = gr.Textbox(
1146
+ value="",
1147
+ label="msg",
1148
+ elem_id="gr-hidden-msg",
1149
  visible=False,
1150
  )
1151
+ hidden_btn = gr.Button(
1152
+ "go",
1153
+ elem_id="gr-hidden-btn",
1154
  visible=False,
1155
  )
1156
  hidden_chatbot = gr.Chatbot(
1157
+ value=[],
1158
+ label="history",
1159
+ elem_id="gr-hidden-chatbot",
1160
  visible=False,
1161
  )
1162
+ payload_out = gr.Textbox(
1163
+ value="{}",
1164
+ label="payload",
1165
+ elem_id="gr-payload-out",
1166
  visible=False,
1167
  )
 
 
 
 
 
 
1168
 
1169
+ # ── Backend ──
1170
+ def on_submit(message: str, history: list):
1171
  if not message or not message.strip():
1172
+ return history, "{}"
 
 
 
 
 
 
 
 
 
 
 
 
1173
 
1174
+ new_history, payload, _ = process_chat(message, history)
1175
+ return new_history, payload
1176
 
1177
+ hidden_btn.click(
1178
  fn=on_submit,
1179
+ inputs=[hidden_msg, chat_history],
1180
+ outputs=[chat_history, payload_out],
 
1181
  )
1182
 
1183
+ # Forward payload to JS
1184
+ payload_out.change(
1185
  fn=None,
1186
+ inputs=[payload_out],
1187
  outputs=None,
1188
+ js="""(p) => {
1189
+ if(p && p !== '{}') {
1190
+ if(window._handlePayload) window._handlePayload(p);
 
 
 
 
 
 
 
 
 
 
1191
  }
1192
+ }""",
 
1193
  )
1194
 
1195
+ # Update chatbot display from history
1196
+ chat_history.change(
1197
+ fn=lambda h: h,
1198
+ inputs=[chat_history],
1199
+ outputs=[hidden_chatbot],
 
 
 
 
 
 
 
 
 
1200
  )
1201
 
1202
  if __name__ == "__main__":
1203
  demo.launch(
1204
  server_name="0.0.0.0",
1205
  server_port=7860,
 
 
1206
  )