The file /mnt/DATA/WORK/agent-gui/static/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)

.office-card, .chat-card, .panel {
  overflow: hidden;
}

.avatar-panel {
  padding: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}

.avatar-panel h2 { margin: 0; font-size: 0.9rem; }

#avatarCanvas {
  width: 100%;
  max-width: 280px;
  height: 300px;
  border-radius: 12px;
  background: #05080f;
  display: block;
}

#avatarStatus {
  font-size: 0.75rem;
  text-align: center;
}

:root {
  --bg: #070b12;
  --bg-2: #0d1422;
  --panel: #111a2b;
  --panel-2: #162338;
  --line: rgba(191, 219, 254, 0.10);
  --text: #e8f1ff;
  --muted: #8aa1c0;
  --cyan: #6ee7ff;
  --violet: #b38cff;
  --green: #5af0a6;
  --yellow: #ffd166;
  --red: #ff6b88;
  --shadow: rgba(0, 0, 0, 0.35);
}

* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  margin: 0;
  color: var(--text);
  background:
    radial-gradient(circle at 20% 0%, rgba(110,231,255,.20), transparent 25%),
    radial-gradient(circle at 80% 10%, rgba(179,140,255,.16), transparent 22%),
    linear-gradient(180deg, #05070d 0%, var(--bg) 100%);
  font-family: "Courier New", Courier, ui-monospace, SFMono-Regular, monospace;
  overflow: hidden;
}

.scanlines {
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(rgba(255,255,255,0.025) 50%, rgba(0,0,0,0.08) 50%);
  background-size: 100% 4px;
  mix-blend-mode: soft-light;
  opacity: .25;
}

.app-shell {
  position: relative;
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr) 330px;
  gap: 14px;
  padding: 14px;
  height: 100vh;
}

.sidebar, .center-column, .details-column {
  min-height: 0;
}

.sidebar, .panel, .office-card, .chat-card {
  background: linear-gradient(180deg, rgba(17,26,43,.94), rgba(9,14,25,.96));
  border: 1px solid var(--line);
  border-radius: 22px;
  box-shadow: 0 14px 40px var(--shadow), inset 0 1px 0 rgba(255,255,255,.04);
}

.sidebar, .details-column {
  overflow: auto;
  padding: 16px;
}

.center-column {
  display: grid;
  grid-template-rows: minmax(0, 0.9fr) minmax(0, 1.1fr);
  gap: 14px;
  min-width: 0;
}

.office-card, .chat-card, .panel {
  overflow: hidden;
}

.office-card {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
}

.card-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 14px 16px 12px;
  border-bottom: 1px solid var(--line);
}
.card-head.compact { padding-bottom: 10px; }

h1, h2, p { margin: 0; }
h1 { font-size: 22px; letter-spacing: .04em; }
h2 { font-size: 16px; letter-spacing: .03em; }
.eyebrow {
  color: var(--cyan);
  text-transform: uppercase;
  letter-spacing: .18em;
  font-size: 10px;
  margin-bottom: 6px;
}
.muted { color: var(--muted); font-size: 12px; line-height: 1.4; }

