OpenCompanion / app.py
OrbitMC's picture
Update app.py
c3382e5 verified
Raw
History Blame Contribute Delete
35.2 kB
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
# ── Install deps ──────────────────────────────────────────────────────────────
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
# ── Constants ─────────────────────────────────────────────────────────────────
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" # small, fast, reliable
VRM_PATH = "model/Ani.vrm"
ANIM_ZIP = "animation/all_vrma.zip"
TTS_VOICE = "en-US-AriaNeural" # reliable fallback
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": "😀"},
}
# ── VRMA list ─────────────────────────────────────────────────────────────────
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()
# ── Model ─────────────────────────────────────────────────────────────────────
_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
# ── TTS ───────────────────────────────────────────────────────────────────────
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 ""
# ── VRM helpers ───────────────────────────────────────────────────────────────
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()
# ── Chat handler ──────────────────────────────────────────────────────────────
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
# ══════════════════════════════════════════════════════════════════════════════
# HTML for the iframe-like inner panel
# ══════════════════════════════════════════════════════════════════════════════
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 app
# ══════════════════════════════════════════════════════════════════════════════
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 holding the full custom UI ──
iframe_html = f"""
<iframe id="ani-iframe"
srcdoc="{INNER_HTML.replace(chr(34), '&quot;').replace(chr(39), '&#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)
# ── Hidden Gradio controls ──
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")
# ── Backend ──
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],
)
# Sync state β†’ chatbot widget (hidden but keeps history)
chat_history.change(
fn=lambda h: h,
inputs=[chat_history],
outputs=[gr_hist],
)
# JS: whenever gr_pay changes, the watchPayload loop above picks it up automatically
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)