Nafiul Huda commited on
Commit
8753bf6
·
verified ·
1 Parent(s): bbc4236

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +309 -504
app.py CHANGED
@@ -1,9 +1,4 @@
1
- #!/usr/bin/env python3
2
- """
3
- AI VRM Companion
4
- Fixes applied: Shared libraries path, Camera fly-away bug, and asset caching.
5
- """
6
- import os, sys, io, json, asyncio, logging, glob, subprocess, atexit
7
  from flask import Flask, request, send_file, send_from_directory, Response, jsonify
8
  import edge_tts
9
 
@@ -11,7 +6,7 @@ app = Flask(__name__)
11
  logging.basicConfig(level=logging.INFO)
12
 
13
  # ---------------------------------------------------------------------------
14
- # Paths
15
  # ---------------------------------------------------------------------------
16
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
17
  MODEL_DIR = os.path.join(BASE_DIR, "model")
@@ -19,38 +14,8 @@ ANIM_DIR = os.path.join(BASE_DIR, "animation")
19
  os.makedirs(MODEL_DIR, exist_ok=True)
20
  os.makedirs(ANIM_DIR, exist_ok=True)
21
 
22
- # ---------------------------------------------------------------------------
23
- # Background llama-server process
24
- # ---------------------------------------------------------------------------
25
- ggufs = glob.glob(os.path.join(BASE_DIR, "*.gguf"))
26
- model_path = ggufs[0] if ggufs else os.path.join(BASE_DIR, "minicpm5-1b-Q4_K_M.gguf")
27
-
28
- llama_proc = None
29
- if os.path.exists(model_path):
30
- logging.info(f"Starting local LLM server with model {model_path}...")
31
- try:
32
- # Run pre-compiled llama-server natively. Ensure library paths are set.
33
- env = os.environ.copy()
34
- env["LD_LIBRARY_PATH"] = "/app"
35
-
36
- llama_proc = subprocess.Popen([
37
- "./llama-server",
38
- "-m", model_path,
39
- "--host", "127.0.0.1",
40
- "--port", "8000",
41
- "-c", "2048",
42
- "--threads", "4"
43
- ], stdout=subprocess.DEVNULL, stderr=sys.stderr, env=env)
44
- except Exception as e:
45
- logging.error(f"Failed to start llama-server: {e}")
46
- else:
47
- logging.warning(f"No GGUF model found at {model_path}.")
48
-
49
- def cleanup():
50
- if llama_proc:
51
- llama_proc.terminate()
52
-
53
- atexit.register(cleanup)
54
 
55
  # ---------------------------------------------------------------------------
56
  # API Endpoints
@@ -81,25 +46,20 @@ def tts():
81
 
82
  @app.route("/local_chat", methods=["POST"])
83
  def local_chat():
84
- if not llama_proc:
85
- return jsonify({"error": "Local LLM server is not running."}), 500
86
-
87
- data = request.json
88
  messages = [
89
- {"role": "system", "content": data.get("system", "")},
90
  {"role": "user", "content": data.get("prompt", "")}
91
  ]
92
-
93
  try:
94
- import urllib.request, json
95
  req = urllib.request.Request(
96
- "http://127.0.0.1:8000/v1/chat/completions",
97
  headers={"Content-Type": "application/json"},
98
  data=json.dumps({
99
  "messages": messages,
100
  "temperature": 0.75,
101
- "max_tokens": 256,
102
- "response_format": {"type": "json_object"}
103
  }).encode("utf-8")
104
  )
105
  with urllib.request.urlopen(req, timeout=120) as response:
@@ -107,9 +67,8 @@ def local_chat():
107
  text = res_data['choices'][0]['message']['content'].strip()
108
  return jsonify({"result": text})
109
  except Exception as e:
110
- return jsonify({"error": str(e)}), 500
111
 
112
- # Cache the 3D assets heavily (30 days) so it only loads slowly the VERY FIRST time.
113
  @app.route("/model/<path:filename>")
114
  def serve_model(filename):
115
  return send_from_directory(MODEL_DIR, filename, max_age=2592000)
@@ -123,465 +82,311 @@ def index():
123
  return Response(HTML_PAGE, mimetype="text/html")
124
 
125
  # ---------------------------------------------------------------------------
126
- # Frontend HTML
127
  # ---------------------------------------------------------------------------