.brand { display: flex; gap: 12px; align-items: center; margin-bottom: 22px; }
.logo {
  width: 48px; height: 48px; display: grid; place-items: center;
  border-radius: 14px;
  background: linear-gradient(135deg, #0ff, #a855f7);
  color: #040810;
  font-weight: 900;
  box-shadow: 0 0 0 3px rgba(255,255,255,.06), 0 0 26px rgba(110,231,255,.20);
}
label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
select, textarea, button {
  width: 100%;
  border: 1px solid rgba(125, 154, 187, 0.18);
  border-radius: 14px;
  background: linear-gradient(180deg, #18263c, #101827);
  color: var(--text);
  padding: 12px 14px;
  outline: none;
}
select { margin-bottom: 10px; }
textarea { resize: vertical; min-height: 86px; }
button {
  cursor: pointer;
  background: linear-gradient(180deg, #2a7bc9, #5f39d8);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .08em;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.1);
}
button.secondary { background: linear-gradient(180deg, #1a2539, #111827); color: var(--cyan); }
button:hover { filter: brightness(1.08); }
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }

.session-tag, .pill {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 28px; padding: 0 10px; border-radius: 999px;
  background: #0c1320; border: 1px solid rgba(255,255,255,.08);
  font-size: 12px;
}
.pill.idle { color: var(--muted); }
.pill.thinking, .pill.tool { color: #03121a; background: linear-gradient(180deg, #68e8ff, #3bc7f6); }
.pill.speaking { color: #062016; background: linear-gradient(180deg, #94f7c5, #39d98a); }
.pill.error { color: #24010a; background: linear-gradient(180deg, #ff8aa0, #ff5377); }
.office-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }

#officeCanvas {
  width: 100%;
  height: 100%;
  display: block;
  background: #09101d;
  image-rendering: pixelated;
}
.office-caption {
  padding: 10px 16px 14px;
  color: var(--muted);
  border-top: 1px solid var(--line);
  font-size: 12px;
}

.chat-card {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  min-height: 0;
}
.chat-log {
  padding: 16px;
  overflow: auto;
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
}
.message {
  max-width: min(84%, 680px);
  border: 1px solid rgba(255,255,255,.09);
  border-radius: 16px;
  padding: 12px 14px;
  background: rgba(255,255,255,.03);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.03);
  white-space: pre-wrap;
  line-height: 1.45;
}
.message.user {
  align-self: flex-end;
  background: linear-gradient(180deg, rgba(54, 106, 180, .34), rgba(28, 43, 79, .38));
  border-color: rgba(110,231,255,.22);
}
.message.assistant {
  align-self: flex-start;
  background: linear-gradient(180deg, rgba(91, 68, 160, .28), rgba(20, 29, 52, .55));
  border-color: rgba(179,140,255,.20);
}
.message.error {
  align-self: flex-start;
  background: linear-gradient(180deg, rgba(255,107,136,.24), rgba(49, 14, 31, .48));
  border-color: rgba(255,107,136,.32);
}
.message .meta {
  font-size: 11px;
  color: var(--muted);
  margin-bottom: 6px;
  text-transform: uppercase;
  letter-spacing: .12em;
}
.composer {
  display: grid;
  grid-template-columns: 1fr 160px;
  gap: 12px;
  padding: 14px;
  border-top: 1px solid var(--line);
  background: rgba(10,16,27,.84);
}
.composer button { margin-top: 0; }

.panel {
  padding: 14px;
  margin-bottom: 14px;
}
.panel:last-child { margin-bottom: 0; }
.crew-list { display: grid; gap: 10px; margin-top: 10px; }
.crew-item {
  display: grid;
  grid-template-columns: 18px 1fr auto;
  gap: 10px;
  align-items: center;
  padding: 10px;
  border-radius: 14px;
  background: rgba(255,255,255,.03);
  border: 1px solid rgba(255,255,255,.06);
  cursor: pointer;
}
.crew-item.active { border-color: rgba(110,231,255,.35); box-shadow: 0 0 0 1px rgba(110,231,255,.14) inset; }
.crew-swatch {
  width: 18px; height: 18px; border-radius: 6px; border: 1px solid rgba(255,255,255,.2);
  box-shadow: 0 0 14px rgba(255,255,255,.06);
}
.crew-name { font-size: 13px; }
.crew-status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; }
.meta-box {
  margin-top: 10px;
  padding: 12px;
  border-radius: 14px;
  background: rgba(255,255,255,.03);
  border: 1px solid rgba(255,255,255,.06);
  font-size: 12px;
  color: var(--muted);
  line-height: 1.5;
  min-height: 80px;
  white-space: pre-line;
}
.timeline { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.timeline-item {
  padding: 10px 12px;
  border-radius: 12px;
  background: rgba(255,255,255,.03);
  border-left: 3px solid var(--cyan);
}
.timeline-item.error { border-left-color: var(--red); }
.timeline-item small {
  display: block;
  margin-bottom: 4px;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: .08em;
  font-size: 10px;
}

@media (max-width: 1220px) {
  body { overflow: auto; }
  .app-shell {
    height: auto;
    min-height: 100vh;
    grid-template-columns: 250px minmax(0, 1fr);
  }
  .details-column { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
  .panel { margin-bottom: 0; }
}

@media (max-width: 920px) {
  .app-shell { grid-template-columns: 1fr; }
  .details-column { grid-column: auto; grid-template-columns: 1fr; }
  .center-column { grid-template-rows: 460px minmax(420px, 1fr); }
}

@media (max-width: 680px) {
  .button-row, .composer { grid-template-columns: 1fr; }
  .office-meta { justify-content: flex-start; }
  .card-head { align-items: flex-start; flex-direction: column; }
  .center-column { grid-template-rows: 380px minmax(460px, 1fr); }
}

+.avatar-panel h2 { margin: 0; font-size: 0.9rem; }


cool ca fonctionne, implemente ça sur mon gui d'avatar


Installation et configuration de Blender


Maintenant j'ajoute le polling dans `app.js` — juste la partie qui se déclenche après un chat response.


Edit

async function sendMessage(event) {
  event.preventDefault();
  const input = $('messageInput');
  const message = input.value.trim();
  if (!message) return;
  const agent = $('agentSelect').value;
  const sessionId = await ensureSession();

  input.value = '';
  renderEvent({ type: 'user_message', content: message, agent });
  setStatus('thinking');
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, 'thinking', '', 0);

  try {
    const data = await api('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $('officeCaption').textContent = data.avatar?.state === 'speaking'
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || 'speaking');
    await loadSessions();
  } catch (error) {
    addMessage('error', error.message, 'erreur');
    setStatus('error');
    setOfficeCaption('Erreur lors de l'exécution de l'agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}

let _animPollTimer = null;

function pollAvatarAnim(sessionId) {
  if (_animPollTimer) clearInterval(_animPollTimer);
  _animPollTimer = setInterval(async () => {
    try {
      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
      if (data.status === 'pending') return;
      clearInterval(_animPollTimer);
      _animPollTimer = null;
      if (data.status === 'ready' && window.avatarController) {
        await window.avatarController.speakWithAnimation(sessionId);
        window.avatarController.startAnimation(data.bsData);
      }
      if (data.status === 'error') {
        console.warn('Avatar anim error:', data.message);
        if (window.avatarController) window.avatarController.setStatus('Erreur TTS/A2E');
      }
    } catch (_) {}
  }, 800);
}

async function sendMessage(event) {
  event.preventDefault();
  const input = $('messageInput');
  const message = input.value.trim();
  if (!message) return;
  const agent = $('agentSelect').value;
  const sessionId = await ensureSession();

  input.value = '';
  renderEvent({ type: 'user_message', content: message, agent });
  setStatus('thinking');
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, 'thinking', '', 0);

  try {
    const data = await api('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $('officeCaption').textContent = data.avatar?.state === 'speaking'
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || 'speaking');
    if (data.avatar_anim === 'pending') {
      if (window.avatarController) window.avatarController.setStatus('Génération avatar...');
      pollAvatarAnim(sessionId);
    }
    await loadSessions();
  } catch (error) {
    addMessage('error', error.message, 'erreur');
    setStatus('error');
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}


The file /mnt/DATA/WORK/agent-gui/static/app.js has been updated successfully. (file state is current in your context — no need to Read it back)

async function sendMessage(event) {
  event.preventDefault();
  const input = $('messageInput');
  const message = input.value.trim();
  if (!message) return;
  const agent = $('agentSelect').value;
  const sessionId = await ensureSession();

  input.value = '';
  renderEvent({ type: 'user_message', content: message, agent });
  setStatus('thinking');
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, 'thinking', '', 0);

  try {
    const data = await api('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $('officeCaption').textContent = data.avatar?.state === 'speaking'
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || 'speaking');
    await loadSessions();
  } catch (error) {
    addMessage('error', error.message, 'erreur');
    setStatus('error');
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}

let _animPollTimer = null;

function pollAvatarAnim(sessionId) {
  if (_animPollTimer) clearInterval(_animPollTimer);
  _animPollTimer = setInterval(async () => {
    try {
      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
      if (data.status === 'pending') return;
      clearInterval(_animPollTimer);
      _animPollTimer = null;
      if (data.status === 'ready' && window.avatarController) {
        await window.avatarController.speakWithAnimation(sessionId);
        window.avatarController.startAnimation(data.bsData);
      }
      if (data.status === 'error') {
        console.warn('Avatar anim error:', data.message);
        if (window.avatarController) window.avatarController.setStatus('Erreur TTS/A2E');
      }
    } catch (_) {}
  }, 800);
}

async function sendMessage(event) {
  event.preventDefault();
  const input = $('messageInput');
  const message = input.value.trim();
  if (!message) return;
  const agent = $('agentSelect').value;
  const sessionId = await ensureSession();

  input.value = '';
  renderEvent({ type: 'user_message', content: message, agent });
  setStatus('thinking');
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, 'thinking', '', 0);

  try {
    const data = await api('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $('officeCaption').textContent = data.avatar?.state === 'speaking'
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || 'speaking');
    if (data.avatar_anim === 'pending') {
      if (window.avatarController) window.avatarController.setStatus('Génération avatar...');
      pollAvatarAnim(sessionId);
    }
    await loadSessions();
  } catch (error) {
    addMessage('error', error.message, 'erreur');
    setStatus('error');
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}

+  if (_animPollTimer) clearInterval(_animPollTimer);

+  _animPollTimer = setInterval(async () => {

+      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);

+      if (data.status === ‘pending’) return;

+      if (data.status === ‘ready’ && window.avatarController) {

+        await window.avatarController.speakWithAnimation(sessionId);

+        window.avatarController.startAnimation(data.bsData);

+        console.warn(‘Avatar anim error:’, data.message);

+        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);

-  const agent = $('agentSelect').value;

+  const agent = $(‘agentSelect’).value;

-  renderEvent({ type: 'user_message', content: message, agent });

+  renderEvent({ type: ‘user_message’, content: message, agent });

-  setCrewMood(agent, 'thinking', '', 0);

+  setCrewMood(agent, ‘thinking’, ‘’, 0);

-    const data = await api('/api/chat', {

+    const data = await api(‘/api/chat’, {

-    $('officeCaption').textContent = data.avatar?.state === 'speaking'

+    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’

-    setStatus(data.avatar?.state || 'speaking');

+    setStatus(data.avatar?.state || ‘speaking’);

+    if (data.avatar_anim === ‘pending’) {

+      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);

-    addMessage('error', error.message, 'erreur');

-    setOfficeCaption('Erreur lors de l’exécution de l’agent.');

-    setCrewMood(agent, 'error', '!', 1900);

+    addMessage(‘error’, error.message, ‘erreur’);

+    setOfficeCaption(‘Erreur lors de l’exécution de l’agent.’);

+    setCrewMood(agent, ‘error’, ‘!’, 1900);


Enfin, j'initialise le controller avatar au démarrage de l'app.


async function init() {
  initEvents();
  resizeOfficeCanvas();
  await loadAgents();

async function init() {
  initEvents();
  resizeOfficeCanvas();
  // Wait a tick for the avatar module to register its controller
  await new Promise((r) => setTimeout(r, 100));
  if (window.avatarController) window.avatarController.init();
  await loadAgents();


async function init() {
  initEvents();
  resizeOfficeCanvas();
  await loadAgents();

async function init() {
  initEvents();
  resizeOfficeCanvas();
  // Wait a tick for the avatar module to register its controller
  await new Promise((r) => setTimeout(r, 100));
  if (window.avatarController) window.avatarController.init();
  await loadAgents();

+  // Wait a tick for the avatar module to register its controller

+  await new Promise((r) => setTimeout(r, 100));

+  if (window.avatarController) window.avatarController.init();

if (!state.selectedAgentId && state.crew[0]) {


Maintenant je vérifie que tout est bien en place et je teste le serveur.


Bash

cd /mnt/DATA/WORK/agent-gui && find . -newer agent_gui/__init__.py -name "*.py" -o -newer agent_gui/__init__.py -name "*.js" 2>/dev/null | grep -v __pycache__ | sort

List newly created/modified files


./agent_gui/adapters.py
./agent_gui/app.py
./agent_gui/avatar_lam.py
./agent_gui/avatar.py
./agent_gui/config.py
./agent_gui/sessions.py
./agent_gui/tts.py
./static/app.js
./static/avatar.js
./tests/test_adapters.py
./tests/test_avatar.py
./tests/test_config.py
./tests/test_sessions.py
Shell cwd was reset to /home/wildlama


grep -n "from .tts\|from .avatar_lam\|_anim_store\|_start_avatar" /mnt/DATA/WORK/agent-gui/agent_gui/app.py | head -20

Verify app.py imports and functions


24:_anim_store: dict[str, None | dict | str] = {}
28:def _start_avatar_pipeline(session_id: str, text: str) -> None:
30:        _anim_store[session_id] = None  # pending
34:            from .tts import synthesize
35:            from .avatar_lam import audio_to_blendshapes
41:                _anim_store[session_id] = bs_data
44:                _anim_store[session_id] = str(exc)
83:                val = _anim_store.get(session_id)
131:                _start_avatar_pipeline(session_id, assistant_text)


# Test quick syntax check on new Python files
$HOME/miniconda3/bin/python -c "
import ast, pathlib
for f in ['agent_gui/tts.py', 'agent_gui/avatar_lam.py', 'agent_gui/app.py']:
    src = (pathlib.Path('/mnt/DATA/WORK/agent-gui') / f).read_text()
    ast.parse(src)
    print(f'OK: {f}')
"

Syntax check Python files


OK: agent_gui/tts.py
OK: agent_gui/avatar_lam.py
OK: agent_gui/app.py


# Test TTS quickly
$HOME/miniconda3/bin/python -c "
import sys
sys.path.insert(0, '/mnt/DATA/WORK/agent-gui')
from pathlib import Path
import tempfile, os
wav = Path(tempfile.mktemp(suffix='.wav'))
from agent_gui.tts import synthesize
synthesize('Hello, I am Barbara, your AI assistant.', wav)
print(f'WAV: {wav}, size={wav.stat().st_size} bytes')
wav.unlink()
"

Test TTS synthesize function


WAV: /tmp/tmpvflb4wlj.wav, size=123726 bytes


TTS fonctionne. Maintenant testons le pipeline A2E complet sur ce WAV.


$HOME/miniconda3/bin/python -c "
import sys, tempfile
from pathlib import Path
sys.path.insert(0, '/mnt/DATA/WORK/agent-gui')
from agent_gui.tts import synthesize

# Generate a test WAV
wav = Path(tempfile.mktemp(suffix='.wav'))
synthesize('Hello, I am Barbara.', wav)
print(f'TTS OK: {wav.stat().st_size} bytes')

# Test A2E
from agent_gui.avatar_lam import audio_to_blendshapes
print('Running Audio2Expression...')
bs = audio_to_blendshapes(wav)
print(f'A2E OK: {len(bs[\"frames\"])} frames at {bs[\"metadata\"][\"fps\"]} fps')
wav.unlink()
" 2>&1 | tail -20

Test full TTS + A2E pipeline


/home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/transformers/utils/generic.py:441: FutureWarning: `torch.utils._pytree._register_pytree_node` is deprecated. Please use `torch.utils._pytree.register_pytree_node` instead.
  _torch_pytree._register_pytree_node(
/home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/transformers/utils/generic.py:309: FutureWarning: `torch.utils._pytree._register_pytree_node` is deprecated. Please use `torch.utils._pytree.register_pytree_node` instead.
  _torch_pytree._register_pytree_node(
[2026-06-15 23:46:47,872 INFO infer.py line 51 45277] => Loading config ...
[2026-06-15 23:46:47,872 INFO infer.py line 58 45277] => Building model ...
[2026-06-15 23:46:48,297 INFO infer.py line 66 45277] Num params: 97912596
[2026-06-15 23:46:48,474 INFO infer.py line 73 45277] Loading weight at: pretrained_models/lam_audio2exp_streaming.tar
[2026-06-15 23:46:48,634 INFO infer.py line 85 45277] => Loaded weight 'pretrained_models/lam_audio2exp_streaming.tar'
OK 73 frames
TTS OK: 77646 bytes
Running Audio2Expression...
A2E OK: 73 frames at 30.0 fps


Pipeline complet fonctionnel — 73 frames pour "Hello, I am Barbara." Maintenant je lance l'agent-gui pour vérifier tout fonctionne.


# Kill any existing agent-gui if running, then start fresh
pkill -f "agent_gui.app" 2>/dev/null || true
sleep 1
cd /mnt/DATA/WORK/agent-gui && $HOME/miniconda3/bin/python -m agent_gui.app --port 8765 &
sleep 3
curl -s http://127.0.0.1:8765/health | python3 -m json.tool 2>/dev/null || \
  $HOME/miniconda3/bin/python -c "import json,urllib.request; print(json.loads(urllib.request.urlopen('http://127.0.0.1:8765/health').read()))"

Start agent-gui and check health


Exit code 144


cd /mnt/DATA/WORK/agent-gui && $HOME/miniconda3/bin/python -m agent_gui.app --port 8765 2>&1 &
echo "PID $!"
sleep 4
$HOME/miniconda3/bin/python -c "
import urllib.request, json
try:
    r = urllib.request.urlopen('http://127.0.0.1:8765/health')
    print(json.loads(r.read()))
except Exception as e:
    print('NOT UP:', e)
"

Start agent-gui server


PID 45424
{'ok': True, 'service': 'agent-gui', 'time': '2026-06-15T21:47:05.097547+00:00'}


# Check that static files are served correctly
$HOME/miniconda3/bin/python -c "
import urllib.request
r = urllib.request.urlopen('http://127.0.0.1:8765/avatar/barbara/skin.glb')
print('GLB served:', r.headers.get('Content-Type'), 'size:', r.headers.get('Content-Length'))
"

Test that skin.glb is served


GLB served: model/gltf-binary size: 3612156


# Test the anim endpoint
$HOME/miniconda3/bin/python -c "
import urllib.request, json
r = urllib.request.urlopen('http://127.0.0.1:8765/api/avatar/anim?session_id=test123')
print(json.loads(r.read()))
"

Test avatar anim endpoint


{'status': 'pending'}


Il y a une race condition avec le chargement du module. Je corrige avatar.js pour s'auto-initialiser.


Edit

// Exported controller
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };

// Exported controller — auto-init since modules execute after DOM is parsed
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };
init();


The file /mnt/DATA/WORK/agent-gui/static/avatar.js has been updated successfully. (file state is current in your context — no need to Read it back)

// Exported controller — auto-init since modules execute after DOM is parsed
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };
init();

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const AVATAR_GLB = '/avatar/barbara/skin.glb';
const FPS = 30;

let renderer, scene, camera, avatarMesh, morphDict, morphNames;
let frames = [], frameIndex = 0, animTimer = null;
let audioCtx = null, audioBuffer = null, audioSource = null;

function init() {
  const canvas = document.getElementById('avatarCanvas');
  if (!canvas) return;

  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
  renderer.outputColorSpace = THREE.SRGBColorSpace;

  scene = new THREE.Scene();

  camera = new THREE.PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.01, 100);
  camera.position.set(0, 0, 3);

  const ambient = new THREE.AmbientLight(0xffffff, 1.2);
  scene.add(ambient);
  const dir = new THREE.DirectionalLight(0xffffff, 1.5);
  dir.position.set(1, 2, 3);
  scene.add(dir);

  loadAvatar();
  renderLoop();
}

function loadAvatar() {
  const loader = new GLTFLoader();
  loader.load(AVATAR_GLB, (gltf) => {
    const root = gltf.scene;
    scene.add(root);

    root.traverse((node) => {
      if (node.isMesh && node.morphTargetDictionary && Object.keys(node.morphTargetDictionary).length > 0) {
        avatarMesh = node;
        morphDict = node.morphTargetDictionary;
      }
    });

    fitCamera(root);
    setStatus('Prêt');
  }, undefined, (err) => {
    setStatus('Erreur chargement avatar');
    console.error(err);
  });
}

function fitCamera(object) {
  const box = new THREE.Box3().setFromObject(object);
  const center = box.getCenter(new THREE.Vector3());
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y);
  const fovRad = camera.fov * Math.PI / 180;
  const dist = (maxDim / 2) / Math.tan(fovRad / 2);

  // Focus on upper half (face/head)
  camera.position.set(center.x, center.y + size.y * 0.15, center.z + dist * 1.1);
  camera.lookAt(center.x, center.y + size.y * 0.15, center.z);
  camera.near = dist / 100;
  camera.far = dist * 100;
  camera.updateProjectionMatrix();
}

function applyFrame(frame) {
  if (!avatarMesh || !morphDict || !frame) return;
  const weights = frame.weights;
  for (let i = 0; i < morphNames.length; i++) {
    const name = morphNames[i];
    if (name in morphDict) {
      avatarMesh.morphTargetInfluences[morphDict[name]] = weights[i] ?? 0;
    }
  }
}

function resetMorphs() {
  if (!avatarMesh) return;
  avatarMesh.morphTargetInfluences.fill(0);
}

function renderLoop() {
  requestAnimationFrame(renderLoop);
  renderer.render(scene, camera);
}

function setStatus(text) {
  const el = document.getElementById('avatarStatus');
  if (el) el.textContent = text;
}

async function loadAudio(url) {
  if (!audioCtx) audioCtx = new AudioContext();
  const res = await fetch(url);
  const buf = await res.arrayBuffer();
  audioBuffer = await audioCtx.decodeAudioData(buf);
}

function playAudio() {
  if (!audioCtx || !audioBuffer) return;
  if (audioSource) { try { audioSource.stop(); } catch (_) {} }
  audioSource = audioCtx.createBufferSource();
  audioSource.buffer = audioBuffer;
  audioSource.connect(audioCtx.destination);
  audioSource.start(0);
}

function startAnimation(bsData) {
  if (animTimer) clearInterval(animTimer);
  morphNames = bsData.metadata.blendshape_names;
  frames = bsData.frames;
  frameIndex = 0;
  setStatus('Animation...');

  animTimer = setInterval(() => {
    if (frameIndex >= frames.length) {
      clearInterval(animTimer);
      animTimer = null;
      resetMorphs();
      setStatus('Prêt');
      return;
    }
    applyFrame(frames[frameIndex++]);
  }, 1000 / FPS);
}

async function speakWithAnimation(sessionId) {
  setStatus('Génération audio...');
  try {
    await loadAudio(`/api/avatar/audio?session_id=${encodeURIComponent(sessionId)}`);
    playAudio();
  } catch (e) {
    console.warn('Audio load failed:', e);
  }
}

// Exported controller
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };

+// Exported controller — auto-init since modules execute after DOM is parsed


async function init() {
  initEvents();
  resizeOfficeCanvas();
  // Wait a tick for the avatar module to register its controller
  await new Promise((r) => setTimeout(r, 100));
  if (window.avatarController) window.avatarController.init();
  await loadAgents();

async function init() {
  initEvents();
  resizeOfficeCanvas();
  await loadAgents();


async function init() {
  initEvents();
  resizeOfficeCanvas();
  // Wait a tick for the avatar module to register its controller
  await new Promise((r) => setTimeout(r, 100));
  if (window.avatarController) window.avatarController.init();
  await loadAgents();

async function init() {
  initEvents();
  resizeOfficeCanvas();
  await loadAgents();

-  // Wait a tick for the avatar module to register its controller

-  await new Promise((r) => setTimeout(r, 100));

-  if (window.avatarController) window.avatarController.init();


Bash

# Restart server with updated files
pkill -f "agent_gui.app" 2>/dev/null; sleep 1
cd /mnt/DATA/WORK/agent-gui && $HOME/miniconda3/bin/python -m agent_gui.app --port 8765 2>&1 &
sleep 3
$HOME/miniconda3/bin/python -c "
import urllib.request, json
r = urllib.request.urlopen('http://127.0.0.1:8765/')
print('GUI served, HTTP', r.status)
"

Restart server with all changes


pkill -f "agent_gui" 2>/dev/null; sleep 2
$HOME/miniconda3/bin/python -m agent_gui.app --port 8765 --config /mnt/DATA/WORK/agent-gui/configs/agents.json 2>&1 &
BGPID=$!
echo "Started PID $BGPID"
sleep 3
$HOME/miniconda3/bin/python -c "import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:8765/health'); print('OK:', r.status)"

Start agent-gui from home dir


Check if agent-gui is running


(Bash completed with no output)


cd /mnt/DATA/WORK/agent-gui && $HOME/miniconda3/bin/python -m agent_gui.app 2>&1 | head -20 &

Start agent-gui and capture output


sleep 2 && $HOME/miniconda3/bin/python -c "
import urllib.request
try:
    r = urllib.request.urlopen('http://127.0.0.1:8765/health')
    print('UP:', r.status)
except Exception as e:
    print('DOWN:', e)
"

Check if server came up


UP: 200


Le serveur est en ligne. Ouvre **http://127.0.0.1:8765** dans ton navigateur — tu devrais voir le visage de Barbara chargé dans le panel de droite. Envoie un message à l'agent, et après la réponse :

1. Le status avatar passera à **"Génération avatar..."** (TTS + A2E en background)
2. Dans ~10-15 secondes, Barbara sera animée en sync avec l'audio de la réponse

**Ce qui a été implémenté :**

- `tts.py` — edge-tts → WAV 16kHz mono via ffmpeg
- `avatar_lam.py` — subprocess `conda run -n lam_a2e` → Audio2Expression → bsData JSON
- `app.py` — thread background TTS+A2E après chaque réponse, endpoint `/api/avatar/anim` et `/api/avatar/audio`
- `avatar.js` — Three.js module : charge `skin.glb` (51 morph targets ARKit), anime les blendshapes à 30fps depuis le bsData, joue l'audio en sync
- Panel "Avatar LAM" dans la colonne droite

Dis-moi ce que tu vois !


We're integrating a LAM 3D talking avatar (Barbara) into the agent-gui so it animates with lip sync when the AI agent responds. The server is running at http://127.0.0.1:8765 — next step is to open it in the browser and verify Barbara's face loads and animates after sending a chat message. (disable recaps in /config)


je ne vois plus la liste des agents à gauche ( claude, hermes) etil n'y a pas d'avatar qui s'affichent


Deux problèmes distincts. Vérifions d'abord côté serveur si les agents se chargent, puis je regarde le JS.


$HOME/miniconda3/bin/python -c "
import urllib.request, json
r = urllib.request.urlopen('http://127.0.0.1:8765/api/agents')
d = json.loads(r.read())
print('agents:', [a.get('id') for a in d['agents']])
"

Check agents API returns data


agents: ['echo', 'hermes', 'claude-code']


# Check JS syntax of app.js for errors (the é char issue maybe)
$HOME/miniconda3/bin/python -c "
data = open('/mnt/DATA/WORK/agent-gui/static/app.js').read()
import re
# Look for the error line I may have broken
idx = data.find('Erreur lors')
print(repr(data[idx-20:idx+80]))
"

Check app.js for the line I edited


'  setOfficeCaption(‘Erreur lors de l’exécution de l’agent.’);\n    setCrewMood(agent, ‘error’, ‘!’, 1'


Trouvé. Mon edit a introduit des guillemets courbes (`'` `'`) au lieu de droits, ce qui casse tout le parsing de `app.js` → l'init ne tourne jamais → pas de liste d'agents. Je corrige.


grep -n "Erreur lors de l\|setOfficeCaption('L\|setOfficeCaption('Erreur" /mnt/DATA/WORK/agent-gui/static/app.js

Find all curly-quote lines in app.js


284:      setOfficeCaption('L’agent réfléchit...');
288:      setOfficeCaption('L’agent utilise un outil CLI.');
406:    setOfficeCaption(‘Erreur lors de l’exécution de l’agent.’);
861:  setOfficeCaption('Le bureau est prêt.');
868:  setOfficeCaption('Erreur de démarrage de l’interface.');


Ligne 406 a des guillemets courbes comme délimiteurs de string (`'...'`) — c'est l'erreur de syntaxe. Les autres lignes ont des apostrophes courbes *à l'intérieur* de strings droites, ce qui est valide. Je corrige la 406.


Edit

    setOfficeCaption(‘Erreur lors de l’exécution de l’agent.’);
    setCrewMood(agent, ‘error’, ‘!’, 1900);

    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);


    setOfficeCaption(‘Erreur lors de l’exécution de l’agent.’);
    setCrewMood(agent, ‘error’, ‘!’, 1900);

    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);

-    setOfficeCaption(‘Erreur lors de l’exécution de l’agent.’);

-    setCrewMood(agent, ‘error’, ‘!’, 1900);

+    setOfficeCaption('Erreur lors de l’exécution de l’agent.');

+    setCrewMood(agent, 'error', '!', 1900);


Bash

# Verify no more curly quotes used as delimiters (look for ‘ or ’ adjacent to ( , ; )
grep -nP "[\(\,]\s*[‘’]|[‘’]\s*[\),;]" /mnt/DATA/WORK/agent-gui/static/app.js || echo "No curly-quote delimiters remaining"

Verify no curly quote delimiters remain


359:      if (data.status === ‘pending’) return;
366:      if (data.status === ‘error’) {
367:        console.warn(‘Avatar anim error:’, data.message);
368:        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);
376:  const input = $(‘messageInput’);
379:  const agent = $(‘agentSelect’).value;
382:  input.value = ‘’;
383:  renderEvent({ type: ‘user_message’, content: message, agent });
384:  setStatus(‘thinking’);
386:  setCrewMood(agent, ‘thinking’, ‘’, 0);
389:    const data = await api(‘/api/chat’, {
390:      method: ‘POST’,
394:    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’
397:    setStatus(data.avatar?.state || ‘speaking’);
398:    if (data.avatar_anim === ‘pending’) {
399:      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);
404:    addMessage(‘error’, error.message, ‘erreur’);
405:    setStatus(‘error’);


Tout mon bloc collé (pollAvatarAnim + sendMessage) a des guillemets courbes. Je dois réécrire la section entière avec des guillemets droits. Je regarde l'étendue exacte.


352	let _animPollTimer = null;
353
354	function pollAvatarAnim(sessionId) {
355	  if (_animPollTimer) clearInterval(_animPollTimer);
356	  _animPollTimer = setInterval(async () => {
357	    try {
358	      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
359	      if (data.status === ‘pending’) return;
360	      clearInterval(_animPollTimer);
361	      _animPollTimer = null;
362	      if (data.status === ‘ready’ && window.avatarController) {
363	        await window.avatarController.speakWithAnimation(sessionId);
364	        window.avatarController.startAnimation(data.bsData);
365	      }
366	      if (data.status === ‘error’) {
367	        console.warn(‘Avatar anim error:’, data.message);
368	        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);
369	      }
370	    } catch (_) {}
371	  }, 800);
372	}
373
374	async function sendMessage(event) {
375	  event.preventDefault();
376	  const input = $(‘messageInput’);
377	  const message = input.value.trim();
378	  if (!message) return;
379	  const agent = $(‘agentSelect’).value;
380	  const sessionId = await ensureSession();
381
382	  input.value = ‘’;
383	  renderEvent({ type: ‘user_message’, content: message, agent });
384	  setStatus(‘thinking’);
385	  setOfficeCaption(`${agent} est en train de réfléchir...`);
386	  setCrewMood(agent, ‘thinking’, ‘’, 0);
387
388	  try {
389	    const data = await api(‘/api/chat’, {
390	      method: ‘POST’,
391	      body: JSON.stringify({ agent, message, session_id: sessionId }),
392	    });
393	    data.events.forEach(renderEvent);
394	    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’
395	      ? `${agent} est retourné à son bureau.`
396	      : state.sceneMessage;
397	    setStatus(data.avatar?.state || ‘speaking’);
398	    if (data.avatar_anim === ‘pending’) {
399	      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);
400	      pollAvatarAnim(sessionId);
401	    }
402	    await loadSessions();
403	  } catch (error) {
404	    addMessage(‘error’, error.message, ‘erreur’);
405	    setStatus(‘error’);
406	    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
407	    setCrewMood(agent, 'error', '!', 1900);
408	  }
409	}
410
411	async function newSession() {

let _animPollTimer = null;

function pollAvatarAnim(sessionId) {
  if (_animPollTimer) clearInterval(_animPollTimer);
  _animPollTimer = setInterval(async () => {
    try {
      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
      if (data.status === ‘pending’) return;
      clearInterval(_animPollTimer);
      _animPollTimer = null;
      if (data.status === ‘ready’ && window.avatarController) {
        await window.avatarController.speakWithAnimation(sessionId);
        window.avatarController.startAnimation(data.bsData);
      }
      if (data.status === ‘error’) {
        console.warn(‘Avatar anim error:’, data.message);
        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);
      }
    } catch (_) {}
  }, 800);
}

async function sendMessage(event) {
  event.preventDefault();
  const input = $(‘messageInput’);
  const message = input.value.trim();
  if (!message) return;
  const agent = $(‘agentSelect’).value;
  const sessionId = await ensureSession();

  input.value = ‘’;
  renderEvent({ type: ‘user_message’, content: message, agent });
  setStatus(‘thinking’);
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, ‘thinking’, ‘’, 0);

  try {
    const data = await api(‘/api/chat’, {
      method: ‘POST’,
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || ‘speaking’);
    if (data.avatar_anim === ‘pending’) {
      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);
      pollAvatarAnim(sessionId);
    }
    await loadSessions();
  } catch (error) {
    addMessage(‘error’, error.message, ‘erreur’);
    setStatus(‘error’);
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}

async function newSession() {


I'll rewrite the whole block with straight quotes.


Edit

let _animPollTimer = null;

function pollAvatarAnim(sessionId) {
  if (_animPollTimer) clearInterval(_animPollTimer);
  _animPollTimer = setInterval(async () => {
    try {
      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
      if (data.status === ‘pending’) return;
      clearInterval(_animPollTimer);
      _animPollTimer = null;
      if (data.status === ‘ready’ && window.avatarController) {
        await window.avatarController.speakWithAnimation(sessionId);
        window.avatarController.startAnimation(data.bsData);
      }
      if (data.status === ‘error’) {
        console.warn(‘Avatar anim error:’, data.message);
        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);
      }
    } catch (_) {}
  }, 800);
}

async function sendMessage(event) {
  event.preventDefault();
  const input = $(‘messageInput’);
  const message = input.value.trim();
  if (!message) return;
  const agent = $(‘agentSelect’).value;
  const sessionId = await ensureSession();

  input.value = ‘’;
  renderEvent({ type: ‘user_message’, content: message, agent });
  setStatus(‘thinking’);
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, ‘thinking’, ‘’, 0);

  try {
    const data = await api(‘/api/chat’, {
      method: ‘POST’,
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || ‘speaking’);
    if (data.avatar_anim === ‘pending’) {
      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);
      pollAvatarAnim(sessionId);
    }
    await loadSessions();
  } catch (error) {
    addMessage(‘error’, error.message, ‘erreur’);
    setStatus(‘error’);
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}

let _animPollTimer = null;

function pollAvatarAnim(sessionId) {
  if (_animPollTimer) clearInterval(_animPollTimer);
  _animPollTimer = setInterval(async () => {
    try {
      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
      if (data.status === 'pending') return;
      clearInterval(_animPollTimer);
      _animPollTimer = null;
      if (data.status === 'ready' && window.avatarController) {
        await window.avatarController.speakWithAnimation(sessionId);
        window.avatarController.startAnimation(data.bsData);
      }
      if (data.status === 'error') {
        console.warn('Avatar anim error:', data.message);
        if (window.avatarController) window.avatarController.setStatus('Erreur TTS/A2E');
      }
    } catch (_) {}
  }, 800);
}

async function sendMessage(event) {
  event.preventDefault();
  const input = $('messageInput');
  const message = input.value.trim();
  if (!message) return;
  const agent = $('agentSelect').value;
  const sessionId = await ensureSession();

  input.value = '';
  renderEvent({ type: 'user_message', content: message, agent });
  setStatus('thinking');
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, 'thinking', '', 0);

  try {
    const data = await api('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $('officeCaption').textContent = data.avatar?.state === 'speaking'
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || 'speaking');
    if (data.avatar_anim === 'pending') {
      if (window.avatarController) window.avatarController.setStatus('Génération avatar...');
      pollAvatarAnim(sessionId);
    }
    await loadSessions();
  } catch (error) {
    addMessage('error', error.message, 'erreur');
    setStatus('error');
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}


The file /mnt/DATA/WORK/agent-gui/static/app.js has been updated successfully. (file state is current in your context — no need to Read it back)

let _animPollTimer = null;

function pollAvatarAnim(sessionId) {
  if (_animPollTimer) clearInterval(_animPollTimer);
  _animPollTimer = setInterval(async () => {
    try {
      const data = await api(`/api/avatar/anim?session_id=${encodeURIComponent(sessionId)}`);
      if (data.status === ‘pending’) return;
      clearInterval(_animPollTimer);
      _animPollTimer = null;
      if (data.status === ‘ready’ && window.avatarController) {
        await window.avatarController.speakWithAnimation(sessionId);
        window.avatarController.startAnimation(data.bsData);
      }
      if (data.status === ‘error’) {
        console.warn(‘Avatar anim error:’, data.message);
        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);
      }
    } catch (_) {}
  }, 800);
}

async function sendMessage(event) {
  event.preventDefault();
  const input = $(‘messageInput’);
  const message = input.value.trim();
  if (!message) return;
  const agent = $(‘agentSelect’).value;
  const sessionId = await ensureSession();

  input.value = ‘’;
  renderEvent({ type: ‘user_message’, content: message, agent });
  setStatus(‘thinking’);
  setOfficeCaption(`${agent} est en train de réfléchir...`);
  setCrewMood(agent, ‘thinking’, ‘’, 0);

  try {
    const data = await api(‘/api/chat’, {
      method: ‘POST’,
      body: JSON.stringify({ agent, message, session_id: sessionId }),
    });
    data.events.forEach(renderEvent);
    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’
      ? `${agent} est retourné à son bureau.`
      : state.sceneMessage;
    setStatus(data.avatar?.state || ‘speaking’);
    if (data.avatar_anim === ‘pending’) {
      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);
      pollAvatarAnim(sessionId);
    }
    await loadSessions();
  } catch (error) {
    addMessage(‘error’, error.message, ‘erreur’);
    setStatus(‘error’);
    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
    setCrewMood(agent, 'error', '!', 1900);
  }
}

-      if (data.status === ‘pending’) return;

+      if (data.status === 'pending') return;

-      if (data.status === ‘ready’ && window.avatarController) {

+      if (data.status === 'ready' && window.avatarController) {

-        console.warn(‘Avatar anim error:’, data.message);

-        if (window.avatarController) window.avatarController.setStatus(‘Erreur TTS/A2E’);

+        console.warn('Avatar anim error:', data.message);

+        if (window.avatarController) window.avatarController.setStatus('Erreur TTS/A2E');

-  const agent = $(‘agentSelect’).value;

+  const agent = $('agentSelect').value;

-  renderEvent({ type: ‘user_message’, content: message, agent });

+  renderEvent({ type: 'user_message', content: message, agent });

-  setCrewMood(agent, ‘thinking’, ‘’, 0);

+  setCrewMood(agent, 'thinking', '', 0);

-    const data = await api(‘/api/chat’, {

+    const data = await api('/api/chat', {

-    $(‘officeCaption’).textContent = data.avatar?.state === ‘speaking’

+    $('officeCaption').textContent = data.avatar?.state === 'speaking'

-    setStatus(data.avatar?.state || ‘speaking’);

-    if (data.avatar_anim === ‘pending’) {

-      if (window.avatarController) window.avatarController.setStatus(‘Génération avatar...’);

+    setStatus(data.avatar?.state || 'speaking');

+    if (data.avatar_anim === 'pending') {

+      if (window.avatarController) window.avatarController.setStatus('Génération avatar...');

-    addMessage(‘error’, error.message, ‘erreur’);

+    addMessage('error', error.message, 'erreur');


Bash

# Verify no curly quotes used as string delimiters remain anywhere in app.js
grep -nP "[\(\,=]\s*[‘’]|[‘’]\s*[\),;]|=== ?[‘’]|\|\| ?[‘’]" /mnt/DATA/WORK/agent-gui/static/app.js && echo "STILL HAS PROBLEMS" || echo "Clean — only curly apostrophes inside strings remain"

Verify app.js has no curly quote delimiters


Clean — only curly apostrophes inside strings remain


# Validate JS parses correctly with node if available
which node && node --check /mnt/DATA/WORK/agent-gui/static/app.js 2>&1 && echo "app.js OK" || echo "node not available, skipping"
# Also check avatar.js for curly quotes
grep -nP "[\(\,=]\s*[‘’]|[‘’]\s*[\),;]|=== ?[‘’]" /mnt/DATA/WORK/agent-gui/static/avatar.js && echo "avatar.js HAS PROBLEMS" || echo "avatar.js clean"

Validate app.js and check avatar.js


/usr/bin/node
app.js OK
avatar.js clean


`app.js` parse maintenant correctement. La liste d'agents reviendra. Pour l'avatar qui ne s'affiche pas — c'est probablement lié à l'erreur JS qui bloquait tout, ou au chargement du GLB. Rafraîchis la page (Ctrl+Shift+R pour vider le cache) et regarde la console du navigateur.

Mais vérifions d'abord que le module avatar.js se charge bien — `node --check` ne valide pas les imports ES. Regardons s'il y a un souci avec `node:` ou la syntaxe module.


node --input-type=module --check < /mnt/DATA/WORK/agent-gui/static/avatar.js 2>&1 && echo "avatar.js module syntax OK" || echo "avatar.js has syntax error"

Validate avatar.js as ES module


avatar.js module syntax OK


Les deux fichiers parsent bien. Le souci d'affichage de l'avatar mérite investigation — ce GLB est peut-être destiné au gaussian splatting, pas à un rendu mesh standard. Vérifions sa structure (matériaux, textures).


$HOME/miniconda3/bin/python -c "
import struct, json
with open('/mnt/DATA/WORK/agent-gui/static/avatar/barbara/skin.glb', 'rb') as f:
    struct.unpack('<4sII', f.read(12))
    chunk_len, chunk_type = struct.unpack('<II', f.read(8))
    gltf = json.loads(f.read(chunk_len))

print('materials:', len(gltf.get('materials', [])))
for m in gltf.get('materials', [])[:3]:
    print('  ', m.get('name'), list(m.keys()))
print('textures:', len(gltf.get('textures', [])))
print('images:', len(gltf.get('images', [])))
prim = gltf['meshes'][0]['primitives'][0]
print('primitive attributes:', list(prim.get('attributes', {}).keys()))
print('primitive mode:', prim.get('mode', 4), '(4=TRIANGLES, 0=POINTS)')
print('has material:', 'material' in prim)
print('extensions used:', gltf.get('extensionsUsed', []))
"

Inspect GLB materials, textures, primitive mode


materials: 1
   lambert2 ['doubleSided', 'name', 'pbrMetallicRoughness']
textures: 0
images: 0
primitive attributes: ['POSITION', 'JOINTS_0', 'WEIGHTS_0']
primitive mode: 4 (4=TRIANGLES, 0=POINTS)
has material: True
extensions used: []


Voilà le vrai problème. Le `skin.glb` est juste une **géométrie nue** : positions + skinning + 51 morph targets, mais **aucune normale, aucune texture, aucune couleur**. C'est un simple proxy mesh. Le rendu photoréaliste de LAM vient du **Gaussian Splatting** (données dans `offset.ply` + `animation.glb`), pas du mesh.

Donc :
1. **Pas d'avatar visible** = le mesh sans normales rend en noir sur fond noir → invisible.
2. Charger juste `skin.glb` en Three.js ne donnera jamais le rendu photoréaliste.

D'après ta mémoire projet, le bon chemin c'est le package npm `gaussian-splat-renderer-for-lam` (repo `aigc3d/LAM_WebRender`). Vérifions sa disponibilité via CDN.


for url in \
  "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam/package.json" \
  "https://registry.npmjs.org/gaussian-splat-renderer-for-lam" \
  ; do
  echo "=== $url ==="
  curl -s --max-time 15 "$url" 2>&1 | head -c 600
  echo ""
done

Check npm package availability for LAM gaussian renderer


=== https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam/package.json ===
{
    "name": "gaussian-splat-renderer-for-lam",
    "version": "0.0.9-alpha.2",
    "description": "Three.js-based 3D Gaussian splat viewer for LAM",
    "module": "build/gaussian-splat-renderer-for-lam.module.js",
    "main": "build/gaussian-splat-renderer-for-lam.umd.cjs",
    "author": "Xiaodan Ye",
    "license": "MIT",
    "type": "module",
    "scripts": {
        "build-demo": "mkdir -p ./build/demo && cp -r ./demo ./build/",
        "build-library": "npx rollup -c && mkdir -p ./build/demo/lib && cp ./build/gaussian-splat-renderer-for-lam.module.* ./build/demo/lib/",
        "build": "
=== https://registry.npmjs.org/gaussian-splat-renderer-for-lam ===
{"_id":"gaussian-splat-renderer-for-lam","_rev":"11-afddcad67ec2068faf754641cf42d126","name":"gaussian-splat-renderer-for-lam","dist-tags":{"latest":"0.0.9-alpha.2"},"versions":{"0.0.1":{"name":"gaussian-splat-renderer-for-lam","version":"0.0.1","keywords":["three","threejs","three.js","splatting","3D","gaussian","webgl","javascript","lam"],"author":{"name":"Xiaodan Ye"},"license":"MIT","_id":"gaussian-splat-renderer-for-lam@0.0.1","maintainers":[{"name":"yexiaodan0222","email":"hey_yxd@qq.com"}],"dist":{"shasum":"98b9d4e3c9979cf1adb6c8ca46db096096d8eb3e","tarball":"https://registry.npmjs.org/


Le package existe et est dispo via CDN. Je récupère son API.


# Get the module entry and check exported API
curl -s --max-time 20 "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2/build/gaussian-splat-renderer-for-lam.module.js" -o /tmp/lam_renderer.js
wc -c /tmp/lam_renderer.js
echo "=== exports ==="
grep -oE "export\{[^}]*\}|export \{[^}]*\}" /tmp/lam_renderer.js | head -5
echo "=== class names / API ==="
grep -oE "GaussianSplatRenderer|getInstance|loadAvatar|updateBS|getExpressionData|startRender|class [A-Za-z]+" /tmp/lam_renderer.js | sort -u | head -20

Download and inspect LAM gaussian renderer module API


wc: /tmp/lam_renderer.js: No such file or directory
=== exports ===
ugrep: warning: /tmp/lam_renderer.js: No such file or directory
=== class names / API ===
ugrep: warning: /tmp/lam_renderer.js: No such file or directory


curl -sL --max-time 30 "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2/build/gaussian-splat-renderer-for-lam.module.js" -o /tmp/lam_renderer.js
ls -la /tmp/lam_renderer.js 2>&1
head -c 200 /tmp/lam_renderer.js 2>&1

Download LAM renderer module with redirect follow


Error: Exit code 1
ls: cannot access '/tmp/lam_renderer.js': No such file or directory
head: cannot open '/tmp/lam_renderer.js' for reading: No such file or directory


curl -sL --max-time 30 "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2/build/gaussian-splat-renderer-for-lam.module.js" 2>&1 | head -c 300; echo "---EXIT $?---"

Test fetching renderer module to stdout


/**
 * AbortablePromise: A quick & dirty wrapper for JavaScript's Promise class that allows the underlying
 * asynchronous operation to be cancelled. It is only meant for simple situations where no complex promise
 * chaining or merging occurs. It needs a significant amount of work to truly replicat---EXIT 0---


Le fetch marche vers stdout (souci d'écriture /tmp en sandbox). Je récupère le contenu et l'API via pipe, et je regarde la démo officielle pour l'usage.


curl -sL --max-time 30 "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2/build/gaussian-splat-renderer-for-lam.module.js" > /mnt/DATA/WORK/agent-gui/static/lib_lam_renderer.js 2>&1
wc -c /mnt/DATA/WORK/agent-gui/static/lib_lam_renderer.js
echo "=== exports ==="
grep -oaE "export\{[^}]*\}" /mnt/DATA/WORK/agent-gui/static/lib_lam_renderer.js | tail -3

Save renderer to project static dir and inspect exports


4067930 /mnt/DATA/WORK/agent-gui/static/lib_lam_renderer.js
=== exports ===


cd /mnt/DATA/WORK/agent-gui/static
# Find the export statement (could be export{ at end)
grep -oaE "export ?\{[A-Za-z0-9_, $]*\}" lib_lam_renderer.js | tail -5
echo "=== GaussianSplatRenderer usages ==="
grep -oa "GaussianSplatRenderer" lib_lam_renderer.js | sort -u
echo "=== key methods ==="
grep -oaE "getInstance|loadAvatar|updateBS|getExpressionData|startRenderEvent|setExpressionData|class GaussianSplatRenderer" lib_lam_renderer.js | sort | uniq -c

Find exports and key methods in renderer


export { AbortablePromise, DropInViewer, GaussianSplatRenderer, KSplatLoader, Utils as LoaderUtils, LogLevel, OrbitControls, PlayCanvasCompressedPlyParser, PlyLoader, PlyParser, RenderMode, SceneFormat, SceneRevealMode, SplatBuffer, SplatBufferGenerator, SplatLoader, SplatParser, SplatPartitioner, SplatRenderMode, Viewer, WebXRMode }
=== GaussianSplatRenderer usages ===
GaussianSplatRenderer
=== key methods ===
      1 class GaussianSplatRenderer
      4 getExpressionData
      5 getInstance
      3 updateBS
Shell cwd was reset to /home/wildlama


# Look at the demo to understand the proper init API
curl -sL --max-time 20 "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2/demo/index.html" 2>&1 | head -c 2000

Fetch demo index.html for usage


Couldn't find the requested file /demo/index.html in gaussian-splat-renderer-for-lam.


# List package files via jsdelivr API
curl -sL --max-time 20 "https://data.jsdelivr.com/v1/packages/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2" 2>&1 | $HOME/miniconda3/bin/python -c "
import sys, json
d = json.load(sys.stdin)
def walk(files, prefix=''):
    for f in files:
        if f.get('type')=='directory':
            walk(f['files'], prefix+f['name']+'/')
        else:
            print(prefix+f['name'])
walk(d['files'])
" 2>&1 | head -40

List package files via jsdelivr API


build/gaussian-splat-renderer-for-lam.module.js
build/gaussian-splat-renderer-for-lam.module.js.map
LICENSE
package.json
README.md


curl -sL --max-time 20 "https://cdn.jsdelivr.net/npm/gaussian-splat-renderer-for-lam@0.0.9-alpha.2/README.md" 2>&1

Read package README for usage API


# 3D Gaussian splatting for Three.js

3D Gaussian Splatting Renderer for [LAM: Large Avatar Model for One-shot Animatable Gaussian Head](https://github.com/aigc3d/LAM), developed based on [@mkkellogg/gaussian-splats-3d](https://github.com/mkkellogg/GaussianSplats3D).]


Le README est minimal. Je reverse-engineer l'API `getInstance` depuis le bundle.


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
# Find getInstance definition
import re
for m in re.finditer(r'getInstance', data):
    i = m.start()
    snippet = data[i:i+400]
    if 'static' in data[max(0,i-20):i] or '(' in data[i:i+30]:
        print('---', i, '---')
        print(snippet[:400])
        print()
" 2>&1 | head -60

Extract getInstance signature from bundle


--- 2123150 ---
getInstanceIndex();

		} else if ( scope === IndexNode.DRAW ) {

			propertyName = builder.getDrawIndex();

		} else if ( scope === IndexNode.INVOCATION_LOCAL ) {

			propertyName = builder.getInvocationLocalIndex();

		} else if ( scope === IndexNode.INVOCATION_SUBGROUP ) {

			propertyName = builder.getInvocationSubgroupIndex();

		} else if ( scope === IndexNode.SUBGROUP ) {

			propertyName =

--- 2845207 ---
getInstanceIndex() {

		console.warn( 'Abstract function.' );

	}

	/**
	 * Returns the drawIndex input variable as a native shader string.
	 * Only relevant for WebGL and its `WEBGL_multi_draw` extension.
	 *
	 * @abstract
	 * @return {String} The drawIndex shader string.
	 */
	getDrawIndex() {

		console.warn( 'Abstract function.' );

	}

	/**
	 * Returns the frontFacing input variable as a nati

--- 3083526 ---
getInstanceIndex() {

		return 'uint( gl_InstanceID )';

	}

	/**
	 * Returns the invocation local index builtin.
	 *
	 * @return {String} The invocation local index.
	 */
	getInvocationLocalIndex() {

		const workgroupSize = this.object.workgroupSize;

		const size = workgroupSize.reduce( ( acc, curr ) => acc * curr, 1 );
Shell cwd was reset to /home/wildlama


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
import re
# Find 'class GaussianSplatRenderer'
i = data.find('class GaussianSplatRenderer')
print('=== class def at', i, '===')
print(data[i:i+1500])
" 2>&1

Extract GaussianSplatRenderer class definition


=== class def at 4046784 ===
class GaussianSplatRenderer {
    static getInstance(container, assetPath, options) {
        var _a, _b, _c, _d, _e, _f;
        return __awaiter(this, void 0, void 0, function* () {
            if (this.instance != undefined) {
                return this.instance;
            }
            try {
                const characterPath = assetPath;
                const { pathname } = urlParse(characterPath);
                const matches = pathname.match(/\/([^/]+?)\.zip/);
                const characterName = matches && matches[1];
                if (!characterName) {
                    throw new Error('character model is not found');
                }
                NProgress.start();
                const characterZipResponse = yield axios.get(characterPath, {
                    responseType: 'arraybuffer',
                    timeout: 100000,
                    onDownloadProgress: (progressEvent) => {
                        if (progressEvent.lengthComputable && progressEvent && progressEvent.total) {
                            const percent = (progressEvent.loaded / progressEvent.total);
                            // console.log(`Downloaded ${percent * 100.0}%`);
                            NProgress.set(percent);
                            (options === null || options === void 0 ? void 0 : options.downloadProgress) && (options === null || options === void 0 ? void 0 : options.downloadProgress(percent));
                        }
                    }
Shell cwd was reset to /home/wildlama


`getInstance(container, assetPath, options)` attend une **URL vers un .zip** contenant l'avatar complet, et `options.getExpressionData` est un callback appelé à chaque frame pour fournir les blendshapes. Je récupère le reste pour comprendre le format exact.


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
i = data.find('class GaussianSplatRenderer')
print(data[i+1500:i+3500])
" 2>&1

Extract more of GaussianSplatRenderer class


     });
                (options === null || options === void 0 ? void 0 : options.loadProgress) && (options === null || options === void 0 ? void 0 : options.loadProgress(0.1));
                NProgress.done();
                console.log('download completed:', characterZipResponse.data);
                const arrayBuffer = characterZipResponse.data;
                const zipData = yield jszip_minExports.loadAsync(arrayBuffer);
                let fileName = '';
                Object.values(zipData.files).forEach(file => {
                    var _a, _b;
                    if (file.dir) {
                        fileName = (_a = file.name) === null || _a === void 0 ? void 0 : _a.slice(0, ((_b = file.name) === null || _b === void 0 ? void 0 : _b.length) - 1); // 去掉末尾的‘/’
                    }
                });
                if (!fileName) {
                    throw new Error('file fold is not found');
                }
                const renderer = new GaussianSplatRenderer(container, zipData);
                const cameraPos = new Vector3();
                cameraPos.x = ((_a = charactorConfig.camPos) === null || _a === void 0 ? void 0 : _a.x) || 0;
                cameraPos.y = ((_b = charactorConfig.camPos) === null || _b === void 0 ? void 0 : _b.y) || 0;
                cameraPos.z = ((_c = charactorConfig.camPos) === null || _c === void 0 ? void 0 : _c.z) || 1;
                const cameraRotation = new Vector3();
                cameraRotation.x = ((_d = charactorConfig.camRot) === null || _d === void 0 ? void 0 : _d.x) || 0;
                cameraRotation.y = ((_e = charactorConfig.camRot) === null || _e === void 0 ? void 0 : _e.y) || 0;
                cameraRotation.z = ((_f = charactorConfig.camRot) === null || _f === void 0 ? void 0 : _f.z) || 0;
                let backgroundColor = 0xffffff;
                if (charactorConfig.backgroundColor) {
                    backgroundColor = parseInt(charactorConfig.backgroundColor, 16);
Shell cwd was reset to /home/wildlama


Le zip doit contenir un **dossier** (ex: `barbara/`) avec les fichiers dedans. Il lit aussi un `charactorConfig`. Je vérifie d'où vient cette config et comment `getExpressionData` est consommé.


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
# Find charactorConfig source
import re
i = data.find('charactorConfig')
print('=== first charactorConfig at', i, '===')
print(data[max(0,i-600):i+200])
" 2>&1

Find charactorConfig source


=== first charactorConfig at 4046622 ===
cale: scale$1
};

var hello = {
	size: 2,
	isGroup: false
};
var idle = {
	size: 1,
	isGroup: false
};
var listen = {
	size: 0,
	isGroup: false
};
var speak = {
	size: 6,
	isGroup: false
};
var think = {
	size: 3,
	isGroup: true
};
var other = [
];
var animationConfig = {
	hello: hello,
	idle: idle,
	listen: listen,
	speak: speak,
	think: think,
	other: other
};

var name = "";
var position = {
	x: 0,
	y: 0,
	z: 0
};
var scale = {
	x: 1,
	y: 1,
	z: 1
};
var camPos = {
	x: 0,
	y: 1.8,
	z: 1
};
var camRot = {
	x: -10,
	y: 0,
	z: 0
};
var backgroundColor = "0xffffff";
var useFlame = "false";
var charactorConfig = {
	name: name,
	position: position,
	scale: scale,
	camPos: camPos,
	camRot: camRot,
	backgroundColor: backgroundColor,
	useFlame: useFlame
};

class GaussianSplatRenderer {
    stat
Shell cwd was reset to /home/wildlama


`charactorConfig` est un défaut intégré. Maintenant comment `getExpressionData` (depuis options) est branché et consommé par frame.


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
import re
for kw in ['getExpressionData', 'updateBS', 'options.getExpressionData', 'this.getExpressionData']:
    i = data.find(kw)
    while i >= 0 and i < len(data):
        seg = data[max(0,i-80):i+160]
        print(f'--- {kw} @ {i} ---')
        print(seg.replace(chr(10),' '))
        print()
        i = data.find(kw, i+1)
        if i > 4040000: break
" 2>&1 | head -50

Find getExpressionData and updateBS usage


--- getExpressionData @ 4050800 ---
|| options === void 0 ? void 0 : options.getChatState;                 renderer.getExpressionData = options === null || options === void 0 ? void 0 : options.getExpressionData;                 if (charactorConfig.useFlame) {

--- updateBS @ 3888369 ---
dle.update(state);                     break;             }         }     }     updateBS(actionData) {         let influence = {             browDownLeft: 0.0,             browDownRight: 0.0,             browInnerUp: 0.0,             browOu

--- options.getExpressionData @ 4050870 ---
 renderer.getExpressionData = options === null || options === void 0 ? void 0 : options.getExpressionData;                 if (charactorConfig.useFlame) {                     renderer.useFlame = (charactorConfig.useFlame == "false") ? false

--- this.getExpressionData @ 4057590 ---
d 0 ? void 0 : _a.update(this.chatState);                 }                 if (this.getExpressionData) {                     this.expressionData = this.updateBS(this.getExpressionData());                 }                 if (this.viewer.u
Shell cwd was reset to /home/wildlama


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
i = data.find('updateBS(actionData)')
print(data[i:i+900])
" 2>&1

Read updateBS to understand expected input format


updateBS(actionData) {
        let influence = {
            browDownLeft: 0.0,
            browDownRight: 0.0,
            browInnerUp: 0.0,
            browOuterUpLeft: 0.0,
            browOuterUpRight: 0.0,
            mouthCheekPuff: 0.0,
            cheekSquintLeft: 0.0,
            cheekSquintRight: 0.0,
            eyeBlinkLeft: 0.0,
            eyeBlinkRight: 0.0,
            eyeLookDownLeft: 0.0,
            eyeLookDownRight: 0.0,
            eyeLookInLeft: 0.0,
            eyeLookInRight: 0.0,
            eyeLookOutLeft: 0.0,
            eyeLookOutRight: 0.0,
            eyeLookUpLeft: 0.0,
            eyeLookUpRight: 0.0,
            eyeSquintLeft: 0.0,
            eyeSquintRight: 0.0,
            eyeWideLeft: 0.0,
            eyeWideRight: 0.0,
            jawForward: 0.0,
            jawLeft: 0.0,
            jawOpen: 0.0,
            jawRight: 0.0,
            mouthClose:
Shell cwd was reset to /home/wildlama


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
i = data.find('updateBS(actionData)')
# Skip past the influence dict default (find end of it after tongueOut)
j = data.find('tongueOut', i)
print(data[j:j+1100])
" 2>&1

Read updateBS body after influence defaults


tongueOut: 0.0
        };
        if (actionData != null) {
            influence = actionData;
        }
        return influence;
    }
}
AnimationManager.IsBlending = false;
AnimationManager.actions = [];
AnimationManager.NeedReset = false;
AnimationManager.NeedFullReset = false;
class State {
    constructor(_actions, _isGroup) {
        this.isPlaying = false;
        this.stage = 0;
        this.actions = [];
        this.blendingTime = 0.5;
        this.isGroup = false;
        this.actions = _actions;
        this.isGroup = _isGroup;
    }
    dispose() {
        this.actions = [];
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    update(state) {
    }
}
class Hello extends State {
    constructor(_actions, _isGroup) {
        super(_actions, _isGroup);
    }
    update(state) {
        if (AnimationManager.CurPlaying == undefined &&
            state == TYVoiceChatState.Idle &&
            this.isPlaying == false) {
            this.stage = 0;
            this.actions[this.stage].time = 0;
            AnimationManager.SetWeight(this.actions[this.sta
Shell cwd was reset to /home/wildlama


`getExpressionData()` doit retourner un objet `{blendshapeName: weight, ...}`. Voyons comment le composant Gradio (qui marche déjà) construit ce frame, pour copier exactement le mapping de noms.


$HOME/miniconda3/bin/python -c "
data = open('/home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js','rb').read().decode('utf-8','ignore')
i = data.find('getArkitFaceFrame')
while i >= 0:
    print('---', i, '---')
    print(data[i:i+700].replace(chr(10),' '))
    print()
    i = data.find('getArkitFaceFrame', i+1)
" 2>&1 | head -40

Find getArkitFaceFrame in gradio bundle


--- 2255840 ---
getArkitFaceFrame(e) {     const t = e.frames.length, n = 1 / 30, s = (performance.now() / 1e3 - this.startTime) % (t * n), a = Math.floor(s / n);     let o = {};     return e.names.forEach((l, c) => {       o[l] = e.frames[a].weights[c];     }), o;   }   render() {     var e;     if (this.viewer && this.viewer.selfDrivenMode) {       if (this.viewer.webXRMode)         this.viewer.renderer.setAnimationLoop(this.viewer.selfDrivenUpdateFunc);       else {         this.viewer.requestFrameId = requestAnimationFrame(() => this.render());         const t = 1 / 30, i = (performance.now() / 1e3 - this.startTime) % (this.viewer.totalFrames * t), s = Math.floor(i / t);         if (this.viewer.frame =

--- 2259934 ---
getArkitFaceFrame(o)), !this.viewer)         throw new Error("render viewer is not initialized");       let l, c;       i.traverse((u) => {         u.isSkinnedMesh && (l = u), u instanceof Pu && u.name == "hip" && (c = u);       }), this.viewer.sortedIndexes = a, l != null && (this.viewer.gaussianSplatCount = l.geometry.attributes.position.count), this.viewer.avatarMesh = i, this.viewer.skinModel = l, this.viewer.boneRoot = c, this.mixer = new rR(i), this.animManager = new Ye(this.mixer, s, t), this.motioncfg = n, l != null && this.viewer.updateMorphTarget(l), this.viewer.threeScene.add(i), i.visible = !1, l != null && l.skeleton.computeBoneTexture();     });   }   unpackFileAsBlob(e) {


Tout est clair maintenant. Format `getExpressionData()` :
```js
// bsData a `names` + `frames[i].weights`
o[names[c]] = frames[currentFrame].weights[c]  // → {name: weight}
```

Le renderer est auto-suffisant (three.js + axios + jszip bundlés, 4MB). Je n'ai plus besoin de mon propre Three.js. Étapes :
1. Créer le zip avatar avec un dossier `barbara/` dedans
2. Réécrire `avatar.js` pour utiliser `GaussianSplatRenderer.getInstance`

D'abord le zip.


cd /mnt/DATA/WORK/agent-gui/static/avatar
# Build zip with a folder named 'barbara' containing the 5 files
rm -f barbara.zip
SRC=/mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData
rm -rf /tmp/lam_barbara && mkdir -p /tmp/lam_barbara/barbara
cp "$SRC"/animation.glb "$SRC"/skin.glb "$SRC"/offset.ply "$SRC"/vertex_order.json "$SRC"/bsData.json /tmp/lam_barbara/barbara/
cd /tmp/lam_barbara && zip -r -q /mnt/DATA/WORK/agent-gui/static/avatar/barbara.zip barbara/
ls -lh /mnt/DATA/WORK/agent-gui/static/avatar/barbara.zip
unzip -l /mnt/DATA/WORK/agent-gui/static/avatar/barbara.zip

Build barbara avatar zip with folder structure


-rw-rw-r--+ 1 wildlama wildlama 4.1M Jun 16 08:04 /mnt/DATA/WORK/agent-gui/static/avatar/barbara.zip
Archive:  /mnt/DATA/WORK/agent-gui/static/avatar/barbara.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2026-06-16 08:04   barbara/
   395148  2026-06-16 08:04   barbara/bsData.json
   209090  2026-06-16 08:04   barbara/vertex_order.json
  1361640  2026-06-16 08:04   barbara/offset.ply
  3612156  2026-06-16 08:04   barbara/skin.glb
  2221524  2026-06-16 08:04   barbara/animation.glb
---------                     -------
  7799558                     6 files
Shell cwd was reset to /home/wildlama


Le zip est prêt. Vérifions les noms de fichiers exacts que le renderer cherche dans le zip.


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
import re
for kw in ['skin.glb','offset.ply','animation.glb','vertex_order','bsData','.ply','.glb']:
    cnt = data.count(kw)
    i = data.find(kw)
    seg = data[max(0,i-60):i+40].replace(chr(10),' ') if i>=0 else ''
    print(f'{kw}: {cnt}x  | {seg}')
" 2>&1

Find expected filenames in renderer bundle


skin.glb: 2x  | se.all([                 this.unpackAndLoadGlb(pathName + '/skin.glb'),                 this.unpackA
offset.ply: 1x  | ffsetFileUrl = yield renderer.unpackFileAsBlob(fileName + '/offset.ply');                 (options =
animation.glb: 1x  | n.glb'),                 this.unpackAndLoadGlb(pathName + '/animation.glb'),                 this.un
vertex_order: 2x  | json'),                 this.unpackAndLoadJson(pathName + '/vertex_order.json'),                 thi
bsData: 0x  |
.ply: 10x  |   class INRIAV1PlyParser {      constructor() {         this.plyParserutils = new PlyParserUtils();
.glb: 3x  | ll([                 this.unpackAndLoadGlb(pathName + '/skin.glb'),                 this.unpackAndLo
Shell cwd was reset to /home/wildlama


Le renderer charge `skin.glb`, `animation.glb`, `offset.ply`, `vertex_order.json` depuis le dossier — `bsData` vient du callback. Mon zip est bon. Je vérifie le constructeur (container = div ?) et les options dispo.


cd /mnt/DATA/WORK/agent-gui/static
$HOME/miniconda3/bin/python -c "
data = open('lib_lam_renderer.js').read()
# constructor of GaussianSplatRenderer
i = data.find('class GaussianSplatRenderer')
j = data.find('constructor(', i)
print('=== constructor ===')
print(data[j:j+600])
print()
# options usage block around getExpressionData assignment
k = data.find('renderer.getExpressionData = options')
print('=== options block ===')
print(data[k-700:k+500])
" 2>&1

Read constructor and options handling


=== constructor ===
constructor(_container, zipData) {
        this.zipUrls = {
            urls: new Map()
        };
        this.useFlame = false;
        this.lastTime = 0;
        this.startTime = 0;
        this.expressionData = {};
        this.chatState = TYVoiceChatState.Idle;
        Object.assign(this.zipUrls, {
            zip: zipData
        });
        const { width, height } = _container.getBoundingClientRect();
        GaussianSplatRenderer._canvas.style.visibility = 'visible';
        GaussianSplatRenderer._canvas.width = width;
        GaussianSplatRenderer._canvas.height = height;
        _con

=== options block ===
         let backgroundColor = 0xffffff;
                if (charactorConfig.backgroundColor) {
                    backgroundColor = parseInt(charactorConfig.backgroundColor, 16);
                }
                if (options && options.backgroundColor && renderer.isHexColorStrict(options.backgroundColor)) {
                    backgroundColor = parseInt(options.backgroundColor, 16);
                }
                let alpha = 0;
                if (options && options.alpha) {
                    alpha = Math.max(0, Math.min(1, options.alpha));
                }
                renderer.getChatState = options === null || options === void 0 ? void 0 : options.getChatState;
                renderer.getExpressionData = options === null || options === void 0 ? void 0 : options.getExpressionData;
                if (charactorConfig.useFlame) {
                    renderer.useFlame = (charactorConfig.useFlame == "false") ? false : true;
                }
                console.log(cameraPos, backgroundColor, alpha);
                renderer.viewer = new Viewer({
                    rootElement: container,
                    threejsCanvas: GaussianSplatRenderer._canvas,
Shell cwd was reset to /home/wildlama


Parfait. Le renderer gère son propre canvas dans un **div container**, et supporte `backgroundColor` + `alpha`. Je réécris `avatar.js` complètement pour utiliser le vrai renderer Gaussian Splat.


Write

import { GaussianSplatRenderer } from '/lib_lam_renderer.js';

const AVATAR_ZIP = '/avatar/barbara.zip';
const FPS = 30;

let rendererInstance = null;
let activeBsData = null;
let playStartTime = 0;
let audioCtx = null, audioBuffer = null, audioSource = null;

function setStatus(text) {
  const el = document.getElementById('avatarStatus');
  if (el) el.textContent = text;
}

// Called by the renderer every frame. Returns {blendshapeName: weight} or null (resting pose).
function getExpressionData() {
  if (!activeBsData) return null;
  const total = activeBsData.frames.length;
  const elapsed = performance.now() / 1000 - playStartTime;
  const frameIdx = Math.floor(elapsed * FPS);
  if (frameIdx >= total) {
    activeBsData = null;
    setStatus('Prêt');
    return null;
  }
  const weights = activeBsData.frames[frameIdx].weights;
  const out = {};
  activeBsData.names.forEach((name, i) => {
    out[name] = weights[i];
  });
  return out;
}

async function init() {
  const container = document.getElementById('avatarRoot');
  if (!container) return;
  try {
    setStatus('Chargement avatar...');
    rendererInstance = await GaussianSplatRenderer.getInstance(container, AVATAR_ZIP, {
      getExpressionData,
      backgroundColor: '0x05080f',
      loadProgress: (p) => setStatus(`Chargement ${Math.round(p * 100)}%`),
      downloadProgress: (p) => setStatus(`Téléchargement ${Math.round(p * 100)}%`),
    });
    setStatus('Prêt');
  } catch (err) {
    console.error('Avatar init failed:', err);
    setStatus('Erreur chargement avatar');
  }
}

async function loadAudio(url) {
  if (!audioCtx) audioCtx = new AudioContext();
  const res = await fetch(url);
  const buf = await res.arrayBuffer();
  audioBuffer = await audioCtx.decodeAudioData(buf);
}

function playAudio() {
  if (!audioCtx || !audioBuffer) return;
  if (audioSource) { try { audioSource.stop(); } catch (_) {} }
  audioSource = audioCtx.createBufferSource();
  audioSource.buffer = audioBuffer;
  audioSource.connect(audioCtx.destination);
  audioSource.start(0);
}

// Start blendshape playback (synced with audio start)
function startAnimation(bsData) {
  activeBsData = bsData;
  playStartTime = performance.now() / 1000;
  setStatus('Parle...');
}

async function speakWithAnimation(sessionId) {
  setStatus('Chargement audio...');
  try {
    await loadAudio(`/api/avatar/audio?session_id=${encodeURIComponent(sessionId)}`);
    playAudio();
  } catch (e) {
    console.warn('Audio load failed:', e);
  }
}

window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };
init();


The file /mnt/DATA/WORK/agent-gui/static/avatar.js has been updated successfully. (file state is current in your context — no need to Read it back)

import { GaussianSplatRenderer } from '/lib_lam_renderer.js';

const AVATAR_ZIP = '/avatar/barbara.zip';
const FPS = 30;

let rendererInstance = null;
let activeBsData = null;
let playStartTime = 0;
let audioCtx = null, audioBuffer = null, audioSource = null;

function setStatus(text) {
  const el = document.getElementById('avatarStatus');
  if (el) el.textContent = text;
}

// Called by the renderer every frame. Returns {blendshapeName: weight} or null (resting pose).
function getExpressionData() {
  if (!activeBsData) return null;
  const total = activeBsData.frames.length;
  const elapsed = performance.now() / 1000 - playStartTime;
  const frameIdx = Math.floor(elapsed * FPS);
  if (frameIdx >= total) {
    activeBsData = null;
    setStatus('Prêt');
    return null;
  }
  const weights = activeBsData.frames[frameIdx].weights;
  const out = {};
  activeBsData.names.forEach((name, i) => {
    out[name] = weights[i];
  });
  return out;
}

async function init() {
  const container = document.getElementById('avatarRoot');
  if (!container) return;
  try {
    setStatus('Chargement avatar...');
    rendererInstance = await GaussianSplatRenderer.getInstance(container, AVATAR_ZIP, {
      getExpressionData,
      backgroundColor: '0x05080f',
      loadProgress: (p) => setStatus(`Chargement ${Math.round(p * 100)}%`),
      downloadProgress: (p) => setStatus(`Téléchargement ${Math.round(p * 100)}%`),
    });
    setStatus('Prêt');
  } catch (err) {
    console.error('Avatar init failed:', err);
    setStatus('Erreur chargement avatar');
  }
}

async function loadAudio(url) {
  if (!audioCtx) audioCtx = new AudioContext();
  const res = await fetch(url);
  const buf = await res.arrayBuffer();
  audioBuffer = await audioCtx.decodeAudioData(buf);
}

function playAudio() {
  if (!audioCtx || !audioBuffer) return;
  if (audioSource) { try { audioSource.stop(); } catch (_) {} }
  audioSource = audioCtx.createBufferSource();
  audioSource.buffer = audioBuffer;
  audioSource.connect(audioCtx.destination);
  audioSource.start(0);
}

// Start blendshape playback (synced with audio start)
function startAnimation(bsData) {
  activeBsData = bsData;
  playStartTime = performance.now() / 1000;
  setStatus('Parle...');
}

async function speakWithAnimation(sessionId) {
  setStatus('Chargement audio...');
  try {
    await loadAudio(`/api/avatar/audio?session_id=${encodeURIComponent(sessionId)}`);
    playAudio();
  } catch (e) {
    console.warn('Audio load failed:', e);
  }
}

window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };
init();

-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

+import { GaussianSplatRenderer } from '/lib_lam_renderer.js';

-const AVATAR_GLB = '/avatar/barbara/skin.glb';

+const AVATAR_ZIP = '/avatar/barbara.zip';

-let renderer, scene, camera, avatarMesh, morphDict, morphNames;

-let frames = [], frameIndex = 0, animTimer = null;

-  const canvas = document.getElementById('avatarCanvas');

-  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });

-  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);

-  renderer.outputColorSpace = THREE.SRGBColorSpace;

-  camera = new THREE.PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.01, 100);

-  const ambient = new THREE.AmbientLight(0xffffff, 1.2);

-  const dir = new THREE.DirectionalLight(0xffffff, 1.5);

+  const el = document.getElementById('avatarStatus');

-      if (node.isMesh && node.morphTargetDictionary && Object.keys(node.morphTargetDictionary).length > 0) {

-        morphDict = node.morphTargetDictionary;

+// Called by the renderer every frame. Returns {blendshapeName: weight} or null (resting pose).

+  const total = activeBsData.frames.length;

+  const elapsed = performance.now() / 1000 - playStartTime;

+  const frameIdx = Math.floor(elapsed * FPS);

-    setStatus('Erreur chargement avatar');

+  const weights = activeBsData.frames[frameIdx].weights;

+  activeBsData.names.forEach((name, i) => {

-  const box = new THREE.Box3().setFromObject(object);

-  const center = box.getCenter(new THREE.Vector3());

-  const size = box.getSize(new THREE.Vector3());

-  const maxDim = Math.max(size.x, size.y);

-  const fovRad = camera.fov * Math.PI / 180;

-  const dist = (maxDim / 2) / Math.tan(fovRad / 2);

-  camera.position.set(center.x, center.y + size.y * 0.15, center.z + dist * 1.1);

-  camera.lookAt(center.x, center.y + size.y * 0.15, center.z);

-  if (!avatarMesh || !morphDict || !frame) return;

-  for (let i = 0; i < morphNames.length; i++) {

-      avatarMesh.morphTargetInfluences[morphDict[name]] = weights[i] ?? 0;

+  const container = document.getElementById('avatarRoot');

+    rendererInstance = await GaussianSplatRenderer.getInstance(container, AVATAR_ZIP, {

+      loadProgress: (p) => setStatus(`Chargement ${Math.round(p * 100)}%`),

+      downloadProgress: (p) => setStatus(`Téléchargement ${Math.round(p * 100)}%`),

+    console.error('Avatar init failed:', err);

+    setStatus('Erreur chargement avatar');

-  const el = document.getElementById('avatarStatus');

+// Start blendshape playback (synced with audio start)

-  if (animTimer) clearInterval(animTimer);

-  morphNames = bsData.metadata.blendshape_names;

+  playStartTime = performance.now() / 1000;

-// Exported controller — auto-init since modules execute after DOM is parsed

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const AVATAR_GLB = '/avatar/barbara/skin.glb';
const FPS = 30;

let renderer, scene, camera, avatarMesh, morphDict, morphNames;
let frames = [], frameIndex = 0, animTimer = null;
let audioCtx = null, audioBuffer = null, audioSource = null;

function init() {
  const canvas = document.getElementById('avatarCanvas');
  if (!canvas) return;

  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
  renderer.outputColorSpace = THREE.SRGBColorSpace;

  scene = new THREE.Scene();

  camera = new THREE.PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.01, 100);
  camera.position.set(0, 0, 3);

  const ambient = new THREE.AmbientLight(0xffffff, 1.2);
  scene.add(ambient);
  const dir = new THREE.DirectionalLight(0xffffff, 1.5);
  dir.position.set(1, 2, 3);
  scene.add(dir);

  loadAvatar();
  renderLoop();
}

function loadAvatar() {
  const loader = new GLTFLoader();
  loader.load(AVATAR_GLB, (gltf) => {
    const root = gltf.scene;
    scene.add(root);

    root.traverse((node) => {
      if (node.isMesh && node.morphTargetDictionary && Object.keys(node.morphTargetDictionary).length > 0) {
        avatarMesh = node;
        morphDict = node.morphTargetDictionary;
      }
    });

    fitCamera(root);
    setStatus('Prêt');
  }, undefined, (err) => {
    setStatus('Erreur chargement avatar');
    console.error(err);
  });
}

function fitCamera(object) {
  const box = new THREE.Box3().setFromObject(object);
  const center = box.getCenter(new THREE.Vector3());
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y);
  const fovRad = camera.fov * Math.PI / 180;
  const dist = (maxDim / 2) / Math.tan(fovRad / 2);

  // Focus on upper half (face/head)
  camera.position.set(center.x, center.y + size.y * 0.15, center.z + dist * 1.1);
  camera.lookAt(center.x, center.y + size.y * 0.15, center.z);
  camera.near = dist / 100;
  camera.far = dist * 100;
  camera.updateProjectionMatrix();
}

function applyFrame(frame) {
  if (!avatarMesh || !morphDict || !frame) return;
  const weights = frame.weights;
  for (let i = 0; i < morphNames.length; i++) {
    const name = morphNames[i];
    if (name in morphDict) {
      avatarMesh.morphTargetInfluences[morphDict[name]] = weights[i] ?? 0;
    }
  }
}

function resetMorphs() {
  if (!avatarMesh) return;
  avatarMesh.morphTargetInfluences.fill(0);
}

function renderLoop() {
  requestAnimationFrame(renderLoop);
  renderer.render(scene, camera);
}

function setStatus(text) {
  const el = document.getElementById('avatarStatus');
  if (el) el.textContent = text;
}

async function loadAudio(url) {
  if (!audioCtx) audioCtx = new AudioContext();
  const res = await fetch(url);
  const buf = await res.arrayBuffer();
  audioBuffer = await audioCtx.decodeAudioData(buf);
}

function playAudio() {
  if (!audioCtx || !audioBuffer) return;
  if (audioSource) { try { audioSource.stop(); } catch (_) {} }
  audioSource = audioCtx.createBufferSource();
  audioSource.buffer = audioBuffer;
  audioSource.connect(audioCtx.destination);
  audioSource.start(0);
}

function startAnimation(bsData) {
  if (animTimer) clearInterval(animTimer);
  morphNames = bsData.metadata.blendshape_names;
  frames = bsData.frames;
  frameIndex = 0;
  setStatus('Animation...');

  animTimer = setInterval(() => {
    if (frameIndex >= frames.length) {
      clearInterval(animTimer);
      animTimer = null;
      resetMorphs();
      setStatus('Prêt');
      return;
    }
    applyFrame(frames[frameIndex++]);
  }, 1000 / FPS);
}

async function speakWithAnimation(sessionId) {
  setStatus('Génération audio...');
  try {
    await loadAudio(`/api/avatar/audio?session_id=${encodeURIComponent(sessionId)}`);
    playAudio();
  } catch (e) {
    console.warn('Audio load failed:', e);
  }
}

// Exported controller — auto-init since modules execute after DOM is parsed
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };
init();


Maintenant je mets à jour `index.html` : remplacer le canvas par un div container et retirer l'importmap three.js (désormais inutile, le renderer bundle three).


Edit

  <link rel="stylesheet" href="/styles.css" />
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
    }
  }
  </script>
</head>

  <link rel="stylesheet" href="/styles.css" />
</head>


The file /mnt/DATA/WORK/agent-gui/static/index.html has been updated successfully. (file state is current in your context — no need to Read it back)

  <link rel="stylesheet" href="/styles.css" />
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
    }
  }
  </script>
</head>

  <link rel="stylesheet" href="/styles.css" />
</head>

<!doctype html>

  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />

AG

Pixel command center

Agent GUI

Surcouche locale universelle

      Agent actif
      <select id="agentSelect"></select>

        <button id="newSessionBtn">Nouvelle session</button>
        <button id="reloadBtn" class="secondary">Reload crew</button>

Sessions

Virtual office

Bureau animé

            idle
            Session

        <canvas id="officeCanvas" width="960" height="540"></canvas>

Les agents arrivent au bureau.

Conversation

Chat

Ctrl+Enter pour envoyer

        <form id="chatForm" class="composer">
          <textarea id="messageInput" rows="3" placeholder="Écris à l'agent..."></textarea>
          <button type="submit">Envoyer</button>
        </form>

Avatar LAM

Barbara

        <canvas id="avatarCanvas" width="280" height="300"></canvas>

Chargement...

Crew

Agents présents

Selected

Agent

idle

Timeline

Activity

-      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",

-      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"


Avatar LAM

Barbara

        <canvas id="avatarCanvas" width="280" height="300"></canvas>

Chargement...


Avatar LAM

Barbara

        <canvas id="avatarCanvas" width="280" height="300"></canvas>

Chargement...

-        <canvas id="avatarCanvas" width="280" height="300"></canvas>


#avatarCanvas {
  width: 100%;
  max-width: 280px;
  height: 300px;
  border-radius: 12px;
  background: #05080f;
  display: block;
}

#avatarRoot {
  width: 100%;
  max-width: 280px;
  height: 300px;
  border-radius: 12px;
  background: #05080f;
  overflow: hidden;
  position: relative;
}

#avatarRoot canvas {
  width: 100% !important;
  height: 100% !important;
  display: block;
}