| import gradio as gr |
| import spaces |
| import os |
| import sys |
| import json |
| import re |
| import zipfile |
| import asyncio |
| import tempfile |
| import base64 |
| import threading |
| from pathlib import Path |
| import subprocess |
|
|
| |
| def install_packages(): |
| packages = [ |
| "transformers>=4.40.0", |
| "torch>=2.1.0", |
| "accelerate>=0.27.0", |
| "edge-tts", |
| "sentencepiece", |
| ] |
| for pkg in packages: |
| subprocess.run( |
| [sys.executable, "-m", "pip", "install", pkg, "-q"], |
| check=False, |
| ) |
|
|
| install_packages() |
|
|
| import torch |
| from transformers import AutoTokenizer, AutoModelForCausalLM |
| import edge_tts |
|
|
| |
| MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" |
| VRM_PATH = "model/Ani.vrm" |
| ANIM_ZIP = "animation/all_vrma.zip" |
| TTS_VOICE = "en-US-AriaNeural" |
| TTS_VOICE2 = "zh-CN-XiaoyiNeural" |
|
|
| EMOTIONS_MAP = { |
| "happy": {"blinking": True, "icon": "π"}, |
| "sad": {"blinking": True, "icon": "π’"}, |
| "angry": {"blinking": False, "icon": "π "}, |
| "surprised": {"blinking": False, "icon": "π²"}, |
| "fearful": {"blinking": True, "icon": "π¨"}, |
| "disgusted": {"blinking": False, "icon": "π€’"}, |
| "neutral": {"blinking": True, "icon": "π"}, |
| "excited": {"blinking": True, "icon": "π€©"}, |
| "confused": {"blinking": True, "icon": "π"}, |
| "thinking": {"blinking": True, "icon": "π€"}, |
| "laughing": {"blinking": False, "icon": "π"}, |
| "crying": {"blinking": False, "icon": "π"}, |
| "love": {"blinking": True, "icon": "π₯°"}, |
| "shy": {"blinking": True, "icon": "π"}, |
| "bored": {"blinking": True, "icon": "π"}, |
| "sleepy": {"blinking": False, "icon": "π΄"}, |
| "determined": {"blinking": True, "icon": "πͺ"}, |
| "proud": {"blinking": True, "icon": "π"}, |
| "embarrassed": {"blinking": True, "icon": "π³"}, |
| "grateful": {"blinking": True, "icon": "π"}, |
| "playful": {"blinking": True, "icon": "π"}, |
| "serious": {"blinking": True, "icon": "π€"}, |
| } |
|
|
| |
| def get_vrma_list(): |
| if not os.path.exists(ANIM_ZIP): |
| return [] |
| with zipfile.ZipFile(ANIM_ZIP, "r") as z: |
| return [f for f in z.namelist() if f.endswith(".vrma")] |
|
|
| VRMA_LIST = get_vrma_list() |
|
|
| |
| _tokenizer = None |
| _model = None |
| _lock = threading.Lock() |
|
|
| def load_model(): |
| global _tokenizer, _model |
| if _model is not None: |
| return |
| with _lock: |
| if _model is not None: |
| return |
| print(f"[ANI] Loading {MODEL_ID} β¦") |
| _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) |
| _model = AutoModelForCausalLM.from_pretrained( |
| MODEL_ID, |
| torch_dtype=torch.float16, |
| device_map="auto", |
| low_cpu_mem_usage=True, |
| ) |
| _model.eval() |
| print("[ANI] Model ready.") |
|
|
| def build_system_prompt(): |
| emo_str = ", ".join(EMOTIONS_MAP.keys()) |
| anim_str = ", ".join(VRMA_LIST[:30]) if VRMA_LIST else "null" |
| return ( |
| "You are Ani, a friendly AI anime companion.\n" |
| "Reply ONLY with valid JSON β no markdown, no extra text:\n" |
| '{"response":"...", "emotion":"...", "animation":"...", "intensity":0.8}\n' |
| f"Emotions: {emo_str}\n" |
| f"Animations: {anim_str}\n" |
| "Pick the best emotion and animation. Keep replies to 1-3 sentences." |
| ) |
|
|
| @spaces.GPU(duration=60) |
| def generate_response(user_message: str, history: list) -> dict: |
| load_model() |
| system = build_system_prompt() |
| messages = [{"role": "system", "content": system}] |
| for h, a in history[-4:]: |
| if h: |
| messages.append({"role": "user", "content": h}) |
| if a: |
| try: |
| messages.append({"role": "assistant", |
| "content": json.loads(a).get("response", a)}) |
| except Exception: |
| messages.append({"role": "assistant", "content": a}) |
| messages.append({"role": "user", "content": user_message}) |
|
|
| text = _tokenizer.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True |
| ) |
| inputs = _tokenizer(text, return_tensors="pt").to(_model.device) |
|
|
| with torch.no_grad(): |
| out = _model.generate( |
| **inputs, |
| max_new_tokens=200, |
| temperature=0.7, |
| top_p=0.9, |
| do_sample=True, |
| pad_token_id=_tokenizer.eos_token_id, |
| ) |
| raw = _tokenizer.decode( |
| out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True |
| ).strip() |
|
|
| try: |
| m = re.search(r"\{.*\}", raw, re.DOTALL) |
| result = json.loads(m.group()) if m else {} |
| except Exception: |
| result = {} |
|
|
| result.setdefault("response", raw or "Hello!") |
| result.setdefault("emotion", "neutral") |
| result.setdefault("animation", None) |
| result.setdefault("intensity", 0.7) |
| if result["emotion"] not in EMOTIONS_MAP: |
| result["emotion"] = "neutral" |
| return result |
|
|
| |
| async def _tts(text, voice, path): |
| await edge_tts.Communicate(text, voice).save(path) |
|
|
| def run_tts(text: str) -> str: |
| tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) |
| tmp.close() |
| for voice in [TTS_VOICE2, TTS_VOICE]: |
| try: |
| asyncio.run(_tts(text, voice, tmp.name)) |
| if os.path.getsize(tmp.name) > 0: |
| with open(tmp.name, "rb") as f: |
| b64 = base64.b64encode(f.read()).decode() |
| os.unlink(tmp.name) |
| return b64 |
| except Exception as e: |
| print(f"[TTS] {voice} failed: {e}") |
| return "" |
|
|
| |
| def get_vrm_b64() -> str: |
| if os.path.exists(VRM_PATH): |
| with open(VRM_PATH, "rb") as f: |
| return base64.b64encode(f.read()).decode() |
| return "" |
|
|
| def get_vrma_b64(name: str) -> str: |
| if not name or not os.path.exists(ANIM_ZIP): |
| return "" |
| with zipfile.ZipFile(ANIM_ZIP, "r") as z: |
| for n in z.namelist(): |
| if n == name or os.path.basename(n) == name: |
| return base64.b64encode(z.read(n)).decode() |
| return "" |
|
|
| def get_idle_b64(): |
| for p in ["natural2.vrma", "natural.vrma", "idle.vrma", "stand.vrma"]: |
| b = get_vrma_b64(p) |
| if b: |
| return b |
| if VRMA_LIST: |
| return get_vrma_b64(VRMA_LIST[0]) |
| return "" |
|
|
| VRM_B64 = get_vrm_b64() |
| IDLE_B64 = get_idle_b64() |
|
|
| |
| def chat(user_msg: str, history: list): |
| if not user_msg.strip(): |
| return history, "{}" |
| try: |
| result = generate_response(user_msg, history) |
| except Exception as e: |
| result = {"response": f"Sorry, error: {e}", "emotion": "sad", |
| "animation": None, "intensity": 0.5} |
|
|
| text = result.get("response", "β¦") |
| emotion = result.get("emotion", "neutral") |
| animation = result.get("animation") |
| intensity = result.get("intensity", 0.7) |
|
|
| audio_b64 = run_tts(text) |
| anim_b64 = get_vrma_b64(animation) if animation else "" |
| blinking = EMOTIONS_MAP.get(emotion, EMOTIONS_MAP["neutral"])["blinking"] |
|
|
| payload = json.dumps({ |
| "type": "chat_response", |
| "response": text, |
| "emotion": emotion, |
| "blinking": blinking, |
| "animation_b64": anim_b64, |
| "audio_b64": audio_b64, |
| "intensity": intensity, |
| }) |
| new_history = history + [[user_msg, json.dumps(result)]] |
| return new_history, payload |
|
|
| |
| |
| |
| INNER_CSS = """ |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| background: #0d0d18; |
| font-family: 'Segoe UI', system-ui, sans-serif; |
| color: #e8e8f0; |
| height: 100%; |
| overflow: hidden; |
| } |
| #app { |
| display: grid; |
| grid-template-columns: 1fr 340px; |
| height: 600px; |
| border-radius: 12px; |
| overflow: hidden; |
| border: 1px solid rgba(255,255,255,0.07); |
| } |
| |
| /* ββ VRM side ββ */ |
| #vrm-side { |
| position: relative; |
| background: linear-gradient(160deg,#0d0d1e,#080812,#0d0d1e); |
| overflow: hidden; |
| } |
| #vrm-canvas { position:absolute; inset:0; width:100%; height:100%; display:block; } |
| |
| #status-bar { |
| position:absolute; top:12px; left:12px; |
| display:flex; align-items:center; gap:7px; |
| background:rgba(8,8,18,.7); backdrop-filter:blur(10px); |
| border:1px solid rgba(255,255,255,.07); border-radius:18px; |
| padding:6px 13px; z-index:10; |
| } |
| #sdot { |
| width:7px; height:7px; border-radius:50%; background:#4ade80; |
| box-shadow:0 0 7px #4ade80; |
| animation:dpulse 2s ease-in-out infinite; |
| } |
| @keyframes dpulse{0%,100%{box-shadow:0 0 6px #4ade80}50%{box-shadow:0 0 14px #4ade80,0 0 22px rgba(74,222,128,.4)}} |
| #stext { font-size:11px; color:rgba(255,255,255,.6); } |
| |
| #emo-badge { |
| position:absolute; bottom:16px; left:16px; |
| background:rgba(8,8,18,.75); backdrop-filter:blur(10px); |
| border:1px solid rgba(99,102,241,.28); border-radius:12px; |
| padding:8px 14px; display:flex; align-items:center; gap:8px; z-index:10; |
| } |
| #emo-icon { font-size:18px; } |
| #emo-label { font-size:12px; font-weight:600; color:rgba(255,255,255,.85); text-transform:capitalize; } |
| |
| .particle { |
| position:absolute; border-radius:50%; pointer-events:none; |
| animation:fup linear infinite; z-index:3; |
| } |
| @keyframes fup{0%{transform:translateY(110%) scale(0);opacity:0}8%{opacity:1}92%{opacity:.5}100%{transform:translateY(-10%) scale(1.2);opacity:0}} |
| |
| /* loader */ |
| #loader { |
| position:absolute; inset:0; z-index:20; |
| background:rgba(8,8,18,.95); |
| display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; |
| transition:opacity .8s; |
| } |
| #loader.gone{opacity:0;pointer-events:none;} |
| .ld-logo{font-size:48px;animation:lpulse 2s ease-in-out infinite;} |
| @keyframes lpulse{0%,100%{filter:drop-shadow(0 0 14px rgba(99,102,241,.6))}50%{filter:drop-shadow(0 0 28px rgba(167,139,250,.8))}} |
| .ld-title{font-size:20px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#6366f1,#60a5fa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} |
| .ld-bar{width:140px;height:2px;background:rgba(255,255,255,.09);border-radius:1px;overflow:hidden;} |
| .ld-fill{height:100%;background:linear-gradient(90deg,#6366f1,#a78bfa);border-radius:1px;animation:lsweep 1.6s ease-in-out infinite;} |
| @keyframes lsweep{0%{width:0;margin-left:0}50%{width:50%;margin-left:25%}100%{width:0;margin-left:100%}} |
| .ld-status{font-size:11px;color:rgba(255,255,255,.4);} |
| |
| /* ββ Chat side ββ */ |
| #chat-side { |
| display:flex; flex-direction:column; |
| background:rgba(7,7,15,.97); |
| border-left:1px solid rgba(255,255,255,.055); |
| } |
| |
| #chat-header { |
| padding:16px 18px 12px; |
| border-bottom:1px solid rgba(255,255,255,.055); |
| background:rgba(10,10,20,.9); flex-shrink:0; |
| } |
| #chat-title{font-size:16px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#6366f1,#60a5fa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} |
| #chat-sub{font-size:10px;color:rgba(255,255,255,.35);margin-top:2px;} |
| #model-chip{display:inline-flex;align-items:center;gap:4px;margin-top:6px;background:rgba(99,102,241,.12);border:1px solid rgba(99,102,241,.25);border-radius:16px;padding:2px 9px;font-size:9px;color:#a78bfa;} |
| |
| #msgs { |
| flex:1; overflow-y:auto; padding:14px 12px; |
| display:flex; flex-direction:column; gap:11px; |
| scroll-behavior:smooth; |
| } |
| #msgs::-webkit-scrollbar{width:3px;} |
| #msgs::-webkit-scrollbar-thumb{background:rgba(99,102,241,.25);border-radius:2px;} |
| |
| .mrow{display:flex;gap:8px;animation:min .25s ease;} |
| .mrow.urow{flex-direction:row-reverse;} |
| @keyframes min{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} |
| |
| .mav{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;} |
| .mav.aav{background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 0 8px rgba(99,102,241,.4);} |
| .mav.uav{background:linear-gradient(135deg,#374151,#4b5563);} |
| |
| .mwrap{display:flex;flex-direction:column;max-width:85%;} |
| .urow .mwrap{align-items:flex-end;} |
| .mbub{padding:8px 12px;font-size:12.5px;line-height:1.6;word-break:break-word;} |
| .mbub.abub{background:rgba(99,102,241,.1);border:1px solid rgba(99,102,241,.17);border-radius:3px 13px 13px 13px;color:rgba(255,255,255,.87);} |
| .mbub.ubub{background:linear-gradient(135deg,rgba(99,102,241,.2),rgba(139,92,246,.16));border:1px solid rgba(139,92,246,.2);border-radius:13px 3px 13px 13px;color:rgba(255,255,255,.92);} |
| .mtime{font-size:9px;color:rgba(255,255,255,.25);margin-top:2px;} |
| |
| #typing{display:none;gap:8px;} |
| #typing.show{display:flex;} |
| .td{width:5px;height:5px;border-radius:50%;background:#6366f1;animation:tb 1.1s ease-in-out infinite;} |
| .td:nth-child(2){animation-delay:.17s;}.td:nth-child(3){animation-delay:.34s;} |
| @keyframes tb{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-5px);opacity:1}} |
| |
| #input-area{padding:12px;border-top:1px solid rgba(255,255,255,.055);background:rgba(8,8,18,.9);flex-shrink:0;} |
| #irow{display:flex;gap:8px;align-items:flex-end;} |
| #uinput{ |
| flex:1;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.09); |
| border-radius:16px;padding:10px 14px;color:rgba(255,255,255,.9); |
| font-size:13px;font-family:inherit;resize:none;outline:none; |
| line-height:1.5;min-height:42px;max-height:100px; |
| transition:border-color .2s,box-shadow .2s; |
| } |
| #uinput:focus{border-color:rgba(99,102,241,.45);box-shadow:0 0 0 2px rgba(99,102,241,.08);} |
| #uinput::placeholder{color:rgba(255,255,255,.25);} |
| #sbtn{ |
| width:42px;height:42px;border-radius:50%;border:none;cursor:pointer;flex-shrink:0; |
| background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px; |
| display:flex;align-items:center;justify-content:center; |
| transition:all .2s;box-shadow:0 3px 9px rgba(99,102,241,.4); |
| } |
| #sbtn:hover:not(:disabled){transform:scale(1.08);box-shadow:0 4px 16px rgba(99,102,241,.6);} |
| #sbtn:active:not(:disabled){transform:scale(.93);} |
| #sbtn:disabled{opacity:.4;cursor:not-allowed;} |
| </style> |
| """ |
|
|
| INNER_JS = """ |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.168.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.168.0/examples/jsm/", |
| "@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3.1.3/lib/three-vrm.module.js", |
| "@pixiv/three-vrm-animation":"https://cdn.jsdelivr.net/npm/@pixiv/three-vrm-animation@3.1.3/lib/three-vrm-animation.module.js" |
| } |
| } |
| </script> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
| import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; |
| import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation'; |
| |
| let renderer, scene, camera, clock; |
| let vrm=null, mixer=null; |
| let autoBlinkOn=true, isBlinking=false, blinkTimer=null; |
| let lipInterval=null, audioCtx=null, curEmo='neutral'; |
| let idleB64=''; |
| |
| const EMO_EXPR={ |
| happy:{n:'happy',w:1},sad:{n:'sad',w:1},angry:{n:'angry',w:1}, |
| surprised:{n:'surprised',w:1},fearful:{n:'sad',w:.8},disgusted:{n:'angry',w:.7}, |
| neutral:{n:'neutral',w:0},excited:{n:'happy',w:1},confused:{n:'surprised',w:.6}, |
| thinking:{n:'neutral',w:0},laughing:{n:'happy',w:1},crying:{n:'sad',w:1}, |
| love:{n:'happy',w:.9},shy:{n:'happy',w:.5},bored:{n:'neutral',w:0}, |
| sleepy:{n:'relaxed',w:.8},determined:{n:'angry',w:.4},proud:{n:'happy',w:.8}, |
| embarrassed:{n:'sad',w:.5},grateful:{n:'happy',w:.7},playful:{n:'happy',w:.9},serious:{n:'neutral',w:0} |
| }; |
| const NO_BLINK=new Set(['angry','surprised','laughing','crying','sleepy','disgusted']); |
| const EMO_ICON={happy:'π',sad:'π’',angry:'π ',surprised:'π²',fearful:'π¨',disgusted:'π€’', |
| neutral:'π',excited:'π€©',confused:'π',thinking:'π€',laughing:'π',crying:'π', |
| love:'π₯°',shy:'π',bored:'π',sleepy:'π΄',determined:'πͺ',proud:'π', |
| embarrassed:'π³',grateful:'π',playful:'π',serious:'π€'}; |
| |
| function setSt(t){const e=document.getElementById('stext');if(e)e.textContent=t;} |
| function setLd(t){const e=document.getElementById('ldst');if(e)e.textContent=t;} |
| function hideLoader(){const e=document.getElementById('loader');if(e)setTimeout(()=>e.classList.add('gone'),600);} |
| |
| function initThree(){ |
| const wrap=document.getElementById('vrm-side'); |
| const canvas=document.getElementById('vrm-canvas'); |
| renderer=new THREE.WebGLRenderer({canvas,antialias:true,alpha:true}); |
| renderer.setPixelRatio(Math.min(devicePixelRatio,2)); |
| renderer.setSize(wrap.clientWidth,wrap.clientHeight); |
| renderer.outputColorSpace=THREE.SRGBColorSpace; |
| renderer.shadowMap.enabled=true; |
| |
| scene=new THREE.Scene(); |
| scene.fog=new THREE.Fog(0x0a0a14,10,22); |
| |
| camera=new THREE.PerspectiveCamera(28,wrap.clientWidth/wrap.clientHeight,.1,100); |
| camera.position.set(0,1.38,3.0); |
| camera.lookAt(0,1.0,0); |
| |
| scene.add(new THREE.AmbientLight(0x6366f1,.45)); |
| const kl=new THREE.DirectionalLight(0xffffff,1.1);kl.position.set(2,3,2);kl.castShadow=true;scene.add(kl); |
| const fl=new THREE.DirectionalLight(0xa78bfa,.5);fl.position.set(-2,2,1);scene.add(fl); |
| const rl=new THREE.DirectionalLight(0x60a5fa,.4);rl.position.set(0,2,-3);scene.add(rl); |
| const pt=new THREE.PointLight(0x6366f1,.55,5);pt.position.set(0,.5,1.5);scene.add(pt); |
| |
| const floor=new THREE.Mesh(new THREE.PlaneGeometry(10,10), |
| new THREE.MeshStandardMaterial({color:0x0d0d1a,roughness:.85,metalness:.1,transparent:true,opacity:.55})); |
| floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;scene.add(floor); |
| const grid=new THREE.GridHelper(8,20,0x6366f1,0x1a1a2e); |
| grid.material.opacity=.12;grid.material.transparent=true;scene.add(grid); |
| |
| clock=new THREE.Clock(); |
| new ResizeObserver(()=>{ |
| camera.aspect=wrap.clientWidth/wrap.clientHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(wrap.clientWidth,wrap.clientHeight); |
| }).observe(wrap); |
| animate(); |
| } |
| |
| function animate(){ |
| requestAnimationFrame(animate); |
| const dt=clock.getDelta(); |
| if(mixer)mixer.update(dt); |
| if(vrm){ |
| const t=clock.getElapsedTime(); |
| try{ |
| const sp=vrm.humanoid.getNormalizedBoneNode('spine'); |
| if(sp){sp.rotation.z=Math.sin(t*.5)*.01;sp.rotation.x=Math.sin(t*.3)*.008;} |
| const hd=vrm.humanoid.getNormalizedBoneNode('head'); |
| if(hd){hd.rotation.y=Math.sin(t*.4)*.03;hd.rotation.x=Math.sin(t*.25)*.014;} |
| }catch(e){} |
| vrm.update(dt); |
| } |
| renderer.render(scene,camera); |
| } |
| |
| function b64Blob(b64,mime='application/octet-stream'){ |
| const by=Uint8Array.from(atob(b64),c=>c.charCodeAt(0)); |
| return new Blob([by],{type:mime}); |
| } |
| |
| function loadVRM(b64){ |
| if(!b64||b64.length<100){setSt('No VRM file');hideLoader();return;} |
| const url=URL.createObjectURL(b64Blob(b64)); |
| const loader=new GLTFLoader(); |
| loader.register(p=>new VRMLoaderPlugin(p)); |
| loader.register(p=>new VRMAnimationLoaderPlugin(p)); |
| loader.load(url,gltf=>{ |
| const v=gltf.userData.vrm; |
| if(!v){setSt('Invalid VRM');hideLoader();URL.revokeObjectURL(url);return;} |
| VRMUtils.removeUnnecessaryJoints(v.scene); |
| VRMUtils.removeUnnecessaryVertices(v.scene); |
| if(vrm){scene.remove(vrm.scene);VRMUtils.deepDispose(vrm.scene);} |
| vrm=v; scene.add(vrm.scene); |
| vrm.scene.rotation.y=Math.PI; |
| const box=new THREE.Box3().setFromObject(vrm.scene); |
| const c=box.getCenter(new THREE.Vector3()); |
| vrm.scene.position.sub(c); vrm.scene.position.y=0; |
| URL.revokeObjectURL(url); |
| setSt('Ani is ready β¨'); hideLoader(); startBlink(); |
| if(idleB64)loadVRMA(idleB64,true); |
| },undefined,e=>{console.error(e);setSt('VRM error');hideLoader();URL.revokeObjectURL(url);}); |
| } |
| |
| function loadVRMA(b64,loop=true,onDone=null){ |
| if(!vrm||!b64||b64.length<10)return; |
| const url=URL.createObjectURL(b64Blob(b64)); |
| const loader=new GLTFLoader(); |
| loader.register(p=>new VRMAnimationLoaderPlugin(p)); |
| loader.load(url,gltf=>{ |
| const anims=gltf.userData.vrmAnimations; |
| if(!anims||!anims.length){URL.revokeObjectURL(url);return;} |
| const clip=createVRMAnimationClip(anims[0],vrm); |
| if(mixer)mixer.stopAllAction(); |
| mixer=new THREE.AnimationMixer(vrm.scene); |
| const act=mixer.clipAction(clip); |
| act.setLoop(loop?THREE.LoopRepeat:THREE.LoopOnce,Infinity); |
| act.clampWhenFinished=!loop; act.play(); |
| if(onDone&&!loop)mixer.addEventListener('finished',()=>{onDone();URL.revokeObjectURL(url);}); |
| else URL.revokeObjectURL(url); |
| },undefined,()=>URL.revokeObjectURL(url)); |
| } |
| |
| function startBlink(){ |
| if(blinkTimer)clearInterval(blinkTimer); |
| blinkTimer=setInterval(()=>{if(autoBlinkOn&&vrm&&!isBlinking)doBlink();},2200+Math.random()*2200); |
| } |
| function doBlink(){ |
| if(!vrm||isBlinking)return; |
| const em=vrm.expressionManager;if(!em)return; |
| isBlinking=true;let t=0;const step=16,dur=130; |
| const id=setInterval(()=>{ |
| t+=step;const w=Math.max(0,Math.sin((t/dur)*Math.PI)); |
| try{em.setValue('blink',w);}catch(e){} |
| if(t>=dur){clearInterval(id);try{em.setValue('blink',0);}catch(e){}isBlinking=false;} |
| },step); |
| } |
| |
| function applyEmotion(name,intensity=1){ |
| if(!vrm)return; |
| const em=vrm.expressionManager;if(!em)return; |
| ['happy','sad','angry','surprised','relaxed','neutral'].forEach(e=>{try{em.setValue(e,0);}catch(x){}}); |
| const m=EMO_EXPR[name]||EMO_EXPR.neutral; |
| if(m.w>0)try{em.setValue(m.n,m.w*intensity);}catch(e){} |
| autoBlinkOn=!NO_BLINK.has(name); curEmo=name; |
| const ic=document.getElementById('emo-icon');const lb=document.getElementById('emo-label'); |
| if(ic)ic.textContent=EMO_ICON[name]||'π'; |
| if(lb)lb.textContent=name; |
| } |
| |
| function startLip(arrayBuf){ |
| stopLip(); |
| if(!audioCtx)audioCtx=new(window.AudioContext||window.webkitAudioContext)(); |
| audioCtx.decodeAudioData(arrayBuf,decoded=>{ |
| const src=audioCtx.createBufferSource();src.buffer=decoded; |
| const an=audioCtx.createAnalyser();an.fftSize=256; |
| src.connect(an);an.connect(audioCtx.destination);src.start(0); |
| const d=new Uint8Array(an.frequencyBinCount); |
| lipInterval=setInterval(()=>{ |
| an.getByteFrequencyData(d); |
| const v=d.slice(2,22),avg=v.reduce((a,b)=>a+b,0)/v.length; |
| const mw=Math.min(1,avg/75); |
| if(vrm){const em=vrm.expressionManager;if(em)try{em.setValue('aa',mw*.85);}catch(e){}} |
| },33); |
| src.addEventListener('ended',()=>{stopLip();autoBlinkOn=!NO_BLINK.has(curEmo);}); |
| },()=>fallbackLip()); |
| } |
| function fallbackLip(){ |
| let t=0; |
| lipInterval=setInterval(()=>{ |
| t+=.1;const m=.25+.45*Math.abs(Math.sin(t*7))*Math.abs(Math.sin(t*2.7)); |
| if(vrm){const em=vrm.expressionManager;if(em)try{em.setValue('aa',m);}catch(e){}} |
| },50); |
| setTimeout(stopLip,4000); |
| } |
| function stopLip(){ |
| if(lipInterval){clearInterval(lipInterval);lipInterval=null;} |
| if(vrm){const em=vrm.expressionManager;if(em){try{em.setValue('aa',0);}catch(e){}}} |
| } |
| |
| // Exposed to window for cross-script access |
| window._vrmHandlePayload=function(payload){ |
| if(!payload||payload==='{}')return; |
| let d;try{d=JSON.parse(payload);}catch(e){return;} |
| if(!d||d.type!=='chat_response')return; |
| if(d.emotion)applyEmotion(d.emotion,d.intensity||.8); |
| if(d.animation_b64&&d.animation_b64.length>10) |
| loadVRMA(d.animation_b64,false,()=>{if(idleB64)loadVRMA(idleB64,true);}); |
| if(d.audio_b64&&d.audio_b64.length>10){ |
| const bin=atob(d.audio_b64),buf=new Uint8Array(bin.length); |
| for(let i=0;i<bin.length;i++)buf[i]=bin.charCodeAt(i); |
| startLip(buf.buffer.slice(0)); |
| // Play audio |
| try{new Audio('data:audio/mp3;base64,'+d.audio_b64).play().catch(()=>{});}catch(e){} |
| } |
| }; |
| |
| function mkParticles(){ |
| const side=document.getElementById('vrm-side');if(!side)return; |
| const cols=['rgba(99,102,241,','rgba(139,92,246,','rgba(96,165,250,']; |
| for(let i=0;i<12;i++){ |
| const p=document.createElement('div');p.className='particle'; |
| const sz=2+Math.random()*3,col=cols[i%3]; |
| p.style.cssText=`width:${sz}px;height:${sz}px;left:${Math.random()*100}%;`+ |
| `background:${col}${.3+Math.random()*.4});box-shadow:0 0 ${sz*2}px ${col}.6);`+ |
| `animation-duration:${9+Math.random()*12}s;animation-delay:${Math.random()*10}s;`; |
| side.appendChild(p); |
| } |
| } |
| |
| (function boot(){ |
| initThree(); mkParticles(); |
| setLd('Loading 3D modelβ¦'); |
| const vb=document.getElementById('__vrm_b64__'); |
| const ib=document.getElementById('__idle_b64__'); |
| idleB64=(ib&&ib.textContent.trim().length>100)?ib.textContent.trim():''; |
| if(vb&&vb.textContent.trim().length>100){ |
| loadVRM(vb.textContent.trim()); |
| }else{ |
| setSt('model/Ani.vrm not found'); hideLoader(); |
| } |
| })(); |
| </script> |
| |
| <script> |
| // Chat UI β plain script (no module needed) |
| (function(){ |
| const msgs = document.getElementById('msgs'); |
| const input = document.getElementById('uinput'); |
| const btn = document.getElementById('sbtn'); |
| const typing = document.getElementById('typing'); |
| let lastPayload=''; |
| |
| function now(){const d=new Date();return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');} |
| |
| function addMsg(text,role){ |
| const row=document.createElement('div');row.className='mrow'+(role==='user'?' urow':''); |
| const av=document.createElement('div');av.className='mav '+(role==='user'?'uav':'aav');av.textContent=role==='user'?'π€':'πΈ'; |
| const mw=document.createElement('div');mw.className='mwrap'; |
| const b=document.createElement('div');b.className='mbub '+(role==='user'?'ubub':'abub');b.textContent=text; |
| const t=document.createElement('div');t.className='mtime';t.textContent=now(); |
| mw.appendChild(b);mw.appendChild(t); |
| if(role==='user'){row.appendChild(mw);row.appendChild(av);}else{row.appendChild(av);row.appendChild(mw);} |
| msgs.insertBefore(row,document.getElementById('typing')); |
| msgs.scrollTop=msgs.scrollHeight; |
| } |
| |
| function showTyping(on){typing.classList.toggle('show',on);msgs.scrollTop=msgs.scrollHeight;} |
| |
| input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,100)+'px';}); |
| input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}); |
| btn.addEventListener('click',send); |
| |
| function send(){ |
| const txt=input.value.trim(); |
| if(!txt||btn.disabled)return; |
| addMsg(txt,'user'); |
| input.value='';input.style.height='auto'; |
| btn.disabled=true;showTyping(true); |
| |
| // Talk to Gradio via parent window postMessage |
| window.parent.postMessage({type:'ani_chat',message:txt},'*'); |
| } |
| |
| // Receive payload from parent |
| window.addEventListener('message',e=>{ |
| const d=e.data; |
| if(!d||d.type!=='ani_payload')return; |
| const p=d.payload; |
| if(!p||p===lastPayload)return; |
| lastPayload=p; |
| showTyping(false); |
| btn.disabled=false; |
| try{ |
| const obj=JSON.parse(p); |
| if(obj.response)addMsg(obj.response,'ai'); |
| if(window._vrmHandlePayload)window._vrmHandlePayload(p); |
| }catch(ex){} |
| }); |
| })(); |
| </script> |
| """ |
|
|
| def build_inner_html(): |
| vrm_b64_escaped = VRM_B64 if VRM_B64 else "" |
| idle_b64_escaped = IDLE_B64 if IDLE_B64 else "" |
| return f"""<!DOCTYPE html> |
| <html lang="en"> |
| <head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> |
| {INNER_CSS} |
| </head> |
| <body> |
| <div id="__vrm_b64__" style="display:none">{vrm_b64_escaped}</div> |
| <div id="__idle_b64__" style="display:none">{idle_b64_escaped}</div> |
| |
| <div id="app"> |
| <div id="vrm-side"> |
| <canvas id="vrm-canvas"></canvas> |
| <div id="loader"> |
| <div class="ld-logo">πΈ</div> |
| <div class="ld-title">Ani</div> |
| <div class="ld-bar"><div class="ld-fill"></div></div> |
| <div class="ld-status" id="ldst">Startingβ¦</div> |
| </div> |
| <div id="status-bar"><div id="sdot"></div><span id="stext">Loadingβ¦</span></div> |
| <div id="emo-badge"><span id="emo-icon">π</span><span id="emo-label">neutral</span></div> |
| </div> |
| |
| <div id="chat-side"> |
| <div id="chat-header"> |
| <div id="chat-title">β¨ Ani</div> |
| <div id="chat-sub">AI Anime Companion</div> |
| <div id="model-chip">π€ Qwen2.5-0.5B Β· ZeroGPU</div> |
| </div> |
| <div id="msgs"> |
| <div class="mrow"> |
| <div class="mav aav">πΈ</div> |
| <div class="mwrap"> |
| <div class="mbub abub">Hello! I'm Ani β¨ How can I help you today?</div> |
| <div class="mtime">just now</div> |
| </div> |
| </div> |
| <div id="typing"> |
| <div class="mav aav">πΈ</div> |
| <div class="mwrap"> |
| <div class="mbub abub" style="display:flex;align-items:center;gap:5px;padding:9px 12px;"> |
| <div class="td"></div><div class="td"></div><div class="td"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div id="input-area"> |
| <div id="irow"> |
| <textarea id="uinput" placeholder="Talk to Aniβ¦" rows="1"></textarea> |
| <button id="sbtn">β€</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| {INNER_JS} |
| </body> |
| </html>""" |
|
|
| INNER_HTML = build_inner_html() |
|
|
| |
| |
| |
| GRADIO_CSS = """ |
| .gradio-container { max-width: 100% !important; padding: 8px !important; background: #0a0a0f !important; } |
| footer { display: none !important; } |
| #ani-iframe { width:100%; border:none; border-radius:12px; height:608px; display:block; } |
| #payload-box textarea { display:none !important; } |
| #payload-box { display:none !important; } |
| #chat-hist { display:none !important; } |
| """ |
|
|
| with gr.Blocks(title="Ani AI Companion") as demo: |
|
|
| gr.HTML("<style>" + GRADIO_CSS.replace("<style>","").replace("</style>","") + "</style>") |
|
|
| chat_history = gr.State([]) |
| payload_store = gr.State("{}") |
|
|
| |
| iframe_html = f""" |
| <iframe id="ani-iframe" |
| srcdoc="{INNER_HTML.replace(chr(34), '"').replace(chr(39), ''')}" |
| sandbox="allow-scripts allow-same-origin" |
| style="width:100%;height:608px;border:none;border-radius:12px;display:block;" |
| ></iframe> |
| |
| <script> |
| // Bridge: receive messages from iframe, call Gradio, send payload back |
| window.addEventListener('message', function(e) {{ |
| if (!e.data || e.data.type !== 'ani_chat') return; |
| const msg = e.data.message; |
| if (!msg) return; |
| |
| // Trigger Gradio submit |
| const inp = document.querySelector('#gr-msg-input textarea') || |
| document.querySelector('#gr-msg-input input'); |
| if (inp) {{ |
| const nv = Object.getOwnPropertyDescriptor( |
| inp.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, |
| 'value' |
| ); |
| if (nv && nv.set) nv.set.call(inp, msg); |
| inp.dispatchEvent(new Event('input', {{bubbles: true}})); |
| }} |
| setTimeout(() => {{ |
| const gb = document.getElementById('gr-submit-btn'); |
| if (gb) gb.click(); |
| }}, 60); |
| }}); |
| |
| // Watch payload textbox and forward to iframe |
| function watchPayload() {{ |
| const sel = [ |
| '#gr-payload-out textarea', |
| '#gr-payload-out input', |
| ]; |
| let last = ''; |
| setInterval(() => {{ |
| let val = ''; |
| for (const s of sel) {{ |
| const el = document.querySelector(s); |
| if (el && el.value && el.value !== '{{}}') {{ val = el.value; break; }} |
| }} |
| if (!val || val === last) return; |
| last = val; |
| const iframe = document.getElementById('ani-iframe'); |
| if (iframe && iframe.contentWindow) {{ |
| iframe.contentWindow.postMessage({{type:'ani_payload', payload:val}}, '*'); |
| }} |
| }}, 300); |
| }} |
| watchPayload(); |
| </script> |
| """ |
| gr.HTML(iframe_html) |
|
|
| |
| with gr.Row(visible=False): |
| gr_msg = gr.Textbox(value="", label="msg", elem_id="gr-msg-input") |
| gr_btn = gr.Button("go", elem_id="gr-submit-btn") |
| gr_pay = gr.Textbox(value="{}", label="payload",elem_id="gr-payload-out") |
| gr_hist = gr.Chatbot(value=[], label="history", elem_id="gr-chat-hist") |
|
|
| |
| def on_submit(message: str, history: list): |
| if not message or not message.strip(): |
| return history, "{}" |
| new_history, payload = chat(message, history)[:2] |
| return new_history, payload |
|
|
| gr_btn.click( |
| fn=on_submit, |
| inputs=[gr_msg, chat_history], |
| outputs=[chat_history, gr_pay], |
| ) |
|
|
| |
| chat_history.change( |
| fn=lambda h: h, |
| inputs=[chat_history], |
| outputs=[gr_hist], |
| ) |
|
|
| |
|
|
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860) |