128
  HTML_PAGE = r"""<!DOCTYPE html>
129
  <html lang="en">
130
  <head>
131
- <meta charset="UTF-8">
132
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
133
- <title>AI Companion</title>
134
- <style>
135
- *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
136
- :root{
137
- --bg:#fff5f5;--surface:rgba(255,240,245,.85);--surface2:rgba(255,220,230,.55);
138
- --accent:#e8859a;--accent2:#f4a5b8;--accent3:#fcd4df;
139
- --text:#4a2030;--text2:#8a5060;--text3:#c08898;
140
- --radius:16px;--shadow:0 2px 16px rgba(180,80,110,.10);
141
- }
142
- html,body{height:100%;overflow:hidden;color:var(--text);font:13px/1.45 'SF Pro Text',-apple-system,system-ui,sans-serif;touch-action:none;user-select:none;-webkit-user-select:none;background:var(--bg)}
143
- #c{position:fixed;top:0;left:0;width:100%;height:100%}
144
- #c canvas{display:block;width:100%;height:100%}
145
- #load{position:fixed;top:env(safe-area-inset-top,12px);left:50%;transform:translateX(-50%);background:var(--surface);padding:7px 18px;border-radius:20px;font-size:11px;color:var(--accent);z-index:25;backdrop-filter:blur(12px);opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;box-shadow:var(--shadow);letter-spacing:.02em}
146
- #load.on{opacity:1}
147
- #err{position:fixed;top:env(safe-area-inset-top,12px);left:12px;right:12px;background:rgba(200,40,60,.92);color:#fff;padding:10px 16px;border-radius:var(--radius);font-size:12px;z-index:60;display:none;text-align:center;backdrop-filter:blur(8px)}
148
- #info{position:fixed;top:env(safe-area-inset-top,12px);left:12px;right:12px;background:var(--surface);color:var(--accent);padding:8px 16px;border-radius:var(--radius);font-size:11px;z-index:59;display:none;text-align:center;backdrop-filter:blur(10px);box-shadow:var(--shadow)}
149
- #speakDot, #thinkDot{position:fixed;bottom:calc(78px + env(safe-area-inset-bottom,0));left:50%;transform:translateX(-50%);display:flex;gap:4px;z-index:29;opacity:0;transition:opacity .3s}
150
- #speakDot.on, #thinkDot.on{opacity:1}
151
- .sdot{width:4px;height:14px;border-radius:2px;background:var(--accent);animation:sdotPulse .55s infinite alternate}
152
- .sdot:nth-child(2){animation-delay:.12s;height:18px}
153
- .sdot:nth-child(3){animation-delay:.24s;height:12px}
154
- .sdot:nth-child(4){animation-delay:.36s;height:16px}
155
- @keyframes sdotPulse{0%{transform:scaleY(.4);opacity:.5}100%{transform:scaleY(1);opacity:1}}
156
- .tdot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:tdotBounce .7s infinite}
157
- .tdot:nth-child(2){animation-delay:.15s}
158
- .tdot:nth-child(3){animation-delay:.3s}
159
- @keyframes tdotBounce{0%,80%,100%{transform:translateY(0);opacity:.4}40%{transform:translateY(-10px);opacity:1}}
160
- #bar{position:fixed;left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0));z-index:30;display:flex;gap:8px;align-items:center}
161
- #ti{flex:1;background:var(--surface);border:1.5px solid var(--accent3);color:var(--text);padding:13px 18px;border-radius:24px;font:13px/1.4 inherit;outline:none;backdrop-filter:blur(12px);min-height:48px;user-select:text;box-shadow:var(--shadow);transition:border-color .2s,box-shadow .2s}
162
- #ti::placeholder{color:var(--text3)}
163
- #ti:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(232,133,154,.15)}
164
- .btn{width:46px;height:46px;border-radius:50%;border:none;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .12s,opacity .2s}
165
- #sb{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 3px 12px rgba(232,133,154,.35)}
166
- #mb{background:var(--surface);color:var(--accent);border:1.5px solid var(--accent3);backdrop-filter:blur(12px);box-shadow:var(--shadow)}
167
- .btn:active:not(:disabled){transform:scale(.92)}
168
- #mb svg, #sb svg{width:18px;height:18px}
169
- #menu{position:fixed;left:0;right:0;bottom:0;background:var(--surface);backdrop-filter:blur(28px);z-index:40;transform:translateY(100%);transition:transform .32s cubic-bezier(.22,.9,.36,1);padding:20px 18px calc(28px + env(safe-area-inset-bottom,0));border-radius:22px 22px 0 0;border-top:1.5px solid var(--accent3);max-height:82vh;overflow-y:auto}
170
- #menu.open{transform:translateY(0)}
171
- #menu h3{font-size:9.5px;color:var(--accent);text-transform:uppercase;letter-spacing:.14em;margin:18px 0 8px;font-weight:700}
172
- #menu h3:first-child{margin-top:0}
173
- .close{position:absolute;top:12px;right:16px;background:none;border:none;color:var(--text3);font-size:22px;cursor:pointer;line-height:1;padding:4px}
174
- .row{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px}
175
- .row label{flex:0 0 90px;color:var(--text2);font-size:11px;font-weight:500}
176
- .row input,.row select{flex:1;background:rgba(255,255,255,.7);border:1px solid var(--accent3);color:var(--text);padding:9px 12px;border-radius:10px;font-size:12px;outline:none}
177
- .row select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23e8859a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:28px}
178
- .hint{font-size:10px;color:var(--text3);margin:2px 0 6px;line-height:1.4}
179
- .divider{height:1px;background:var(--accent3);margin:14px 0;opacity:.5}
180
- .provider-tabs{display:flex;gap:4px;margin:8px 0}
181
- .ptab{flex:1;padding:8px 0;text-align:center;font-size:11px;font-weight:600;border-radius:10px;cursor:pointer;border:1.5px solid var(--accent3);color:var(--text2);background:transparent}
182
- .ptab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
183
- .provider-config{display:none}
184
- .provider-config.active{display:block}
185
- .credits{margin-top:18px;padding-top:14px;border-top:1px solid var(--accent3);font-size:10px;color:var(--text3);line-height:1.6}
186
- @media(min-width:768px){
187
- #bar{left:50%;right:auto;transform:translateX(-50%);width:min(600px,90vw)}
188
- #menu{left:50%;right:auto;transform:translateX(-50%) translateY(100%);width:min(460px,94vw);border-radius:22px 22px 0 0}
189
- #menu.open{transform:translateX(-50%) translateY(0)}
190
- }
191
- </style>
192
  </head>
193
  <body>
194
- <div id="c"></div>
195
- <div id="load">Loading...</div>
196
- <div id="err"></div>
197
- <div id="info"></div>
198
- <div id="thinkDot"><div class="tdot"></div><div class="tdot"></div><div class="tdot"></div></div>
199
- <div id="speakDot"><div class="sdot"></div><div class="sdot"></div><div class="sdot"></div><div class="sdot"></div></div>
200
- <div id="bar">
201
- <button id="mb" class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
202
- <input id="ti" type="text" placeholder="Say something sweet..." autocomplete="off">
203
- <button id="sb" class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
204
- </div>
205
- <div id="menu">
206
- <button class="close" id="mc">&times;</button>
207
- <h3>AI Provider</h3>
208
- <div class="provider-tabs">
209
- <div class="ptab active" data-prov="google">Google</div>
210
- <div class="ptab" data-prov="groq">Groq</div>
211
- <div class="ptab" data-prov="openrouter">OpenRouter</div>
212
- <div class="ptab" data-prov="local">Local CPU</div>
213
- </div>
214
-
215
- <div class="provider-config active" id="prov-google">
216
- <div class="row"><label>API Key</label><input type="password" id="googleApiKey"></div>
217
- <div class="row"><label>Model</label><select id="googleModel"><option value="gemini-2.5-flash" selected>Gemini 2.5 Flash</option></select></div>
218
- </div>
219
-
220
- <div class="provider-config" id="prov-groq">
221
- <div class="row"><label>API Key</label><input type="password" id="groqApiKey"></div>
222
- <div class="row"><label>Model</label><select id="groqModel"><option value="llama-3.3-70b-versatile" selected>Llama 3.3 70B</option></select></div>
223
- </div>
224
-
225
- <div class="provider-config" id="prov-openrouter">
226
- <div class="row"><label>API Key</label><input type="password" id="openrouterApiKey"></div>
227
- <div class="row"><label>Model</label><select id="openrouterModel"><option value="google/gemini-2.5-flash" selected>Gemini 2.5 Flash</option></select></div>
228
- </div>
229
-
230
- <div class="provider-config" id="prov-local">
231
- <div class="row"><label>Model</label><input type="text" value="MiniCPM-1B" disabled></div>
232
- <div class="hint">Uses pre-compiled C++ llama-server. Fully offline processing.</div>
233
- </div>
234
-
235
- <div class="divider"></div>
236
- <h3>Voice</h3>
237
- <div class="row"><label>TTS Voice</label>
238
- <select id="ttsVoice">
239
- <option value="zh-CN-XiaoyiNeural" selected>Xiaoyi (Chinese)</option>
240
- <option value="en-US-AriaNeural">Aria (English US)</option>
241
- <option value="ja-JP-NanamiNeural">Nanami (Japanese)</option>
242
- </select>
243
- </div>
244
-
245
- <div class="divider"></div>
246
- <div class="credits">
247
- <p>Models are cached in browser storage after first load for instant boots.</p>
248
- </div>
249
- </div>
250
-
251
- <script type="importmap">
252
- {"imports":{
253
- "three":"https://esm.sh/three@0.160.0",
254
- "three/addons/":"https://esm.sh/three@0.160.0/examples/jsm/",
255
- "@pixiv/three-vrm":"https://esm.sh/@pixiv/three-vrm@3.3.4?deps=three@0.160.0",
256
- "@pixiv/three-vrm-animation":"https://esm.sh/@pixiv/three-vrm-animation@3.3.4?deps=three@0.160.0,@pixiv/three-vrm@3.3.4"
257
- }}
258
- </script>
259
-
260
- <script type="module">
261
- import * as THREE from 'three';
262
- import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
263
- import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
264
- import {VRMLoaderPlugin,VRMUtils} from '@pixiv/three-vrm';
265
- import {VRMAnimationLoaderPlugin,createVRMAnimationClip} from '@pixiv/three-vrm-animation';
266
-
267
- const isMobile = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
268
- const VISEMES = ['aa','ih','ou','ee','oh','a','i','u','e','o'];
269
- const EXPR_NAMES = ['happy','sad','angry','surprised','relaxed','neutral','blink','lookUp','lookDown','lookLeft','lookRight','aa','ih','ou','ee','oh'];
270
- const FALLBACK_VRMAS = ['neutral2.vrma','neutral3.vrma','neutral_idle.vrma'];
271
-
272
- const scene = new THREE.Scene();
273
- scene.background = new THREE.Color(0xfff5f5);
274
- const camera = new THREE.PerspectiveCamera(40, innerWidth/innerHeight, 0.01, 100);
275
- camera.position.set(0, 1.4, 2.0);
276
-
277
- const renderer = new THREE.WebGLRenderer({antialias:!isMobile, alpha:true});
278
- renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
279
- renderer.setSize(innerWidth, innerHeight);
280
- renderer.outputColorSpace = THREE.SRGBColorSpace;
281
- document.getElementById('c').appendChild(renderer.domElement);
282
-
283
- const orbit = new OrbitControls(camera, renderer.domElement);
284
- orbit.target.set(0, 1.3, 0);
285
- orbit.enableDamping = true;
286
- orbit.dampingFactor = 0.08;
287
- orbit.minDistance = 0.3;
288
- orbit.maxDistance = 10;
289
- orbit.maxPolarAngle = Math.PI * 0.85;
290
-
291
- window.addEventListener('resize', () => {
292
- camera.aspect = window.innerWidth / window.innerHeight;
293
- camera.updateProjectionMatrix();
294
- renderer.setSize(window.innerWidth, window.innerHeight);
295
- });
296
-
297
- scene.add(new THREE.AmbientLight(0xfff4e8, 0.6));
298
- const keyL = new THREE.DirectionalLight(0xfff2e0, 1.6);
299
- keyL.position.set(3, 4, 3);
300
- scene.add(keyL);
301
-
302
- let vrm = null, mixer = null, vrmaAction = null;
303
- let vrmaFiles = {}, vrmaNames = [];
304
- let proceduralMotion = 'idle';
305
- let speaking = false, speechActive = false;
306
- let targetVisemes = {}; VISEMES.forEach(v => targetVisemes[v] = 0);
307
- let exprCur = {}, exprTgt = {}, exprAlias = {};
308
- let blinkT = 0, nextBlink = 2, blinkV = 0;
309
- let vrmLoadedOk = false;
310
- let activeProvider = 'google';
311
-
312
- let JSZip = null;
313
- async function ensureJSZip(){
314
- if(JSZip) return JSZip;
315
- return new Promise((res,rej)=>{
316
- const s = document.createElement('script');
317
- s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
318
- s.onload = ()=>{ JSZip = window.JSZip; res(JSZip); };
319
- document.head.appendChild(s);
320
- });
321
- }
322
-
323
- const gltfLoader = new GLTFLoader();
324
- gltfLoader.register(p => new VRMLoaderPlugin(p));
325
- gltfLoader.register(p => new VRMAnimationLoaderPlugin(p));
326
-
327
- async function loadVrmFromUrl(url) {
328
- setLoading(true, 'Downloading 3D Character (may take a moment)...');
329
- return new Promise((resolve, reject) => {
330
- gltfLoader.load(url, gltf => {
331
- const newVrm = gltf.userData.vrm;
332
- VRMUtils.rotateVRM0(newVrm);
333
-
334
- if(vrm) scene.remove(vrm.scene);
335
- vrm = newVrm;
336
- scene.add(vrm.scene);
337
-
338
- // Standardize size so camera doesn't mess up
339
- const box = new THREE.Box3().setFromObject(vrm.scene);
340
- vrm.scene.scale.setScalar(1.65 / Math.max(box.getSize(new THREE.Vector3()).y, 0.01));
341
- vrm.scene.position.set(0, 0, 0);
342
-
343
- exprAlias = {};
344
- const names = vrm.expressionManager?.expressions?.map(e => e.name) || [];
345
- EXPR_NAMES.forEach(t => { const f = names.find(n => n.toLowerCase()===t.toLowerCase()); if(f) exprAlias[t]=f; });
346
-
347
- // Focus camera cleanly once
348
- const head = vrm.humanoid?.getRawBoneNode('head');
349
- if(head){
350
- const wp = new THREE.Vector3();
351
- head.getWorldPosition(wp);
352
- orbit.target.set(wp.x, wp.y - 0.05, wp.z);
353
- camera.position.set(wp.x, wp.y, wp.z + 1.2);
354
- orbit.update();
355
- }
356
-
357
- vrmLoadedOk = true;
358
- setLoading(false);
359
- showInfo('Assets Cached. Loading will be instant next time!', 4000);
360
- resolve();
361
- }, xhr => {
362
- if(xhr.total > 0) setLoading(true, `Downloading 3D Character ${Math.round(xhr.loaded/xhr.total*100)}%`);
363
- }, err => { showErr('VRM load failed'); reject(err); });
364
- });
365
- }
366
-
367
- function setExpressionValue(name, val){
368
- try { vrm?.expressionManager?.setValue(exprAlias[name] || name, val); } catch(e){}
369
- }
370
- function getExpressionValue(name){
371
- try { return vrm?.expressionManager?.getValue(exprAlias[name] || name) || 0; } catch(e){ return 0; }
372
- }
373
-
374
- function updateBlink(dt){
375
- if(!vrm) return;
376
- if(exprCur.happy > 0.3 || exprCur.sad > 0.3 || exprCur.angry > 0.3) {
377
- setExpressionValue('blink', 0); blinkV = 0; blinkT = 0; return;
378
- }
379
- blinkT += dt;
380
- if(blinkT > nextBlink){
381
- if(blinkV === 0) blinkV = 1;
382
- if(blinkV === 1){
383
- let v = getExpressionValue('blink') + 12*dt;
384
- if(v >= 1){ v=1; blinkV=-1; }
385
- setExpressionValue('blink', v);
386
- } else if(blinkV === -1){
387
- let v = getExpressionValue('blink') - 12*dt;
388
- if(v <= 0){ v=0; blinkV=0; blinkT=0; nextBlink=2+Math.random()*4; }
389
- setExpressionValue('blink', v);
390
- }
391
- }
392
- }
393
-
394
- function updateExpressions(dt){
395
- if(!vrm) return;
396
- for(const name of EXPR_NAMES){
397
- if(name.startsWith('blink') || VISEMES.includes(name)) continue;
398
- let cur = exprCur[name] || 0;
399
- cur += ((exprTgt[name] || 0) - cur) * 6 * dt;
400
- exprCur[name] = cur;
401
- setExpressionValue(name, cur);
402
- }
403
- for(const v of VISEMES){
404
- let cur = getExpressionValue(v);
405
- cur += ((targetVisemes[v] || 0) - cur) * 18 * dt;
406
- setExpressionValue(v, THREE.MathUtils.clamp(cur, 0, 1.5));
407
- }
408
- }
409
-
410
- function loadVrmaFromBlob(blob, name){
411
- if(!vrm) return;
412
- const url = URL.createObjectURL(blob);
413
- gltfLoader.load(url, gltf => {
414
- URL.revokeObjectURL(url);
415
- if(!mixer) mixer = new THREE.AnimationMixer(vrm.scene);
416
- const clip = createVRMAnimationClip(gltf.userData.vrmAnimations[0], vrm);
417
- const next = mixer.clipAction(clip).reset().setLoop(THREE.LoopRepeat, Infinity);
418
- if(vrmaAction) vrmaAction.crossFadeTo(next, 0.35, true);
419
- next.play();
420
- vrmaAction = next;
421
- });
422
- }
423
-
424
- async function autoLoadAnimations(){
425
- try {
426
- const Zip = await ensureJSZip();
427
- let res = await fetch('/animation/all_vrma.zip');
428
- if(!res.ok) res = await fetch('https://raw.githubusercontent.com/unsloth/vrm/main/all_vrma.zip');
429
- const zip = await Zip.loadAsync(await res.blob());
430
- const promises = [];
431
- zip.forEach((path, entry) => {
432
- if(!entry.dir && path.toLowerCase().endsWith('.vrma')){
433
- promises.push(entry.async('blob').then(b => {
434
- const name = path.split('/').pop();
435
- vrmaFiles[name] = b; vrmaNames.push(name);
436
- }));
437
- }
438
- });
439
- await Promise.all(promises);
440
- const m = Object.keys(vrmaFiles).find(k => k.includes('neutral2'));
441
- if(m && vrm) loadVrmaFromBlob(vrmaFiles[m], m);
442
- } catch(e){}
443
- }
444
-
445
- let audioCtx = null, analyser = null, dataArray = null, audioObj = null;
446
- function playTTSAudio(blob){
447
- if(!audioCtx) {
448
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
449
- analyser = audioCtx.createAnalyser(); analyser.fftSize = 256;
450
- dataArray = new Uint8Array(analyser.frequencyBinCount);
451
- }
452
- if(audioCtx.state === 'suspended') audioCtx.resume();
453
- if(audioObj){ audioObj.pause(); audioObj.src=''; }
454
-
455
- audioObj = new Audio(URL.createObjectURL(blob));
456
- const src = audioCtx.createMediaElementSource(audioObj);
457
- src.connect(analyser);
458
- analyser.connect(audioCtx.destination);
459
-
460
- speechActive = true;
461
- document.getElementById('speakDot').classList.add('on');
462
- audioObj.play();
463
-
464
- audioObj.onended = () => {
465
- speechActive = false;
466
- document.getElementById('speakDot').classList.remove('on');
467
- VISEMES.forEach(v => setExpressionValue(v, 0));
468
- for(const n of EXPR_NAMES) exprTgt[n] = 0; exprTgt['neutral'] = 1;
469
- };
470
- }
471
-
472
- async function speakText(text){
473
- const voice = document.getElementById('ttsVoice').value;
474
- try {
475
- const res = await fetch(`/tts?` + new URLSearchParams({text, voice}));
476
- if(res.ok) playTTSAudio(await res.blob());
477
- } catch(e){}
478
- }
479
-
480
- async function callAI(userPrompt){
481
- const sys = `You are a devoted anime companion. Respond ONLY in valid JSON format! { "text": "speech here", "expressions": {"happy": 0.8} }`;
482
- let raw = '';
483
-
484
- if(activeProvider === 'local'){
485
- const res = await fetch('/local_chat', {
486
- method:'POST', headers:{'Content-Type':'application/json'},
487
- body: JSON.stringify({prompt: userPrompt, system: sys})
488
- });
489
- raw = (await res.json()).result;
490
- } else {
491
- const key = document.getElementById('googleApiKey').value;
492
- const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`, {
493
- method:'POST', headers:{'Content-Type':'application/json'},
494
- body: JSON.stringify({contents:[{role:'user',parts:[{text:userPrompt}]}], systemInstruction:{parts:[{text:sys}]}})
495
- });
496
- raw = (await res.json()).candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';
497
- }
498
-
499
- try { return JSON.parse(raw.replace(/```json/gi,'').replace(/```/g,'').trim()); }
500
- catch(e) { return { text: raw, expressions: { neutral: 1 } }; }
501
- }
502
-
503
- document.getElementById('sb').addEventListener('click', async () => {
504
- const ti = document.getElementById('ti');
505
- const prompt = ti.value.trim();
506
- if(!prompt || !vrmLoadedOk) return;
507
- ti.value = ''; ti.blur();
508
-
509
- setLoading(true, 'thinking...');
510
- document.getElementById('thinkDot').classList.add('on');
511
- try {
512
- const res = await callAI(prompt);
513
- if(res.text) speakText(res.text);
514
- if(res.expressions){
515
- for(const n of EXPR_NAMES) exprTgt[n] = 0;
516
- for(const [k,v] of Object.entries(res.expressions)) if(EXPR_NAMES.includes(k)) exprTgt[k] = v;
517
- }
518
- } catch(e){ showErr('AI Error: '+e.message); }
519
- setLoading(false);
520
- document.getElementById('thinkDot').classList.remove('on');
521
- });
522
-
523
- const menu = document.getElementById('menu');
524
- document.getElementById('mb').addEventListener('click', () => menu.classList.toggle('open'));
525
- document.getElementById('mc').addEventListener('click', () => menu.classList.remove('open'));
526
- document.querySelectorAll('.ptab').forEach(tab => {
527
- tab.addEventListener('click', () => {
528
- document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
529
- document.querySelectorAll('.provider-config').forEach(c => c.classList.remove('active'));
530
- tab.classList.add('active');
531
- activeProvider = tab.dataset.prov;
532
- document.getElementById('prov-'+activeProvider).classList.add('active');
533
- });
534
- });
535
- function showErr(m){ const e=document.getElementById('err'); e.textContent=m; e.style.display='block'; setTimeout(()=>e.style.display='none',4000); }
536
- function setLoading(on, t=''){ const e=document.getElementById('load'); e.textContent=t; on?e.classList.add('on'):e.classList.remove('on'); }
537
- function showInfo(m, d){ const e=document.getElementById('info'); e.textContent=m; e.style.display='block'; setTimeout(()=>e.style.display='none',d); }
538
-
539
- const clock = new THREE.Clock();
540
- function animate(){
541
- requestAnimationFrame(animate);
542
- const dt = Math.min(clock.getDelta(), 0.1);
543
- const t = clock.getElapsedTime();
544
-
545
- orbit.update();
546
-
547
- if(vrm){
548
- if(speechActive && analyser && dataArray){
549
- analyser.getByteFrequencyData(dataArray);
550
- const b = (dataArray[2] + dataArray[3]) / 512;
551
- const m = (dataArray[10] + dataArray[11]) / 512;
552
- const h = (dataArray[20] + dataArray[21]) / 512;
553
- targetVisemes.oh = b * 1.8; targetVisemes.ou = b * 1.2;
554
- targetVisemes.aa = m * 2.0; targetVisemes.ee = h * 1.8; targetVisemes.ih = h * 1.0;
555
- } else { VISEMES.forEach(v => targetVisemes[v] = 0); }
556
-
557
- if(mixer) mixer.update(dt);
558
- updateBlink(dt);
559
- updateExpressions(dt);
560
- vrm.update(dt);
561
- }
562
-
563
- // FIX: Apply shake safely using visual translation, instantly reversing it to avoid drift.
564
- const shakeX = Math.sin(t * 1.2) * 0.001;
565
- const shakeY = Math.cos(t * 0.9) * 0.001;
566
- camera.translateX(shakeX);
567
- camera.translateY(shakeY);
568
-
569
- renderer.render(scene, camera);
570
-
571
- camera.translateX(-shakeX);
572
- camera.translateY(-shakeY);
573
- }
574
-
575
- async function init(){
576
- try { await loadVrmFromUrl('/model/Ani.vrm'); } catch(e){}
577
- await autoLoadAnimations();
578
- animate();
579
- }
580
- init();
581
- </script>
582
  </body>
583
- </html>"""
 
584
 
585
  if __name__ == "__main__":
586
- port = int(os.environ.get("PORT", 7860))
587
- app.run(host="0.0.0.0", port=port, debug=False)
 
1
+ import os, io, json, asyncio, logging, urllib.request
 
 
 
 
 
2
  from flask import Flask, request, send_file, send_from_directory, Response, jsonify
3
  import edge_tts
4
 
 
6
  logging.basicConfig(level=logging.INFO)
7
 
8
  # ---------------------------------------------------------------------------
9
+ # Paths & Config
10
  # ---------------------------------------------------------------------------
11
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
12
  MODEL_DIR = os.path.join(BASE_DIR, "model")
 
14
  os.makedirs(MODEL_DIR, exist_ok=True)
15
  os.makedirs(ANIM_DIR, exist_ok=True)
16
 
17
+ # Point to the internal llama-server managed by supervisor
18
+ LLAMA_BASE_URL = os.environ.get("LLAMA_BASE_URL", "http://127.0.0.1:8080")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  # ---------------------------------------------------------------------------
21
  # API Endpoints
 
46
 
47
  @app.route("/local_chat", methods=["POST"])
48
  def local_chat():
49
+ data = request.json or {}
 
 
 
50
  messages = [
51
+ {"role": "system", "content": data.get("system", "You are a helpful AI VRM companion. Keep responses short and sweet.")},
52
  {"role": "user", "content": data.get("prompt", "")}
53
  ]
54
+
55
  try:
 
56
  req = urllib.request.Request(
57
+ f"{LLAMA_BASE_URL}/v1/chat/completions",
58
  headers={"Content-Type": "application/json"},
59
  data=json.dumps({
60
  "messages": messages,
61
  "temperature": 0.75,
62
+ "max_tokens": 256
 
63
  }).encode("utf-8")
64
  )
65
  with urllib.request.urlopen(req, timeout=120) as response:
 
67
  text = res_data['choices'][0]['message']['content'].strip()
68
  return jsonify({"result": text})
69
  except Exception as e:
70
+ return jsonify({"error": f"Local LLM unreachable: {e}"}), 500
71
 
 
72
  @app.route("/model/<path:filename>")
73
  def serve_model(filename):
74
  return send_from_directory(MODEL_DIR, filename, max_age=2592000)
 
82
  return Response(HTML_PAGE, mimetype="text/html")
83
 
84
  # ---------------------------------------------------------------------------
85
+ # Frontend HTML (Completed)
86
  # ---------------------------------------------------------------------------
87
  HTML_PAGE = r"""<!DOCTYPE html>
88
  <html lang="en">
89
  <head>
90
+ <meta charset="UTF-8">
91
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
92
+ <title>AI Companion</title>
93
+ <style>
94
+ *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
95
+ :root{
96
+ --bg:#fff5f5;--surface:rgba(255,240,245,.85);--surface2:rgba(255,220,230,.55);
97
+ --accent:#e8859a;--accent2:#f4a5b8;--accent3:#fcd4df;
98
+ --text:#4a2030;--text2:#8a5060;--text3:#c08898;
99
+ --radius:16px;--shadow:0 2px 16px rgba(180,80,110,.10);
100
+ }
101
+ html,body{height:100%;overflow:hidden;color:var(--text);font:13px/1.45 'SF Pro Text',-apple-system,system-ui,sans-serif;touch-action:none;user-select:none;-webkit-user-select:none;background:var(--bg)}
102
+ #c{position:fixed;top:0;left:0;width:100%;height:100%}
103
+ #c canvas{display:block;width:100%;height:100%;touch-action:none}
104
+ #load{position:fixed;top:env(safe-area-inset-top,12px);left:50%;transform:translateX(-50%);background:var(--surface);padding:7px 18px;border-radius:20px;font-size:11px;color:var(--accent);z-index:25;backdrop-filter:blur(12px);opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;box-shadow:var(--shadow);letter-spacing:.02em}
105
+ #load.on{opacity:1}
106
+ #err{position:fixed;top:env(safe-area-inset-top,12px);left:12px;right:12px;background:rgba(200,40,60,.92);color:#fff;padding:10px 16px;border-radius:var(--radius);font-size:12px;z-index:60;display:none;text-align:center;backdrop-filter:blur(8px)}
107
+ #info{position:fixed;top:env(safe-area-inset-top,12px);left:12px;right:12px;background:var(--surface);color:var(--accent);padding:8px 16px;border-radius:var(--radius);font-size:11px;z-index:59;display:none;text-align:center;backdrop-filter:blur(10px);box-shadow:var(--shadow)}
108
+ #speakDot, #thinkDot{position:fixed;bottom:calc(78px + env(safe-area-inset-bottom,0));left:50%;transform:translateX(-50%);display:flex;gap:4px;z-index:29;opacity:0;transition:opacity .3s}
109
+ #speakDot.on, #thinkDot.on{opacity:1}
110
+ .sdot{width:4px;height:14px;border-radius:2px;background:var(--accent);animation:sdotPulse .55s infinite alternate}
111
+ .sdot:nth-child(2){animation-delay:.12s;height:18px}
112
+ .sdot:nth-child(3){animation-delay:.24s;height:12px}
113
+ .sdot:nth-child(4){animation-delay:.36s;height:16px}
114
+ @keyframes sdotPulse{0%{transform:scaleY(.4);opacity:.5}100%{transform:scaleY(1);opacity:1}}
115
+ .tdot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:tdotBounce .7s infinite}
116
+ .tdot:nth-child(2){animation-delay:.15s}
117
+ .tdot:nth-child(3){animation-delay:.3s}
118
+ @keyframes tdotBounce{0%,80%,100%{transform:translateY(0);opacity:.4}40%{transform:translateY(-10px);opacity:1}}
119
+ #bar{position:fixed;left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0));z-index:30;display:flex;gap:8px;align-items:center}
120
+ #ti{flex:1;background:var(--surface);border:1.5px solid var(--accent3);color:var(--text);padding:13px 18px;border-radius:24px;font:13px/1.4 inherit;outline:none;backdrop-filter:blur(12px);min-height:48px;user-select:text;box-shadow:var(--shadow);transition:border-color .2s,box-shadow .2s}
121
+ #ti::placeholder{color:var(--text3)}
122
+ #ti:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(232,133,154,.15)}
123
+ .btn{width:46px;height:46px;border-radius:50%;border:none;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .12s,opacity .2s}
124
+ #sb{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 3px 12px rgba(232,133,154,.35)}
125
+ #mb{background:var(--surface);color:var(--accent);border:1.5px solid var(--accent3);backdrop-filter:blur(12px);box-shadow:var(--shadow)}
126
+ .btn:active:not(:disabled){transform:scale(.92)}
127
+ #mb svg, #sb svg{width:18px;height:18px}
128
+ #menu{position:fixed;left:0;right:0;bottom:0;background:var(--surface);backdrop-filter:blur(28px);z-index:40;transform:translateY(100%);transition:transform .32s cubic-bezier(.22,.9,.36,1);padding:20px 18px calc(28px + env(safe-area-inset-bottom,0));border-radius:22px 22px 0 0;border-top:1.5px solid var(--accent3);max-height:82vh;overflow-y:auto}
129
+ #menu.open{transform:translateY(0)}
130
+ #menu h3{font-size:9.5px;color:var(--accent);text-transform:uppercase;letter-spacing:.14em;margin:18px 0 8px;font-weight:700}
131
+ #menu h3:first-child{margin-top:0}
132
+ .close{position:absolute;top:12px;right:16px;background:none;border:none;color:var(--text3);font-size:22px;cursor:pointer;line-height:1;padding:4px}
133
+ .row{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px}
134
+ .row label{flex:0 0 90px;color:var(--text2);font-size:11px;font-weight:500}
135
+ .row input,.row select{flex:1;background:rgba(255,255,255,.7);border:1px solid var(--accent3);color:var(--text);padding:9px 12px;border-radius:10px;font-size:12px;outline:none}
136
+ .row select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23e8859a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:28px}
137
+ .hint{font-size:10px;color:var(--text3);margin:2px 0 6px;line-height:1.4}
138
+ .divider{height:1px;background:var(--accent3);margin:14px 0;opacity:.5}
139
+ .provider-tabs{display:flex;gap:4px;margin:8px 0}
140
+ .ptab{flex:1;padding:8px 0;text-align:center;font-size:11px;font-weight:600;border-radius:10px;cursor:pointer;border:1.5px solid var(--accent3);color:var(--text2);background:transparent}
141
+ .ptab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
142
+ .provider-config{display:none}
143
+ .provider-config.active{display:block}
144
+ .credits{margin-top:18px;padding-top:14px;border-top:1px solid var(--accent3);font-size:10px;color:var(--text3);line-height:1.6}
145
+ @media(min-width:768px){
146
+ #bar{left:50%;right:auto;transform:translateX(-50%);width:min(600px,90vw)}
147
+ #menu{left:50%;right:auto;transform:translateX(-50%) translateY(100%);width:min(460px,94vw);border-radius:22px 22px 0 0}
148
+ #menu.open{transform:translateX(-50%) translateY(0)}
149
+ }
150
+ </style>
151
  </head>
152
  <body>
153
+ <div id="c"></div>
154
+ <div id="load">Loading...</div>
155
+ <div id="err"></div>
156
+ <div id="info"></div>
157
+ <div id="thinkDot"><div class="tdot"></div><div class="tdot"></div><div class="tdot"></div></div>
158
+ <div id="speakDot"><div class="sdot"></div><div class="sdot"></div><div class="sdot"></div><div class="sdot"></div></div>
159
+ <div id="bar">
160
+ <button id="mb" class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
161
+ <input id="ti" type="text" placeholder="Say something sweet..." autocomplete="off">
162
+ <button id="sb" class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
163
+ </div>
164
+ <div id="menu">
165
+ <button class="close" id="mc">&times;</button>
166
+ <h3>AI Provider</h3>
167
+ <div class="provider-tabs">
168
+ <div class="ptab active" data-prov="local">Local CPU</div>
169
+ </div>
170
+ <div class="provider-config active" id="prov-local">
171
+ <div class="row"><label>Model</label><input type="text" value="MiniCPM-1B" disabled></div>
172
+ <div class="hint">Uses pre-compiled C++ llama-server. Fully offline processing.</div>
173
+ </div>
174
+ <div class="divider"></div>
175
+ <h3>Voice</h3>
176
+ <div class="row"><label>TTS Voice</label>
177
+ <select id="ttsVoice">
178
+ <option value="zh-CN-XiaoyiNeural" selected>Xiaoyi (Chinese)</option>
179
+ <option value="en-US-AriaNeural">Aria (English US)</option>
180
+ <option value="ja-JP-NanamiNeural">Nanami (Japanese)</option>
181
+ </select>
182
+ </div>
183
+ <div class="divider"></div>
184
+ <div class="credits">
185
+ <p>Models are cached in browser storage after first load for instant boots.</p>
186
+ </div>
187
+ </div>
188
+
189
+ <script type="importmap">
190
+ {{"imports":{{
191
+ "three":"https://esm.sh/three@0.160.0",
192
+ "three/addons/":"https://esm.sh/three@0.160.0/examples/jsm/",
193
+ "@pixiv/three-vrm":"https://esm.sh/@pixiv/three-vrm@3.3.4?deps=three@0.160.0",
194
+ "@pixiv/three-vrm-animation":"https://esm.sh/@pixiv/three-vrm-animation@3.3.4?deps=three@0.160.0,@pixiv/three-vrm@3.3.4"
195
+ }}}}
196
+ </script>
197
+ <script type="module">
198
+ import * as THREE from 'three';
199
+ import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
200
+ import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
201
+ import {VRMLoaderPlugin,VRMUtils} from '@pixiv/three-vrm';
202
+ import {VRMAnimationLoaderPlugin} from '@pixiv/three-vrm-animation';
203
+
204
+ const isMobile = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
205
+ const VISEMES = ['aa','ih','ou','ee','oh','a','i','u','e','o'];
206
+ const EXPR_NAMES = ['happy','sad','angry','surprised','relaxed','neutral','blink','lookUp','lookDown','lookLeft','lookRight','aa','ih','ou','ee','oh'];
207
+
208
+ const scene = new THREE.Scene();
209
+ scene.background = new THREE.Color(0xfff5f5);
210
+ const camera = new THREE.PerspectiveCamera(40, innerWidth/innerHeight, 0.01, 100);
211
+ camera.position.set(0, 1.4, 2.0);
212
+
213
+ const renderer = new THREE.WebGLRenderer({antialias:!isMobile, alpha:true});
214
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
215
+ renderer.setSize(innerWidth, innerHeight);
216
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
217
+ document.getElementById('c').appendChild(renderer.domElement);
218
+
219
+ const orbit = new OrbitControls(camera, renderer.domElement);
220
+ orbit.target.set(0, 1.3, 0);
221
+ orbit.enableDamping = true;
222
+
223
+ window.addEventListener('resize', () => {
224
+ camera.aspect = window.innerWidth / window.innerHeight;
225
+ camera.updateProjectionMatrix();
226
+ renderer.setSize(window.innerWidth, window.innerHeight);
227
+ });
228
+
229
+ scene.add(new THREE.AmbientLight(0xfff4e8, 0.6));
230
+ const keyL = new THREE.DirectionalLight(0xfff2e0, 1.6);
231
+ keyL.position.set(3, 4, 3);
232
+ scene.add(keyL);
233
+
234
+ let vrm = null, mixer = null;
235
+ let exprCur = {}, exprTgt = {}, exprAlias = {};
236
+ let blinkT = 0, nextBlink = 2, blinkV = 0;
237
+
238
+ async function loadVrm() {
239
+ setLoading(true, 'Loading Character...');
240
+ const gltfLoader = new GLTFLoader();
241
+ gltfLoader.register(p => new VRMLoaderPlugin(p));
242
+ gltfLoader.register(p => new VRMAnimationLoaderPlugin(p));
243
+
244
+ // Using a public sample VRM for demonstration
245
+ const url = 'https://pixiv.github.io/three-vrm/packages/three-vrm/examples/models/VRM1_glb/vrm1_sample.glb';
246
+
247
+ try {
248
+ const gltf = await gltfLoader.loadAsync(url);
249
+ vrm = gltf.userData.vrm;
250
+ VRMUtils.rotateVRM0(vrm);
251
+ scene.add(vrm.scene);
252
+
253
+ const names = vrm.expressionManager?.expressions?.map(e => e.name) || [];
254
+ EXPR_NAMES.forEach(t => {
255
+ const f = names.find(n => n.toLowerCase()===t.toLowerCase());
256
+ if(f) exprAlias[t]=f;
257
+ });
258
+
259
+ setLoading(false);
260
+ } catch(e) {
261
+ showErr('VRM load failed');
262
+ }
263
+ }
264
+
265
+ function setExpressionValue(name, val){
266
+ try { vrm?.expressionManager?.setValue(exprAlias[name] || name, val); } catch(e){}
267
+ }
268
+
269
+ function updateBlink(dt){
270
+ if(!vrm) return;
271
+ blinkT += dt;
272
+ if(blinkT > nextBlink){
273
+ if(blinkV === 0) blinkV = 1;
274
+ if(blinkV === 1){
275
+ let v = 0; // Simplified blink
276
+ setExpressionValue('blink', 1);
277
+ blinkV = -1;
278
+ } else {
279
+ setExpressionValue('blink', 0);
280
+ blinkV = 0; blinkT = 0; nextBlink=2+Math.random()*4;
281
+ }
282
+ }
283
+ }
284
+
285
+ function updateExpressions(dt){
286
+ if(!vrm) return;
287
+ for(const name of EXPR_NAMES){
288
+ if(name === 'blink' || VISEMES.includes(name)) continue;
289
+ let cur = exprCur[name] || 0;
290
+ cur += ((exprTgt[name] || 0) - cur) * 6 * dt;
291
+ exprCur[name] = cur;
292
+ setExpressionValue(name, cur);
293
+ }
294
+ for(const v of VISEMES){
295
+ let cur = exprCur[v] || 0;
296
+ cur += ((exprTgt[v] || 0) - cur) * 15 * dt;
297
+ exprCur[v] = cur;
298
+ setExpressionValue(v, cur);
299
+ }
300
+ }
301
+
302
+ function setLoading(on, txt){
303
+ const l = document.getElementById('load');
304
+ l.textContent = txt || 'Loading...';
305
+ l.className = on ? 'on' : '';
306
+ }
307
+ function showErr(t){
308
+ const e = document.getElementById('err');
309
+ e.textContent = t; e.style.display = 'block';
310
+ setTimeout(()=> e.style.display='none', 5000);
311
+ }
312
+ function showInfo(t, d){
313
+ const i = document.getElementById('info');
314
+ i.textContent = t; i.style.display = 'block';
315
+ setTimeout(()=> i.style.display='none', d);
316
+ }
317
+
318
+ async function chat() {
319
+ const input = document.getElementById('ti');
320
+ const prompt = input.value.trim();
321
+ if(!prompt) return;
322
+ input.value = '';
323
+
324
+ document.getElementById('thinkDot').classList.add('on');
325
+ try {
326
+ const res = await fetch('/local_chat', {
327
+ method: 'POST',
328
+ headers: {'Content-Type': 'application/json'},
329
+ body: JSON.stringify({prompt})
330
+ });
331
+ const data = await res.json();
332
+ const text = data.result || data.error;
333
+
334
+ document.getElementById('thinkDot').classList.remove('on');
335
+ speak(text);
336
+ } catch(e) {
337
+ showErr('Chat failed');
338
+ document.getElementById('thinkDot').classList.remove('on');
339
+ }
340
+ }
341
+
342
+ async function speak(text) {
343
+ const voice = document.getElementById('ttsVoice').value;
344
+ document.getElementById('speakDot').classList.add('on');
345
+ try {
346
+ const res = await fetch(`/tts?text=${encodeURIComponent(text)}&voice=${voice}`);
347
+ const blob = await res.blob();
348
+ const audio = new Audio(URL.createObjectURL(blob));
349
+
350
+ // Simple viseme simulation
351
+ const interval = setInterval(() => {
352
+ const v = VISEMES[Math.floor(Math.random()*VISEMES.length)];
353
+ exprTgt[v] = 0.8;
354
+ setTimeout(() => exprTgt[v] = 0, 100);
355
+ }, 150);
356
+
357
+ audio.onended = () => {
358
+ clearInterval(interval);
359
+ document.getElementById('speakDot').classList.remove('on');
360
+ };
361
+ audio.play();
362
+ } catch(e) {
363
+ document.getElementById('speakDot').classList.remove('on');
364
+ }
365
+ }
366
+
367
+ document.getElementById('sb').onclick = chat;
368
+ document.getElementById('ti').onkeypress = (e) => e.key === 'Enter' && chat();
369
+ document.getElementById('mb').onclick = () => document.getElementById('menu').classList.toggle('open');
370
+ document.getElementById('mc').onclick = () => document.getElementById('menu').classList.remove('open');
371
+
372
+ let lastT = 0;
373
+ function loop(t) {
374
+ const dt = (t - lastT) / 1000;
375
+ lastT = t;
376
+ updateBlink(dt);
377
+ updateExpressions(dt);
378
+ orbit.update();
379
+ renderer.render(scene, camera);
380
+ requestAnimationFrame(loop);
381
+ }
382
+
383
+ loadVrm();
384
+ requestAnimationFrame(loop);
385
+ </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  </body>
387
+ </html>
388
+ """
389
 
390
  if __name__ == "__main__":
391
+ # Run on 0.0.0.0:7860 for Hugging Face Spaces
392
+ app.run(host="0.0.0.0", port=7860, debug=False)