on reprend ? tu installes blender ? ou j'ai la version 5 dans mes telchargements


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Bash, Read, WebFetch, WebSearch)

- Explore: Fast read-only search agent for locating code. Use it to find files by pattern (eg. "src/components/**/*.tsx"), grep for symbols or keywords (eg. "API endpoints"), or answer "where is X defined / which files reference Y." Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: "quick" for a single targeted lookup, "medium" for moderate exploration, or "very thorough" to search across multiple locations and naming conventions. (Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


- deep-research: Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. - When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by running the app and observing behavior. Use when asked to verify a PR, confirm a fix works, test a change manually, check that a feature works, or validate local changes before pushing.
- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.
- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.
TRIGGER — read BEFORE opening the target file; don't skip because it "looks like a one-liner" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).
SKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch

deep-research update-config keybindings-help verify code-review simplify fewer-permission-prompts loop schedule claude-api run init review security-review


Installation et configuration de Blender


Je vais vérifier le contexte du projet en cours.


1	---
2	name: project-agent-gui-lam
3	description: "GUI pour agents IA avec avatars LAM 3D parlants en temps réel — état d'avancement complet"
4	metadata:
5	  node_type: memory
6	  type: project
7	  originSessionId: 7de9b929-9050-430b-9b36-f74849c1c0a7
8	---
9
10	## Projet principal
11
12	`/mnt/DATA/WORK/agent-gui` — web app locale (Python stdlib + vanilla JS) pour piloter des agents LLM avec interface graphique. V1 fonctionnelle avec avatar ASCII, canvas pixel-art animé, chat, sessions JSONL, adaptateurs CLI.
13
14	**Why:** Créer une surcouche graphique universelle pour agents IA (Hermes, Claude Code, etc.) avec avatars visuels modélisables.
15
16	**How to apply:** Quand on reprend le travail, partir de cet état et continuer vers l'intégration LAM.
17
18	---
19
20	## Intégration LAM — état au 2026-06-15
21
22	### Objectif
23	Ajouter des avatars 3D photoréalistes parlants en temps réel dans agent-gui. Pipeline :
24	```
25	[Agent répond] → [TTS → audio 16kHz mono]
26	    → [Python: Audio2Expression → 52 blendshapes ARKit @ 30fps]
27	        → [WebSocket JSON] → [Browser: WebGL Gaussian Splatting renderer]
28	```
29
30	### Ce qui est installé et fonctionnel ✅
31
32	**Conda envs sur cette machine :**
33	- `lam` (Python 3.11) — pour LAM core (génération avatar depuis photo)
34	  - PyTorch 2.11+cu128, pytorch3d 0.7.9, diff-gaussian-rasterization, nvdiffrast, simple-knn
35	  - gcc 13 installé dans l'env (nécessaire pour compiler pytorch3d avec CUDA 12.8)
36	- `lam_a2e` (Python 3.10) — pour Audio2Expression (live talking)
37	  - PyTorch 2.11+cu128, transformers, librosa, gradio-gaussian-render
38
39	**Repos clonés :**
40	- `/mnt/DATA/WORK/LAM` — modèle LAM core (génère avatar 3D depuis photo)
41	- `/mnt/DATA/WORK/LAM_Audio2Expression` — Audio2Expression (audio → blendshapes)
42	  - Poids téléchargés et extraits : `pretrained_models/lam_audio2exp_flow/`
43	  - **Testé et fonctionnel** : 52 canaux ARKit, 30 FPS, 4ms/seconde d'audio après warm-up
44
45	**Résultat test Audio2Expression :**
46	- Input : `assets/sample_audio/BarackObama_english.wav`
47	- Output : `bsData.json` — 240 frames à 30fps, 52 blendshapes ARKit par frame
48	- Format : `{ "names": ["browDownLeft", "jawOpen", ...], "frames": [[...], ...], "metadata": {"fps": 30} }`
49
50	**Hardware :**
51	- GPU : RTX 5090, 32 Go VRAM
52	- Drivers NVIDIA : 595.71, CUDA 13.2 (max supporté)
53	- CUDA Toolkit 12.8 installé via conda dans les envs
54
55	---
56
57	### Ce qui reste à faire
58
59	**Étape 1 — Exporter l'avatar 3D (bloquant : nécessite Blender 4.0+)**
60	- Vérifier si Blender est installé : `blender --version`
61	- Si oui : lancer `app_lam.py` dans l'env `lam`, charger une photo portrait, exporter le ZIP avatar
62	- Le ZIP contient : `animation.glb`, `skin.glb`, `offset.ply`, `bsData.json`, `vertex_order.json`
63	- Des avatars sample sont déjà dispo dans `LAM_Audio2Expression/assets/sample_lam/` (barbara, james, status, vfhq_case1) — peut être utilisé pour tester sans Blender
64
65	**Étape 2 — Ajouter TTS dans agent-gui**
66	- Options légères : `pyttsx3` (offline, stdlib-like), `edge-tts` (Microsoft gratuit), `kokoro` (qualité)
67	- Output : fichier WAV 16kHz mono → passé à Audio2Expression
68
69	**Étape 3 — Ajouter WebSocket dans agent_gui/app.py**
70	- Ajouter endpoint WebSocket `/ws/avatar`
71	- Au moment où l'agent répond : TTS → Audio2Expression → stream blendshapes JSON à 30fps vers browser
72
73	**Étape 4 — Remplacer canvas pixel-art par renderer WebGL**
74	- Package npm : `gaussian-splat-renderer-for-lam` (repo : `aigc3d/LAM_WebRender`)
75	- API : charge le ZIP avatar une fois, polling `getExpressionData()` à chaque frame rAF
76	- Ou utiliser les avatars sample GLB directement si le format est compatible
77
78	**Alternative rapide sans Blender :**
79	Utiliser les avatars sample fournis (`barbara`, `james`) pour tester le pipeline complet avant d'avoir son propre avatar.
80
81	---
82
83	### Architecture finale visée
84
85	```
86	agent_gui/
87	  app.py          ← ajouter WebSocket + appel subprocess Audio2Expression
88	  avatar_lam.py   ← nouveau module : lance lam_a2e env, streame blendshapes
89	  tts.py          ← nouveau module : TTS → WAV 16kHz
90	static/
91	  app.js          ← remplacer canvas pixel-art par WebGL renderer LAM
92	  avatar.zip      ← avatar exporté depuis photo (ou sample barbara/james)
93	```
94
95	### Commandes utiles pour reprendre
96
97	```bash
98	# Tester Audio2Expression
99	cd /mnt/DATA/WORK/LAM_Audio2Expression
100	$HOME/miniconda3/bin/conda run -n lam_a2e python inference_streaming_audio.py
101
102	# Lancer app Gradio LAM (pour exporter avatar depuis photo, nécessite Blender)
103	cd /mnt/DATA/WORK/LAM
104	$HOME/miniconda3/bin/conda run -n lam python app_lam.py
105
106	# Lancer app Gradio Audio2Expression (demo complète avec WebGL)
107	cd /mnt/DATA/WORK/LAM_Audio2Expression
108	$HOME/miniconda3/bin/conda run -n lam_a2e python app_lam_audio2exp.py
109	```
110

---
name: project-agent-gui-lam
description: "GUI pour agents IA avec avatars LAM 3D parlants en temps réel — état d'avancement complet"
metadata:
  node_type: memory
  type: project
  originSessionId: 7de9b929-9050-430b-9b36-f74849c1c0a7
---

## Projet principal

`/mnt/DATA/WORK/agent-gui` — web app locale (Python stdlib + vanilla JS) pour piloter des agents LLM avec interface graphique. V1 fonctionnelle avec avatar ASCII, canvas pixel-art animé, chat, sessions JSONL, adaptateurs CLI.

**Why:** Créer une surcouche graphique universelle pour agents IA (Hermes, Claude Code, etc.) avec avatars visuels modélisables.

**How to apply:** Quand on reprend le travail, partir de cet état et continuer vers l'intégration LAM.

---

## Intégration LAM — état au 2026-06-15

### Objectif
Ajouter des avatars 3D photoréalistes parlants en temps réel dans agent-gui. Pipeline :
```
[Agent répond] → [TTS → audio 16kHz mono]
    → [Python: Audio2Expression → 52 blendshapes ARKit @ 30fps]
        → [WebSocket JSON] → [Browser: WebGL Gaussian Splatting renderer]
```

### Ce qui est installé et fonctionnel ✅

**Conda envs sur cette machine :**
- `lam` (Python 3.11) — pour LAM core (génération avatar depuis photo)
  - PyTorch 2.11+cu128, pytorch3d 0.7.9, diff-gaussian-rasterization, nvdiffrast, simple-knn
  - gcc 13 installé dans l'env (nécessaire pour compiler pytorch3d avec CUDA 12.8)
- `lam_a2e` (Python 3.10) — pour Audio2Expression (live talking)
  - PyTorch 2.11+cu128, transformers, librosa, gradio-gaussian-render

**Repos clonés :**
- `/mnt/DATA/WORK/LAM` — modèle LAM core (génère avatar 3D depuis photo)
- `/mnt/DATA/WORK/LAM_Audio2Expression` — Audio2Expression (audio → blendshapes)
  - Poids téléchargés et extraits : `pretrained_models/lam_audio2exp_flow/`
  - **Testé et fonctionnel** : 52 canaux ARKit, 30 FPS, 4ms/seconde d'audio après warm-up

**Résultat test Audio2Expression :**
- Input : `assets/sample_audio/BarackObama_english.wav`
- Output : `bsData.json` — 240 frames à 30fps, 52 blendshapes ARKit par frame
- Format : `{ "names": ["browDownLeft", "jawOpen", ...], "frames": [[...], ...], "metadata": {"fps": 30} }`

**Hardware :**
- GPU : RTX 5090, 32 Go VRAM
- Drivers NVIDIA : 595.71, CUDA 13.2 (max supporté)
- CUDA Toolkit 12.8 installé via conda dans les envs

---

### Ce qui reste à faire

**Étape 1 — Exporter l'avatar 3D (bloquant : nécessite Blender 4.0+)**
- Vérifier si Blender est installé : `blender --version`
- Si oui : lancer `app_lam.py` dans l'env `lam`, charger une photo portrait, exporter le ZIP avatar
- Le ZIP contient : `animation.glb`, `skin.glb`, `offset.ply`, `bsData.json`, `vertex_order.json`
- Des avatars sample sont déjà dispo dans `LAM_Audio2Expression/assets/sample_lam/` (barbara, james, status, vfhq_case1) — peut être utilisé pour tester sans Blender

**Étape 2 — Ajouter TTS dans agent-gui**
- Options légères : `pyttsx3` (offline, stdlib-like), `edge-tts` (Microsoft gratuit), `kokoro` (qualité)
- Output : fichier WAV 16kHz mono → passé à Audio2Expression

**Étape 3 — Ajouter WebSocket dans agent_gui/app.py**
- Ajouter endpoint WebSocket `/ws/avatar`
- Au moment où l'agent répond : TTS → Audio2Expression → stream blendshapes JSON à 30fps vers browser

**Étape 4 — Remplacer canvas pixel-art par renderer WebGL**
- Package npm : `gaussian-splat-renderer-for-lam` (repo : `aigc3d/LAM_WebRender`)
- API : charge le ZIP avatar une fois, polling `getExpressionData()` à chaque frame rAF
- Ou utiliser les avatars sample GLB directement si le format est compatible

**Alternative rapide sans Blender :**
Utiliser les avatars sample fournis (`barbara`, `james`) pour tester le pipeline complet avant d'avoir son propre avatar.

---

### Architecture finale visée

```
agent_gui/
  app.py          ← ajouter WebSocket + appel subprocess Audio2Expression
  avatar_lam.py   ← nouveau module : lance lam_a2e env, streame blendshapes
  tts.py          ← nouveau module : TTS → WAV 16kHz
static/
  app.js          ← remplacer canvas pixel-art par WebGL renderer LAM
  avatar.zip      ← avatar exporté depuis photo (ou sample barbara/james)
```

### Commandes utiles pour reprendre

```bash
# Tester Audio2Expression
cd /mnt/DATA/WORK/LAM_Audio2Expression
$HOME/miniconda3/bin/conda run -n lam_a2e python inference_streaming_audio.py

# Lancer app Gradio LAM (pour exporter avatar depuis photo, nécessite Blender)
cd /mnt/DATA/WORK/LAM
$HOME/miniconda3/bin/conda run -n lam python app_lam.py

# Lancer app Gradio Audio2Expression (demo complète avec WebGL)
cd /mnt/DATA/WORK/LAM_Audio2Expression
$HOME/miniconda3/bin/conda run -n lam_a2e python app_lam_audio2exp.py
```


Parfait, contexte retrouvé. On en était à l'Étape 1 — exporter l'avatar 3D, qui nécessite Blender 4.0+. Vérifions ce qui est dispo.


Bash

blender --version 2>/dev/null || echo "Blender non trouvé dans le PATH"

Check if Blender is installed


Blender non trouvé dans le PATH


ls ~/Téléchargements/ 2>/dev/null || ls ~/Downloads/ 2>/dev/null || ls ~/téléchargements/ 2>/dev/null || echo "Dossier téléchargements non trouvé"

List downloads folder


blender-5.1.2-linux-x64
blender-5.1.2-linux-x64 (2)
blender-5.1.2-linux-x64.tar.xz


Blender 5.1.2 est déjà dans tes téléchargements et le dossier est extrait. On peut l'utiliser directement — pas besoin de réinstaller.


ls ~/Téléchargements/blender-5.1.2-linux-x64/ 2>/dev/null | head -20

Check Blender extracted folder contents


(Bash completed with no output)


ls ~/Downloads/blender-5.1.2-linux-x64/ 2>/dev/null | head -20

Check Blender extracted folder in Downloads


5.1
blender
blender-launcher
blender-softwaregl
blender-symbolic.svg
blender-system-info.sh
blender-thumbnailer
blender.desktop
blender.svg
copyright.txt
lib
license
readme.html


~/Downloads/blender-5.1.2-linux-x64/blender --version 2>&1 | head -3

Test Blender version from Downloads


Blender 5.1.2
	build date: 2026-05-19
	build time: 02:18:37


Blender 5.1.2 est opérationnel. Voyons ce que `app_lam.py` attend comme chemin Blender.


grep -n -i "blender" /mnt/DATA/WORK/LAM/app_lam.py | head -20

Check how app_lam.py references Blender


131:    parser.add_argument("--blender_path", type=str,
132:                        default='blender' ,help="Path to Blender executable")
138:    cfg.blender_path = args.blender_path
300:            cmd = "thirdparties/blender/blender --background --python 'tools/generateGLBWithBlender_v2.py'"
306:                from tools.generateARKITGLBWithBlender import generate_glb
318:                    blender_exec=Path(cfg.blender_path)
458:                                              visible=os.path.exists(cfg.blender_path))
466:                    visible=os.path.exists(cfg.blender_path)


Il attend `--blender_path` ou cherche `blender` dans le PATH. Il y a aussi un `thirdparties/blender/blender` en dur à la ligne 300. Deux options :

1. **Ajouter Blender au PATH** (le plus simple)
2. **Passer `--blender_path`** au lancement

Je recommande d'ajouter un lien symbolique ou de passer le path directement. On lance `app_lam.py` avec :

```bash
cd /mnt/DATA/WORK/LAM
$HOME/miniconda3/bin/conda run -n lam python app_lam.py \
  --blender_path ~/Downloads/blender-5.1.2-linux-x64/blender
```

Tu veux qu'on lance directement, ou tu préfères d'abord tester avec les avatars sample (`barbara`, `james`) qui sont déjà dispo dans `LAM_Audio2Expression/assets/sample_lam/` — sans avoir besoin de Blender pour cette étape ?


tente avec barabara


Je vérifie d'abord ce qui est dispo dans le dossier sample.


ls /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/ 2>/dev/null || ls /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/ 2>/dev/null

List barbara sample avatar contents


arkitWithBSData


List barbara arkitWithBSData folder


animation.glb
bsData.json
offset.ply
skin.glb
vertex_order.json


Parfait — tous les fichiers sont là (`animation.glb`, `skin.glb`, `offset.ply`, `bsData.json`, `vertex_order.json`). On lance la démo complète Audio2Expression avec barbara.


cd /mnt/DATA/WORK/LAM_Audio2Expression && $HOME/miniconda3/bin/conda run -n lam_a2e python app_lam_audio2exp.py 2>&1 &
echo "PID: $!"

Launch LAM Audio2Expression Gradio app in background


PID: 43574


sleep 8 && curl -s http://127.0.0.1:7860 > /dev/null 2>&1 && echo "Gradio UP sur port 7860" || echo "Pas encore prêt"

Wait and check if Gradio is up


Gradio UP sur port 7860


jobs -l 2>/dev/null; ps aux | grep app_lam_audio2exp | grep -v grep

Confirm process is still running


wildlama   43574  0.0  0.0  18956  2240 ?        S    23:34   0:00 /bin/bash -c source /home/wildlama/.claude/shell-snapshots/snapshot-bash-1781559205895-kprmaw.sh 2>/dev/null || true && shopt -u extglob 2>/dev/null || true && eval 'cd /mnt/DATA/WORK/LAM_Audio2Expression && $HOME/miniconda3/bin/conda run -n lam_a2e python app_lam_audio2exp.py 2>&1 & echo "PID: $!"' < /dev/null && pwd -P >| /tmp/claude-88d3-cwd
wildlama   43575  1.4  0.1 150732 80176 ?        S    23:34   0:00 /home/wildlama/miniconda3/bin/python /home/wildlama/miniconda3/bin/conda run -n lam_a2e python app_lam_audio2exp.py
wildlama   43586 68.3  1.9 25916576 1279684 ?    Sl   23:34   0:08 python app_lam_audio2exp.py


Gradio est up. Ouvre ton navigateur sur **http://127.0.0.1:7860** — tu devrais voir l'interface Audio2Expression avec le renderer WebGL LAM.

Pour tester barbara :
1. Dans l'interface, charge l'avatar `assets/sample_lam/barbara/arkitWithBSData/`
2. Upload l'audio sample (`assets/sample_audio/BarackObama_english.wav` ou n'importe quel WAV)
3. Lance — tu verras barbara parler en temps réel via les blendshapes ARKit

Dis-moi ce que tu vois à l'écran.


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


Je regarde d'abord l'état actuel du gui avant de planifier l'intégration.


find /mnt/DATA/WORK/agent-gui -type f | sort

List all files in agent-gui


/mnt/DATA/WORK/agent-gui/agent_gui/adapters.py
/mnt/DATA/WORK/agent-gui/agent_gui/app.py
/mnt/DATA/WORK/agent-gui/agent_gui/avatar.py
/mnt/DATA/WORK/agent-gui/agent_gui/config.py
/mnt/DATA/WORK/agent-gui/agent_gui/__init__.py
/mnt/DATA/WORK/agent-gui/agent_gui/__pycache__/adapters.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/agent_gui/__pycache__/app.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/agent_gui/__pycache__/avatar.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/agent_gui/__pycache__/config.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/agent_gui/__pycache__/__init__.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/agent_gui/__pycache__/sessions.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/agent_gui/sessions.py
/mnt/DATA/WORK/agent-gui/configs/agents.json
/mnt/DATA/WORK/agent-gui/data/sessions/6dd3753b2acf.jsonl
/mnt/DATA/WORK/agent-gui/data/sessions/7772cd958587.jsonl
/mnt/DATA/WORK/agent-gui/data/sessions/8ed5cc84d704.jsonl
/mnt/DATA/WORK/agent-gui/data/sessions/c2ba25240ba9.jsonl
/mnt/DATA/WORK/agent-gui/data/sessions/claude-test-1.jsonl
/mnt/DATA/WORK/agent-gui/data/sessions/e5218deff440.jsonl
/mnt/DATA/WORK/agent-gui/PLAN.md
/mnt/DATA/WORK/agent-gui/README.md
/mnt/DATA/WORK/agent-gui/static/app.js
/mnt/DATA/WORK/agent-gui/static/index.html
/mnt/DATA/WORK/agent-gui/static/styles.css
/mnt/DATA/WORK/agent-gui/tests/__pycache__/test_adapters.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/tests/__pycache__/test_avatar.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/tests/__pycache__/test_config.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/tests/__pycache__/test_sessions.cpython-314.pyc
/mnt/DATA/WORK/agent-gui/tests/test_adapters.py
/mnt/DATA/WORK/agent-gui/tests/test_avatar.py
/mnt/DATA/WORK/agent-gui/tests/test_config.py
/mnt/DATA/WORK/agent-gui/tests/test_sessions.py


ls /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/ && echo "---" && ls /mnt/DATA/WORK/LAM_Audio2Expression/

Check barbara assets and LAM repo structure


animation.glb
bsData.json
offset.ply
skin.glb
vertex_order.json
---
LICENSE
README.md
app_lam_audio2exp.py
assets
bsData.json
configs
engines
exp
inference.py
inference_streaming_audio.py
models
pretrained_models
requirements.txt
scripts
utils
wheels


1	from __future__ import annotations
2
3	import argparse
4	import json
5	import mimetypes
6	from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7	from pathlib import Path
8	from urllib.parse import parse_qs, urlparse
9
10	from .adapters import adapter_for
11	from .avatar import available_states, get_avatar
12	from .config import find_agent, load_agents
13	from .sessions import SessionStore, new_session_id, utc_now
14
15	PROJECT_ROOT = Path(__file__).resolve().parent.parent
16	STATIC_ROOT = PROJECT_ROOT / "static"
17	DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
18	DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"
19
20
21	class AgentGuiServer(ThreadingHTTPServer):
22	    def __init__(self, server_address, RequestHandlerClass, config_path: Path, sessions_root: Path):
23	        super().__init__(server_address, RequestHandlerClass)
24	        self.config_path = config_path
25	        self.sessions = SessionStore(sessions_root)
26
27	    @property
28	    def agents(self):
29	        return load_agents(self.config_path)
30
31
32	class Handler(BaseHTTPRequestHandler):
33	    server: AgentGuiServer
34
35	    def log_message(self, format: str, *args):
36	        print("[agent-gui] " + format % args)
37
38	    def do_GET(self):
39	        parsed = urlparse(self.path)
40	        if parsed.path == "/health":
41	            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
42	        if parsed.path == "/api/agents":
43	            return self.json_response({"agents": self.server.agents})
44	        if parsed.path == "/api/avatar":
45	            state = parse_qs(parsed.query).get("state", ["idle"])[0]
46	            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
47	        if parsed.path == "/api/sessions":
48	            return self.json_response({"sessions": self.server.sessions.list_sessions()})
49	        if parsed.path == "/api/history":
50	            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
51	            return self.json_response({"events": self.server.sessions.read(session_id)})
52	        return self.serve_static(parsed.path)
53
54	    def do_POST(self):
55	        if self.path == "/api/session":
56	            return self.json_response({"session_id": new_session_id()})
57	        if self.path == "/api/chat":
58	            return self.handle_chat()
59	        return self.json_response({"error": "Endpoint inconnu"}, status=404)
60
61	    def handle_chat(self):
62	        try:
63	            payload = self.read_json()
64	            agent_id = str(payload.get("agent", "echo"))
65	            message = str(payload.get("message", "")).strip()
66	            session_id = str(payload.get("session_id") or new_session_id())
67	            if not message:
68	                return self.json_response({"error": "Message vide"}, status=400)
69
70	            agent = find_agent(self.server.agents, agent_id)
71	            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
72	            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})
73
74	            result = adapter_for(agent).run(message, agent, session_id)
75	            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]
76	            return self.json_response(
77	                {
78	                    "session_id": session_id,
79	                    "agent": agent_id,
80	                    "events": stored_events,
81	                    "avatar": get_avatar(result.avatar_state),
82	                }
83	            )
84	        except KeyError as exc:
85	            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
86	        except Exception as exc:  # defensive boundary for local UI
87	            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)
88
89	    def serve_static(self, request_path: str):
90	        relative = "index.html" if request_path in ("/", "") else request_path.lstrip("/")
91	        path = (STATIC_ROOT / relative).resolve()
92	        if STATIC_ROOT.resolve() not in path.parents and path != STATIC_ROOT.resolve():
93	            return self.json_response({"error": "Chemin interdit"}, status=403)
94	        if not path.exists() or not path.is_file():
95	            return self.json_response({"error": "Fichier introuvable"}, status=404)
96	        content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
97	        body = path.read_bytes()
98	        self.send_response(200)
99	        self.send_header("Content-Type", content_type)
100	        self.send_header("Content-Length", str(len(body)))
101	        self.end_headers()
102	        self.wfile.write(body)
103
104	    def read_json(self):
105	        length = int(self.headers.get("Content-Length", "0"))
106	        body = self.rfile.read(length).decode("utf-8") if length else "{}"
107	        return json.loads(body)
108
109	    def json_response(self, payload, status: int = 200):
110	        body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
111	        self.send_response(status)
112	        self.send_header("Content-Type", "application/json; charset=utf-8")
113	        self.send_header("Content-Length", str(len(body)))
114	        self.end_headers()
115	        self.wfile.write(body)
116
117
118	def main(argv: list[str] | None = None):
119	    parser = argparse.ArgumentParser(description="Agent GUI local")
120	    parser.add_argument("--host", default="127.0.0.1")
121	    parser.add_argument("--port", type=int, default=8765)
122	    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
123	    parser.add_argument("--sessions", type=Path, default=DEFAULT_SESSIONS)
124	    args = parser.parse_args(argv)
125
126	    server = AgentGuiServer((args.host, args.port), Handler, args.config, args.sessions)
127	    print(f"Agent GUI lancé: http://{args.host}:{args.port}")
128	    print(f"Config agents: {args.config}")
129	    print("Ctrl+C pour arrêter.")
130	    server.serve_forever()
131
132
133	if __name__ == "__main__":
134	    main()
135

from __future__ import annotations

import argparse
import json
import mimetypes
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"

class AgentGuiServer(ThreadingHTTPServer):
    def __init__(self, server_address, RequestHandlerClass, config_path: Path, sessions_root: Path):
        super().__init__(server_address, RequestHandlerClass)
        self.config_path = config_path
        self.sessions = SessionStore(sessions_root)

    @property
    def agents(self):
        return load_agents(self.config_path)

class Handler(BaseHTTPRequestHandler):
    server: AgentGuiServer

    def log_message(self, format: str, *args):
        print("[agent-gui] " + format % args)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        return self.serve_static(parsed.path)

    def do_POST(self):
        if self.path == "/api/session":
            return self.json_response({"session_id": new_session_id()})
        if self.path == "/api/chat":
            return self.handle_chat()
        return self.json_response({"error": "Endpoint inconnu"}, status=404)

    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]
            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

    def serve_static(self, request_path: str):
        relative = "index.html" if request_path in ("/", "") else request_path.lstrip("/")
        path = (STATIC_ROOT / relative).resolve()
        if STATIC_ROOT.resolve() not in path.parents and path != STATIC_ROOT.resolve():
            return self.json_response({"error": "Chemin interdit"}, status=403)
        if not path.exists() or not path.is_file():
            return self.json_response({"error": "Fichier introuvable"}, status=404)
        content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
        body = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def read_json(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8") if length else "{}"
        return json.loads(body)

    def json_response(self, payload, status: int = 200):
        body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

def main(argv: list[str] | None = None):
    parser = argparse.ArgumentParser(description="Agent GUI local")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8765)
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--sessions", type=Path, default=DEFAULT_SESSIONS)
    args = parser.parse_args(argv)

    server = AgentGuiServer((args.host, args.port), Handler, args.config, args.sessions)
    print(f"Agent GUI lancé: http://{args.host}:{args.port}")
    print(f"Config agents: {args.config}")
    print("Ctrl+C pour arrêter.")
    server.serve_forever()

if __name__ == "__main__":
    main()


1	<!doctype html>
2
3
4	  <meta charset="utf-8" />
5	  <meta name="viewport" content="width=device-width, initial-scale=1" />
6	  <title>Agent GUI Local</title>
7	  <link rel="stylesheet" href="/styles.css" />
8
9
10

11

12
13

14
AG

15

16
Pixel command center

17
Agent GUI

18
Surcouche locale universelle

19

20

21
22	      Agent actif
23	      <select id="agentSelect"></select>
24

25
26

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

30
31

32
Sessions

33

34

35
36
37
38

39

40

41
Virtual office

42
Bureau animé

43

44

45	            idle
46	            Session
47

48

49	        <canvas id="officeCanvas" width="960" height="540"></canvas>
50
Les agents arrivent au bureau.

51

52
53

54

55

56
Conversation

57
Chat

58

59
Ctrl+Enter pour envoyer

60

61
62

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

69
70
71
72

73
Crew

74
Agents présents

75

76

77
78

79
Selected

80
Agent

81
idle

82

83

84
85

86
Timeline

87
Activity

88

89

90
91

92
93
94
95
96

<!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>

Crew

Agents présents

Selected

Agent

idle

Timeline

Activity


1	const state = {
2	  sessionId: localStorage.getItem('agentGuiSession') || null,
3	  agents: [],
4	  crew: [],
5	  selectedAgentId: null,
6	  sceneMessage: 'Les agents arrivent au bureau.',
7	  selectedStatus: 'idle',
8	};
9
10	const OFFICE = {
11	  baseWidth: 960,
12	  baseHeight: 540,
13	};
14
15	const $ = (id) => document.getElementById(id);
16
17	function clamp(value, min, max) {
18	  return Math.max(min, Math.min(max, value));
19	}
20
21	function lerp(a, b, t) {
22	  return a + (b - a) * t;
23	}
24
25	function hashString(text) {
26	  let hash = 2166136261;
27	  for (let i = 0; i < text.length; i += 1) {
28	    hash ^= text.charCodeAt(i);
29	    hash = Math.imul(hash, 16777619);
30	  }
31	  return hash >>> 0;
32	}
33
34	function colorFromHash(text, lightness = 58, saturation = 78) {
35	  const h = hashString(text) % 360;
36	  return `hsl(${h} ${saturation}% ${lightness}%)`;
37	}
38
39	function paletteFor(id) {
40	  const seed = hashString(id);
41	  const hue = seed % 360;
42	  return {
43	    shirt: `hsl(${hue} 80% 58%)`,
44	    shirtDark: `hsl(${hue} 72% 42%)`,
45	    accent: `hsl(${(hue + 42) % 360} 80% 64%)`,
46	    pants: `hsl(${(hue + 210) % 360} 30% 30%)`,
47	    skin: '#f3c6a4',
48	    hair: '#2a2436',
49	    outline: '#10131b',
50	    light: '#ffffff',
51	  };
52	}
53
54	async function api(path, options = {}) {
55	  const response = await fetch(path, {
56	    headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
57	    ...options,
58	  });
59	  const data = await response.json();
60	  if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
61	  return data;
62	}
63
64	function setStatus(name) {
65	  const pill = $('statusPill');
66	  pill.textContent = name;
67	  pill.className = `pill ${name}`;
68	  state.selectedStatus = name;
69	  const member = currentCrewMember();
70	  if (member) member.mood = name;
71	}
72
73	function setOfficeCaption(text) {
74	  state.sceneMessage = text;
75	  $('officeCaption').textContent = text;
76	}
77
78	function currentCrewMember() {
79	  return state.crew.find((member) => member.id === state.selectedAgentId) || state.crew[0] || null;
80	}
81
82	function updateSelectedAgentCard(member) {
83	  if (!member) {
84	    $('selectedAgentName').textContent = 'Agent';
85	    $('selectedAgentState').textContent = 'idle';
86	    $('selectedAgentMeta').textContent = '';
87	    return;
88	  }
89	  $('selectedAgentName').textContent = member.name;
90	  $('selectedAgentState').textContent = member.mood || 'idle';
91	  $('selectedAgentMeta').textContent = [
92	    `id: ${member.id}`,
93	    `type: ${member.type || 'unknown'}`,
94	    `description: ${member.description || '—'}`,
95	    member.command ? `command: ${member.command.join(' ')}` : '',
96	  ].filter(Boolean).join('\n');
97	}
98
99	function getSpawnPoint(index, total) {
100	  const cols = Math.max(3, Math.ceil(Math.sqrt(total || 1)));
101	  const row = Math.floor(index / cols);
102	  const col = index % cols;
103	  return {
104	    x: 180 + col * 120,
105	    y: 350 + row * 36,
106	  };
107	}
108
109	function createCrewMember(agent, index, total) {
110	  const spawn = getSpawnPoint(index, total);
111	  return {
112	    id: agent.id,
113	    name: agent.name || agent.id,
114	    description: agent.description || '',
115	    type: agent.type || 'echo',
116	    command: agent.command || null,
117	    enabled: agent.enabled !== false,
118	    palette: paletteFor(agent.id),
119	    x: spawn.x,
120	    y: spawn.y,
121	    tx: spawn.x,
122	    ty: spawn.y,
123	    face: 1,
124	    mood: 'idle',
125	    bubble: '',
126	    bubbleUntil: 0,
127	    wanderUntil: performance.now() + 1000 + index * 420,
128	    speed: 34 + (index % 3) * 5,
129	    bob: 0,
130	    blinkUntil: performance.now() + 1200 + index * 250,
131	    selected: false,
132	    walkFrame: 0,
133	    active: false,
134	    lastTalkAt: 0,
135	  };
136	}
137
138	function syncCrew() {
139	  const byId = new Map(state.crew.map((member) => [member.id, member]));
140	  const total = state.agents.length;
141	  state.crew = state.agents.map((agent, index) => {
142	    const existing = byId.get(agent.id);
143	    const base = existing || createCrewMember(agent, index, total);
144	    const spawn = getSpawnPoint(index, total);
145	    return {
146	      ...base,
147	      name: agent.name || agent.id,
148	      description: agent.description || '',
149	      type: agent.type || 'echo',
150	      command: agent.command || null,
151	      enabled: agent.enabled !== false,
152	      palette: base.palette || paletteFor(agent.id),
153	      tx: base.tx ?? spawn.x,
154	      ty: base.ty ?? spawn.y,
155	      x: typeof base.x === 'number' ? base.x : spawn.x,
156	      y: typeof base.y === 'number' ? base.y : spawn.y,
157	    };
158	  });
159
160	  if (!state.selectedAgentId && state.crew.length) {
161	    state.selectedAgentId = state.crew[0].id;
162	  }
163	  if (state.selectedAgentId && !state.crew.some((member) => member.id === state.selectedAgentId)) {
164	    state.selectedAgentId = state.crew[0]?.id || null;
165	  }
166
167	  renderCrewList();
168	  const selected = currentCrewMember();
169	  updateSelectedAgentCard(selected);
170	}
171
172	function renderCrewList() {
173	  const root = $('crewList');
174	  root.innerHTML = '';
175	  for (const member of state.crew) {
176	    const item = document.createElement('button');
177	    item.type = 'button';
178	    item.className = `crew-item ${member.id === state.selectedAgentId ? 'active' : ''}`;
179	    item.innerHTML = `
180
181
182
183
184
185
186	    `;
187	    item.querySelector('.crew-swatch').style.background = colorFromHash(member.id);
188	    item.querySelector('.crew-name').textContent = member.name;
189	    item.querySelector('.crew-status').textContent = member.mood || 'idle';
190	    item.querySelector('.crew-badge').textContent = member.id === state.selectedAgentId ? '●' : '⋯';
191	    item.onclick = () => {
192	      state.selectedAgentId = member.id;
193	      $('agentSelect').value = member.id;
194	      renderCrewList();
195	      updateSelectedAgentCard(member);
196	      setOfficeCaption(`${member.name} est sélectionné.`);
197	    };
198	    root.appendChild(item);
199	  }
200	}
201
202	function renderAgentSelectOptions() {
203	  const select = $('agentSelect');
204	  select.innerHTML = '';
205	  for (const agent of state.agents) {
206	    const option = document.createElement('option');
207	    option.value = agent.id;
208	    option.textContent = agent.name || agent.id;
209	    select.appendChild(option);
210	  }
211	}
212
213	function addMessage(kind, content, meta = '') {
214	  const node = document.createElement('div');
215	  node.className = `message ${kind}`;
216	  node.innerHTML = `<div class="meta"></div><div class="content"></div>`;
217	  node.querySelector('.meta').textContent = meta || kind;
218	  node.querySelector('.content').textContent = content;
219	  $('chatLog').appendChild(node);
220	  $('chatLog').scrollTop = $('chatLog').scrollHeight;
221	}
222
223	function addTimeline(event) {
224	  if (!['status', 'tool_request', 'tool_result', 'error'].includes(event.type)) return;
225	  const item = document.createElement('div');
226	  item.className = `timeline-item ${event.type === 'error' ? 'error' : ''}`;
227	  item.innerHTML = `<small></small><div></div>`;
228	  item.querySelector('small').textContent = `${event.type} · ${event.agent || ''}`;
229	  item.querySelector('div').textContent = event.content || '';
230	  $('timeline').prepend(item);
231	}
232
233	function setCrewMood(agentId, mood, bubble = '', ttl = 1600) {
234	  const member = state.crew.find((item) => item.id === agentId);
235	  if (!member) return;
236	  member.mood = mood;
237	  member.bubble = bubble;
238	  member.bubbleUntil = bubble ? performance.now() + ttl : 0;
239	  member.active = agentId === state.selectedAgentId;
240	  member.lastTalkAt = performance.now();
241	  renderCrewList();
242	  updateSelectedAgentCard(currentCrewMember());
243	}
244
245	function setSelectedAgent(agentId) {
246	  state.selectedAgentId = agentId;
247	  $('agentSelect').value = agentId;
248	  for (const member of state.crew) {
249	    member.active = member.id === agentId;
250	  }
251	  const member = currentCrewMember();
252	  renderCrewList();
253	  updateSelectedAgentCard(member);
254	  if (member) {
255	    $('agentDescription').textContent = member.description || '';
256	  }
257	}
258
259	function renderEvent(event) {
260	  if (event.type === 'user_message') {
261	    addMessage('user', event.content, 'toi');
262	    const member = currentCrewMember();
263	    if (member) {
264	      member.mood = 'thinking';
265	      member.bubble = '';
266	      member.active = true;
267	    }
268	  }
269	  if (event.type === 'assistant_message') {
270	    addMessage('assistant', event.content, event.agent || 'agent');
271	    setCrewMood(event.agent, 'speaking', truncateBubble(event.content), 2400);
272	    setOfficeCaption(`${event.agent} a répondu.`);
273	  }
274	  if (event.type === 'error') {
275	    addMessage('error', event.content, event.agent || 'erreur');
276	    setCrewMood(event.agent || state.selectedAgentId, 'error', '!', 1800);
277	    setOfficeCaption('Un agent a rencontré une erreur.');
278	  }
279	  if (event.type === 'status') {
280	    addTimeline(event);
281	    if (/réflexion/i.test(event.content)) {
282	      const member = currentCrewMember();
283	      if (member) member.mood = 'thinking';
284	      setOfficeCaption('L’agent réfléchit...');
285	    } else if (/Lancement commande/i.test(event.content)) {
286	      const member = currentCrewMember();
287	      if (member) member.mood = 'tool';
288	      setOfficeCaption('L’agent utilise un outil CLI.');
289	    }
290	    return;
291	  }
292	  addTimeline(event);
293	  renderCrewList();
294	  updateSelectedAgentCard(currentCrewMember());
295	}
296
297	function truncateBubble(text) {
298	  const oneLine = String(text).replace(/\s+/g, ' ').trim();
299	  if (oneLine.length <= 22) return oneLine;
300	  return `${oneLine.slice(0, 21)}…`;
301	}
302
303	async function ensureSession() {
304	  if (state.sessionId) return state.sessionId;
305	  const data = await api('/api/session', { method: 'POST', body: '{}' });
306	  state.sessionId = data.session_id;
307	  localStorage.setItem('agentGuiSession', state.sessionId);
308	  $('sessionLabel').textContent = `Session ${state.sessionId}`;
309	  return state.sessionId;
310	}
311
312	async function loadAgents() {
313	  const data = await api('/api/agents');
314	  state.agents = data.agents;
315	  renderAgentSelectOptions();
316	  syncCrew();
317	  if (!state.selectedAgentId && state.crew[0]) state.selectedAgentId = state.crew[0].id;
318	  if (state.selectedAgentId) {
319	    $('agentSelect').value = state.selectedAgentId;
320	    const member = currentCrewMember();
321	    updateSelectedAgentCard(member);
322	    $('agentDescription').textContent = member?.description || '';
323	  }
324	  if (state.crew.length === 0) {
325	    setOfficeCaption('Aucun agent disponible.');
326	  }
327	}
328
329	async function loadSessions() {
330	  const data = await api('/api/sessions');
331	  const list = $('sessionsList');
332	  list.innerHTML = '';
333	  for (const session of data.sessions.slice(0, 10)) {
334	    const btn = document.createElement('button');
335	    btn.type = 'button';
336	    btn.textContent = `${session.session_id} · ${session.events} events`;
337	    btn.onclick = () => openSession(session.session_id);
338	    list.appendChild(btn);
339	  }
340	}
341
342	async function openSession(sessionId) {
343	  state.sessionId = sessionId;
344	  localStorage.setItem('agentGuiSession', sessionId);
345	  $('sessionLabel').textContent = `Session ${sessionId}`;
346	  $('chatLog').innerHTML = '';
347	  $('timeline').innerHTML = '';
348	  const data = await api(`/api/history?session_id=${encodeURIComponent(sessionId)}`);
349	  data.events.forEach(renderEvent);
350	}
351
352	async function sendMessage(event) {
353	  event.preventDefault();
354	  const input = $('messageInput');
355	  const message = input.value.trim();
356	  if (!message) return;
357	  const agent = $('agentSelect').value;
358	  const sessionId = await ensureSession();
359
360	  input.value = '';
361	  renderEvent({ type: 'user_message', content: message, agent });
362	  setStatus('thinking');
363	  setOfficeCaption(`${agent} est en train de réfléchir...`);
364	  setCrewMood(agent, 'thinking', '', 0);
365
366	  try {
367	    const data = await api('/api/chat', {
368	      method: 'POST',
369	      body: JSON.stringify({ agent, message, session_id: sessionId }),
370	    });
371	    data.events.forEach(renderEvent);
372	    $('officeCaption').textContent = data.avatar?.state === 'speaking'
373	      ? `${agent} est retourné à son bureau.`
374	      : state.sceneMessage;
375	    setStatus(data.avatar?.state || 'speaking');
376	    await loadSessions();
377	  } catch (error) {
378	    addMessage('error', error.message, 'erreur');
379	    setStatus('error');
380	    setOfficeCaption('Erreur lors de l’exécution de l’agent.');
381	    setCrewMood(agent, 'error', '!', 1900);
382	  }
383	}
384
385	async function newSession() {
386	  const data = await api('/api/session', { method: 'POST', body: '{}' });
387	  state.sessionId = data.session_id;
388	  localStorage.setItem('agentGuiSession', state.sessionId);
389	  $('sessionLabel').textContent = `Session ${state.sessionId}`;
390	  $('chatLog').innerHTML = '';
391	  $('timeline').innerHTML = '';
392	  setStatus('idle');
393	  setOfficeCaption('Nouvelle session démarrée.');
394	  for (const member of state.crew) {
395	    member.mood = 'idle';
396	    member.bubble = '';
397	  }
398	  renderCrewList();
399	  updateSelectedAgentCard(currentCrewMember());
400	}
401
402	function resizeOfficeCanvas() {
403	  const canvas = $('officeCanvas');
404	  const rect = canvas.getBoundingClientRect();
405	  const dpr = window.devicePixelRatio || 1;
406	  canvas.width = Math.max(1, Math.round(rect.width * dpr));
407	  canvas.height = Math.max(1, Math.round(rect.height * dpr));
408	  const ctx = canvas.getContext('2d');
409	  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
410	  ctx.imageSmoothingEnabled = false;
411	}
412
413	function drawPixelRect(ctx, x, y, w, h, color) {
414	  ctx.fillStyle = color;
415	  ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
416	}
417
418	function drawOfficeBackground(ctx, w, h, now) {
419	  const horizon = h * 0.40;
420	  const floorTop = h * 0.56;
421
422	  const wallGrad = ctx.createLinearGradient(0, 0, 0, floorTop);
423	  wallGrad.addColorStop(0, '#17233b');
424	  wallGrad.addColorStop(1, '#0e1627');
425	  ctx.fillStyle = wallGrad;
426	  ctx.fillRect(0, 0, w, floorTop);
427
428	  const floorGrad = ctx.createLinearGradient(0, floorTop, 0, h);
429	  floorGrad.addColorStop(0, '#0f1726');
430	  floorGrad.addColorStop(1, '#060b12');
431	  ctx.fillStyle = floorGrad;
432	  ctx.fillRect(0, floorTop, w, h - floorTop);
433
434	  // windows
435	  for (let i = 0; i < 3; i += 1) {
436	    const wx = w * (0.18 + i * 0.24);
437	    const wy = h * 0.10;
438	    const ww = w * 0.16;
439	    const wh = h * 0.18;
440	    ctx.fillStyle = '#0a1730';
441	    ctx.fillRect(wx, wy, ww, wh);
442	    ctx.fillStyle = 'rgba(110,231,255,0.09)';
443	    ctx.fillRect(wx + 4, wy + 4, ww - 8, wh - 8);
444	    ctx.strokeStyle = 'rgba(255,255,255,0.10)';
445	    ctx.lineWidth = 2;
446	    ctx.strokeRect(wx, wy, ww, wh);
447	    ctx.beginPath();
448	    ctx.moveTo(wx + ww / 2, wy);
449	    ctx.lineTo(wx + ww / 2, wy + wh);
450	    ctx.moveTo(wx, wy + wh / 2);
451	    ctx.lineTo(wx + ww, wy + wh / 2);
452	    ctx.stroke();
453	  }
454
455	  // stars / city lights
456	  for (let i = 0; i < 34; i += 1) {
457	    const x = (i * 73 + 19) % w;
458	    const y = (i * 37 + 11) % Math.max(1, horizon - 10);
459	    ctx.fillStyle = i % 4 === 0 ? '#e4f7ff' : '#8ddcff';
460	    ctx.fillRect(x, y, 2, 2);
461	  }
462	  ctx.fillStyle = 'rgba(8, 18, 39, 0.72)';
463	  for (let i = 0; i < 10; i += 1) {
464	    const x = i * (w / 10) + ((i % 2) * 10);
465	    const bh = 28 + (i % 3) * 18;
466	    ctx.fillRect(x, horizon - bh * 0.6, 18 + (i % 2) * 12, bh);
467	  }
468
469	  // wall trim
470	  ctx.fillStyle = '#0d1626';
471	  ctx.fillRect(0, floorTop - 14, w, 14);
472
473	  // perspective floor grid
474	  const vanishingX = w * 0.52;
475	  const vanishingY = floorTop - 10;
476	  ctx.strokeStyle = 'rgba(161,193,236,0.14)';
477	  ctx.lineWidth = 1;
478	  for (let i = -8; i <= 8; i += 1) {
479	    const t = i / 8;
480	    ctx.beginPath();
481	    ctx.moveTo(w * 0.5, floorTop);
482	    ctx.lineTo(vanishingX + t * w * 0.7, h);
483	    ctx.stroke();
484	  }
485	  for (let i = 0; i <= 10; i += 1) {
486	    const y = lerp(floorTop, h, i / 10);
487	    ctx.beginPath();
488	    ctx.moveTo(0, y);
489	    ctx.lineTo(w, y);
490	    ctx.stroke();
491	  }
492
493	  // central desk and workstation
494	  const deskW = w * 0.22;
495	  const deskH = h * 0.10;
496	  const deskX = w * 0.41;
497	  const deskY = h * 0.68;
498	  ctx.fillStyle = '#2a1f35';
499	  ctx.fillRect(deskX, deskY, deskW, deskH);
500	  ctx.fillStyle = '#1b2437';
501	  ctx.fillRect(deskX + 8, deskY + 8, deskW - 16, deskH - 16);
502	  ctx.fillStyle = '#0b1120';
503	  ctx.fillRect(deskX + deskW * 0.35, deskY - 40, deskW * 0.30, 26);
504	  ctx.fillStyle = '#5fe5ff';
505	  ctx.fillRect(deskX + deskW * 0.37, deskY - 36, deskW * 0.26, 18);
506	  ctx.fillStyle = '#7f8ea8';
507	  ctx.fillRect(deskX + deskW * 0.42, deskY - 12, deskW * 0.16, 18);
508	  ctx.fillStyle = '#2f3d56';
509	  ctx.fillRect(deskX + deskW * 0.48, deskY + 10, deskW * 0.04, 40);
510	  ctx.fillRect(deskX + deskW * 0.12, deskY + 24, deskW * 0.16, 12);
511	  ctx.fillRect(deskX + deskW * 0.72, deskY + 24, deskW * 0.16, 12);
512
513	  // side furniture
514	  ctx.fillStyle = '#1a2438';
515	  ctx.fillRect(w * 0.08, h * 0.67, w * 0.12, h * 0.11);
516	  ctx.fillStyle = '#22324c';
517	  ctx.fillRect(w * 0.09, h * 0.69, w * 0.10, h * 0.07);
518	  ctx.fillStyle = '#1e2d41';
519	  ctx.fillRect(w * 0.80, h * 0.64, w * 0.10, h * 0.13);
520	  ctx.fillStyle = '#5af0a6';
521	  ctx.fillRect(w * 0.83, h * 0.60, 14, 26);
522	  ctx.fillRect(w * 0.79, h * 0.62, 22, 8);
523
524	  // plant
525	  const pulse = 0.5 + Math.sin(now / 500) * 0.5;
526	  ctx.fillStyle = '#14311f';
527	  ctx.fillRect(w * 0.88, h * 0.70, 18, 20);
528	  ctx.fillStyle = `rgba(90, 240, 166, ${0.6 + pulse * 0.2})`;
529	  ctx.fillRect(w * 0.885, h * 0.66, 8, 18);
530	  ctx.fillRect(w * 0.895, h * 0.63, 12, 24);
531	  ctx.fillRect(w * 0.902, h * 0.66, 8, 18);
532
533	  // wall board
534	  ctx.fillStyle = '#111a2b';
535	  ctx.fillRect(w * 0.06, h * 0.20, w * 0.18, h * 0.18);
536	  ctx.strokeStyle = 'rgba(110,231,255,.22)';
537	  ctx.strokeRect(w * 0.06, h * 0.20, w * 0.18, h * 0.18);
538	  ctx.fillStyle = 'rgba(110,231,255,.10)';
539	  ctx.fillRect(w * 0.08, h * 0.24, w * 0.14, 6);
540	  ctx.fillRect(w * 0.08, h * 0.29, w * 0.10, 6);
541	}
542
543	function drawSpeechBubble(ctx, x, y, text, color = '#e8f1ff') {
544	  const paddingX = 8;
545	  const paddingY = 6;
546	  const lines = wrapText(text, 16);
547	  const lineHeight = 12;
548	  const width = Math.max(...lines.map((line) => line.length), 1) * 7 + paddingX * 2;
549	  const height = lines.length * lineHeight + paddingY * 2;
550	  const bx = x - width / 2;
551	  const by = y - height - 16;
552
553	  ctx.fillStyle = 'rgba(5, 10, 18, 0.92)';
554	  ctx.fillRect(bx, by, width, height);
555	  ctx.strokeStyle = 'rgba(110,231,255,0.35)';
556	  ctx.lineWidth = 1;
557	  ctx.strokeRect(bx, by, width, height);
558	  ctx.fillStyle = color;
559	  ctx.font = '11px monospace';
560	  lines.forEach((line, index) => {
561	    ctx.fillText(line, bx + paddingX, by + paddingY + 10 + index * lineHeight);
562	  });
563	  ctx.fillStyle = 'rgba(5, 10, 18, 0.92)';
564	  ctx.beginPath();
565	  ctx.moveTo(x - 5, by + height);
566	  ctx.lineTo(x + 2, by + height + 8);
567	  ctx.lineTo(x + 8, by + height);
568	  ctx.closePath();
569	  ctx.fill();
570	}
571
572	function wrapText(text, maxChars) {
573	  const words = String(text).split(/\s+/).filter(Boolean);
574	  const lines = [];
575	  let current = '';
576	  for (const word of words) {
577	    const next = current ? `${current} ${word}` : word;
578	    if (next.length > maxChars && current) {
579	      lines.push(current);
580	      current = word;
581	    } else {
582	      current = next;
583	    }
584	  }
585	  if (current) lines.push(current);
586	  return lines.slice(0, 3);
587	}
588
589	function drawLittlePerson(ctx, member, now) {
590	  const palette = member.palette;
591	  const x = member.x;
592	  const y = member.y + member.bob;
593	  const pose = member.mood;
594	  const blink = now > member.blinkUntil && Math.floor(now / 260) % 14 < 1;
595	  const outline = palette.outline;
596
597	  ctx.save();
598	  ctx.translate(x, y);
599	  ctx.scale(member.face || 1, 1);
600
601	  // shadow
602	  ctx.fillStyle = 'rgba(0,0,0,.35)';
603	  ctx.beginPath();
604	  ctx.ellipse(0, 2, 12, 4, 0, 0, Math.PI * 2);
605	  ctx.fill();
606
607	  // backpack / jetpack for the Metal-Slug-like vibe
608	  drawPixelRect(ctx, -7, -20, 4, 12, palette.shirtDark);
609	  drawPixelRect(ctx, 3, -20, 4, 12, palette.shirtDark);
610
611	  // legs / boots
612	  const walkPhase = Math.floor(now / 180) % 2;
613	  const step = pose === 'thinking' ? 0 : walkPhase;
614	  const leftForward = step === 0;
615	  const legColor = palette.pants;
616	  const bootColor = '#0b0f18';
617	  const legY = -10;
618
619	  if (pose === 'error') {
620	    drawPixelRect(ctx, -6, legY, 5, 9, legColor);
621	    drawPixelRect(ctx, 1, legY + 1, 5, 8, legColor);
622	  } else {
623	    drawPixelRect(ctx, leftForward ? -6 : -5, legY, 5, 10, legColor);
624	    drawPixelRect(ctx, leftForward ? 1 : 0, legY, 5, 10, legColor);
625	    drawPixelRect(ctx, leftForward ? -7 : -6, legY + 8, 6, 3, bootColor);
626	    drawPixelRect(ctx, leftForward ? 0 : 1, legY + 8, 6, 3, bootColor);
627	  }
628
629	  // torso
630	  const torsoY = -25;
631	  drawPixelRect(ctx, -7, torsoY, 14, 12, outline);
632	  drawPixelRect(ctx, -6, torsoY + 1, 12, 10, palette.shirt);
633	  drawPixelRect(ctx, -5, torsoY + 3, 10, 6, palette.shirtDark);
634	  drawPixelRect(ctx, -2, torsoY + 1, 4, 3, palette.accent);
635
636	  // arms
637	  if (pose === 'speaking') {
638	    drawPixelRect(ctx, -11, torsoY + 2, 5, 3, outline);
639	    drawPixelRect(ctx, -10, torsoY + 3, 4, 2, palette.skin);
640	    drawPixelRect(ctx, 6, torsoY, 4, 3, outline);
641	    drawPixelRect(ctx, 7, torsoY + 1, 3, 2, palette.skin);
642	  } else if (pose === 'thinking' || pose === 'tool') {
643	    drawPixelRect(ctx, -11, torsoY + 4, 5, 3, outline);
644	    drawPixelRect(ctx, -10, torsoY + 5, 4, 2, palette.skin);
645	    drawPixelRect(ctx, 6, torsoY + 2, 5, 3, outline);
646	    drawPixelRect(ctx, 7, torsoY + 3, 4, 2, palette.skin);
647	  } else if (pose === 'error') {
648	    drawPixelRect(ctx, -11, torsoY + 5, 4, 3, outline);
649	    drawPixelRect(ctx, 6, torsoY + 5, 4, 3, outline);
650	  } else {
651	    drawPixelRect(ctx, -11, torsoY + 3, 4, 3, outline);
652	    drawPixelRect(ctx, 6, torsoY + 3, 4, 3, outline);
653	    drawPixelRect(ctx, -10, torsoY + 4, 3, 2, palette.skin);
654	    drawPixelRect(ctx, 7, torsoY + 4, 3, 2, palette.skin);
655	  }
656
657	  // head + hair
658	  drawPixelRect(ctx, -7, -36, 14, 11, outline);
659	  drawPixelRect(ctx, -6, -35, 12, 9, palette.skin);
660	  drawPixelRect(ctx, -7, -38, 14, 4, palette.hair);
661	  drawPixelRect(ctx, -6, -37, 12, 2, palette.hair);
662	  drawPixelRect(ctx, 2, -34, 3, 3, outline);
663	  drawPixelRect(ctx, -4, -34, 2, 2, blink ? palette.hair : palette.light);
664	  drawPixelRect(ctx, 1, -34, 2, 2, blink ? palette.hair : palette.light);
665	  drawPixelRect(ctx, -3, -30, 6, 2, outline);
666
667	  if (pose === 'error') {
668	    drawPixelRect(ctx, -4, -33, 2, 2, palette.accent);
669	    drawPixelRect(ctx, 2, -33, 2, 2, palette.accent);
670	    drawPixelRect(ctx, -3, -31, 6, 1, palette.accent);
671	  }
672
673	  if (pose === 'tool') {
674	    drawPixelRect(ctx, 9, -20, 5, 2, palette.accent);
675	    drawPixelRect(ctx, 12, -21, 2, 6, palette.accent);
676	  }
677
678	  if (member.active) {
679	    ctx.strokeStyle = 'rgba(110,231,255,.65)';
680	    ctx.lineWidth = 2;
681	    ctx.beginPath();
682	    ctx.arc(0, -18, 16, 0, Math.PI * 2);
683	    ctx.stroke();
684	  }
685
686	  if (member.bubble && now < member.bubbleUntil) {
687	    ctx.restore();
688	    drawSpeechBubble(ctx, member.x, member.y - 18 + member.bob, member.bubble);
689	    ctx.save();
690	    ctx.translate(x, y);
691	    ctx.scale(member.face || 1, 1);
692	  }
693
694	  ctx.fillStyle = member.selected ? '#6ee7ff' : '#8aa1c0';
695	  ctx.font = '10px monospace';
696	  ctx.fillText(member.name, -18, 22);
697
698	  ctx.restore();
699	}
700
701	function updateCrew(now) {
702	  const w = $('officeCanvas').clientWidth || OFFICE.baseWidth;
703	  const h = $('officeCanvas').clientHeight || OFFICE.baseHeight;
704	  const floorTop = h * 0.56;
705	  const deskX = w * 0.41;
706	  const deskY = h * 0.68;
707	  const deskW = w * 0.22;
708	  const targetZone = {
709	    x: deskX + deskW * 0.50,
710	    y: deskY - 18,
711	  };
712
713	  for (const member of state.crew) {
714	    member.selected = member.id === state.selectedAgentId;
715	    member.active = member.selected;
716	    member.bob = Math.sin(now / 260 + hashString(member.id) * 0.001) * 1.1;
717
718	    if (member.mood === 'speaking') {
719	      member.tx = targetZone.x;
720	      member.ty = targetZone.y;
721	      member.face = member.x <= targetZone.x ? 1 : -1;
722	    }
723
724	    if (member.mood === 'thinking' || member.mood === 'tool') {
725	      member.tx = targetZone.x - 40 + (hashString(member.id) % 80);
726	      member.ty = targetZone.y + 10 + ((hashString(member.id) >> 4) % 30);
727	      member.face = member.x <= member.tx ? 1 : -1;
728	    }
729
730	    if (member.mood === 'idle' && now > member.wanderUntil) {
731	      const wanderPoints = [
732	        { x: w * 0.16, y: floorTop + 55 },
733	        { x: w * 0.30, y: floorTop + 35 },
734	        { x: w * 0.60, y: floorTop + 40 },
735	        { x: w * 0.78, y: floorTop + 50 },
736	        { x: w * 0.50, y: floorTop + 60 },
737	      ];
738	      const point = wanderPoints[(hashString(member.id) + Math.floor(now / 5000)) % wanderPoints.length];
739	      member.tx = point.x;
740	      member.ty = point.y;
741	      member.wanderUntil = now + 3500 + (hashString(member.id) % 2500);
742	      member.face = member.x <= member.tx ? 1 : -1;
743	    }
744
745	    const dx = member.tx - member.x;
746	    const dy = member.ty - member.y;
747	    const dist = Math.hypot(dx, dy);
748
749	    if (member.mood === 'error') {
750	      member.x += Math.sin(now / 60 + hashString(member.id)) * 0.12;
751	      member.y += Math.cos(now / 54 + hashString(member.id)) * 0.10;
752	      continue;
753	    }
754
755	    if (dist > 1.5) {
756	      const speed = member.speed * (member.mood === 'thinking' ? 0.6 : member.mood === 'tool' ? 0.85 : 1);
757	      const step = Math.min(speed * 0.016, dist);
758	      member.x += (dx / dist) * step;
759	      member.y += (dy / dist) * step;
760	      member.face = dx >= 0 ? 1 : -1;
761	      member.walkFrame = Math.floor(now / 170) % 2;
762	    } else {
763	      member.x = member.tx;
764	      member.y = member.ty;
765	      member.walkFrame = 0;
766	      if (member.mood === 'thinking' || member.mood === 'tool') {
767	        member.mood = 'idle';
768	        member.bubble = '';
769	      }
770	      if (member.mood === 'speaking' && now - member.lastTalkAt > 2400) {
771	        member.mood = 'idle';
772	        member.bubble = '';
773	      }
774	    }
775	  }
776	}
777
778	function drawOffice(now) {
779	  const canvas = $('officeCanvas');
780	  const ctx = canvas.getContext('2d');
781	  const w = canvas.clientWidth || OFFICE.baseWidth;
782	  const h = canvas.clientHeight || OFFICE.baseHeight;
783
784	  ctx.save();
785	  ctx.setTransform(1, 0, 0, 1, 0, 0);
786	  ctx.clearRect(0, 0, canvas.width, canvas.height);
787	  ctx.scale((canvas.width / canvas.clientWidth) || 1, (canvas.height / canvas.clientHeight) || 1);
788	  ctx.imageSmoothingEnabled = false;
789
790	  drawOfficeBackground(ctx, w, h, now);
791	  updateCrew(now);
792
793	  // desk chair glow & monitors for ambience
794	  const glow = 0.35 + Math.sin(now / 420) * 0.08;
795	  ctx.fillStyle = `rgba(110,231,255,${glow})`;
796	  ctx.fillRect(w * 0.47, h * 0.54, 52, 4);
797
798	  for (const member of state.crew) {
799	    drawLittlePerson(ctx, member, now);
800	  }
801
802	  ctx.restore();
803	}
804
805	function animate(now) {
806	  drawOffice(now);
807	  requestAnimationFrame(animate);
808	}
809
810	function initEvents() {
811	  $('chatForm').addEventListener('submit', sendMessage);
812	  $('newSessionBtn').addEventListener('click', newSession);
813	  $('reloadBtn').addEventListener('click', loadAgents);
814	  $('agentSelect').addEventListener('change', () => {
815	    setSelectedAgent($('agentSelect').value);
816	  });
817	  $('messageInput').addEventListener('keydown', (event) => {
818	    if (event.ctrlKey && event.key === 'Enter') sendMessage(event);
819	  });
820	  window.addEventListener('resize', resizeOfficeCanvas);
821	}
822
823	async function init() {
824	  initEvents();
825	  resizeOfficeCanvas();
826	  await loadAgents();
827	  if (!state.selectedAgentId && state.crew[0]) {
828	    setSelectedAgent(state.crew[0].id);
829	  } else if (state.selectedAgentId) {
830	    setSelectedAgent(state.selectedAgentId);
831	  }
832	  if (state.sessionId) await openSession(state.sessionId);
833	  else await newSession();
834	  await loadSessions();
835	  setOfficeCaption('Le bureau est prêt.');
836	  requestAnimationFrame(animate);
837	}
838
839	init().catch(async (error) => {
840	  console.error(error);
841	  setStatus('error');
842	  setOfficeCaption('Erreur de démarrage de l’interface.');
843	});
844

const state = {
  sessionId: localStorage.getItem('agentGuiSession') || null,
  agents: [],
  crew: [],
  selectedAgentId: null,
  sceneMessage: 'Les agents arrivent au bureau.',
  selectedStatus: 'idle',
};

const OFFICE = {
  baseWidth: 960,
  baseHeight: 540,
};

const $ = (id) => document.getElementById(id);

function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function lerp(a, b, t) {
  return a + (b - a) * t;
}

function hashString(text) {
  let hash = 2166136261;
  for (let i = 0; i < text.length; i += 1) {
    hash ^= text.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

function colorFromHash(text, lightness = 58, saturation = 78) {
  const h = hashString(text) % 360;
  return `hsl(${h} ${saturation}% ${lightness}%)`;
}

function paletteFor(id) {
  const seed = hashString(id);
  const hue = seed % 360;
  return {
    shirt: `hsl(${hue} 80% 58%)`,
    shirtDark: `hsl(${hue} 72% 42%)`,
    accent: `hsl(${(hue + 42) % 360} 80% 64%)`,
    pants: `hsl(${(hue + 210) % 360} 30% 30%)`,
    skin: '#f3c6a4',
    hair: '#2a2436',
    outline: '#10131b',
    light: '#ffffff',
  };
}

async function api(path, options = {}) {
  const response = await fetch(path, {
    headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
    ...options,
  });
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  return data;
}

function setStatus(name) {
  const pill = $('statusPill');
  pill.textContent = name;
  pill.className = `pill ${name}`;
  state.selectedStatus = name;
  const member = currentCrewMember();
  if (member) member.mood = name;
}

function setOfficeCaption(text) {
  state.sceneMessage = text;
  $('officeCaption').textContent = text;
}

function currentCrewMember() {
  return state.crew.find((member) => member.id === state.selectedAgentId) || state.crew[0] || null;
}

function updateSelectedAgentCard(member) {
  if (!member) {
    $('selectedAgentName').textContent = 'Agent';
    $('selectedAgentState').textContent = 'idle';
    $('selectedAgentMeta').textContent = '';
    return;
  }
  $('selectedAgentName').textContent = member.name;
  $('selectedAgentState').textContent = member.mood || 'idle';
  $('selectedAgentMeta').textContent = [
    `id: ${member.id}`,
    `type: ${member.type || 'unknown'}`,
    `description: ${member.description || '—'}`,
    member.command ? `command: ${member.command.join(' ')}` : '',
  ].filter(Boolean).join('\n');
}

function getSpawnPoint(index, total) {
  const cols = Math.max(3, Math.ceil(Math.sqrt(total || 1)));
  const row = Math.floor(index / cols);
  const col = index % cols;
  return {
    x: 180 + col * 120,
    y: 350 + row * 36,
  };
}

function createCrewMember(agent, index, total) {
  const spawn = getSpawnPoint(index, total);
  return {
    id: agent.id,
    name: agent.name || agent.id,
    description: agent.description || '',
    type: agent.type || 'echo',
    command: agent.command || null,
    enabled: agent.enabled !== false,
    palette: paletteFor(agent.id),
    x: spawn.x,
    y: spawn.y,
    tx: spawn.x,
    ty: spawn.y,
    face: 1,
    mood: 'idle',
    bubble: '',
    bubbleUntil: 0,
    wanderUntil: performance.now() + 1000 + index * 420,
    speed: 34 + (index % 3) * 5,
    bob: 0,
    blinkUntil: performance.now() + 1200 + index * 250,
    selected: false,
    walkFrame: 0,
    active: false,
    lastTalkAt: 0,
  };
}

function syncCrew() {
  const byId = new Map(state.crew.map((member) => [member.id, member]));
  const total = state.agents.length;
  state.crew = state.agents.map((agent, index) => {
    const existing = byId.get(agent.id);
    const base = existing || createCrewMember(agent, index, total);
    const spawn = getSpawnPoint(index, total);
    return {
      ...base,
      name: agent.name || agent.id,
      description: agent.description || '',
      type: agent.type || 'echo',
      command: agent.command || null,
      enabled: agent.enabled !== false,
      palette: base.palette || paletteFor(agent.id),
      tx: base.tx ?? spawn.x,
      ty: base.ty ?? spawn.y,
      x: typeof base.x === 'number' ? base.x : spawn.x,
      y: typeof base.y === 'number' ? base.y : spawn.y,
    };
  });

  if (!state.selectedAgentId && state.crew.length) {
    state.selectedAgentId = state.crew[0].id;
  }
  if (state.selectedAgentId && !state.crew.some((member) => member.id === state.selectedAgentId)) {
    state.selectedAgentId = state.crew[0]?.id || null;
  }

  renderCrewList();
  const selected = currentCrewMember();
  updateSelectedAgentCard(selected);
}

function renderCrewList() {
  const root = $('crewList');
  root.innerHTML = '';
  for (const member of state.crew) {
    const item = document.createElement('button');
    item.type = 'button';
    item.className = `crew-item ${member.id === state.selectedAgentId ? 'active' : ''}`;
    item.innerHTML = `

    `;
    item.querySelector('.crew-swatch').style.background = colorFromHash(member.id);
    item.querySelector('.crew-name').textContent = member.name;
    item.querySelector('.crew-status').textContent = member.mood || 'idle';
    item.querySelector('.crew-badge').textContent = member.id === state.selectedAgentId ? '●' : '⋯';
    item.onclick = () => {
      state.selectedAgentId = member.id;
      $('agentSelect').value = member.id;
      renderCrewList();
      updateSelectedAgentCard(member);
      setOfficeCaption(`${member.name} est sélectionné.`);
    };
    root.appendChild(item);
  }
}

function renderAgentSelectOptions() {
  const select = $('agentSelect');
  select.innerHTML = '';
  for (const agent of state.agents) {
    const option = document.createElement('option');
    option.value = agent.id;
    option.textContent = agent.name || agent.id;
    select.appendChild(option);
  }
}

function addMessage(kind, content, meta = '') {
  const node = document.createElement('div');
  node.className = `message ${kind}`;
  node.innerHTML = `<div class="meta"></div><div class="content"></div>`;
  node.querySelector('.meta').textContent = meta || kind;
  node.querySelector('.content').textContent = content;
  $('chatLog').appendChild(node);
  $('chatLog').scrollTop = $('chatLog').scrollHeight;
}

function addTimeline(event) {
  if (!['status', 'tool_request', 'tool_result', 'error'].includes(event.type)) return;
  const item = document.createElement('div');
  item.className = `timeline-item ${event.type === 'error' ? 'error' : ''}`;
  item.innerHTML = `<small></small><div></div>`;
  item.querySelector('small').textContent = `${event.type} · ${event.agent || ''}`;
  item.querySelector('div').textContent = event.content || '';
  $('timeline').prepend(item);
}

function setCrewMood(agentId, mood, bubble = '', ttl = 1600) {
  const member = state.crew.find((item) => item.id === agentId);
  if (!member) return;
  member.mood = mood;
  member.bubble = bubble;
  member.bubbleUntil = bubble ? performance.now() + ttl : 0;
  member.active = agentId === state.selectedAgentId;
  member.lastTalkAt = performance.now();
  renderCrewList();
  updateSelectedAgentCard(currentCrewMember());
}

function setSelectedAgent(agentId) {
  state.selectedAgentId = agentId;
  $('agentSelect').value = agentId;
  for (const member of state.crew) {
    member.active = member.id === agentId;
  }
  const member = currentCrewMember();
  renderCrewList();
  updateSelectedAgentCard(member);
  if (member) {
    $('agentDescription').textContent = member.description || '';
  }
}

function renderEvent(event) {
  if (event.type === 'user_message') {
    addMessage('user', event.content, 'toi');
    const member = currentCrewMember();
    if (member) {
      member.mood = 'thinking';
      member.bubble = '';
      member.active = true;
    }
  }
  if (event.type === 'assistant_message') {
    addMessage('assistant', event.content, event.agent || 'agent');
    setCrewMood(event.agent, 'speaking', truncateBubble(event.content), 2400);
    setOfficeCaption(`${event.agent} a répondu.`);
  }
  if (event.type === 'error') {
    addMessage('error', event.content, event.agent || 'erreur');
    setCrewMood(event.agent || state.selectedAgentId, 'error', '!', 1800);
    setOfficeCaption('Un agent a rencontré une erreur.');
  }
  if (event.type === 'status') {
    addTimeline(event);
    if (/réflexion/i.test(event.content)) {
      const member = currentCrewMember();
      if (member) member.mood = 'thinking';
      setOfficeCaption('L’agent réfléchit...');
    } else if (/Lancement commande/i.test(event.content)) {
      const member = currentCrewMember();
      if (member) member.mood = 'tool';
      setOfficeCaption('L’agent utilise un outil CLI.');
    }
    return;
  }
  addTimeline(event);
  renderCrewList();
  updateSelectedAgentCard(currentCrewMember());
}

function truncateBubble(text) {
  const oneLine = String(text).replace(/\s+/g, ' ').trim();
  if (oneLine.length <= 22) return oneLine;
  return `${oneLine.slice(0, 21)}…`;
}

async function ensureSession() {
  if (state.sessionId) return state.sessionId;
  const data = await api('/api/session', { method: 'POST', body: '{}' });
  state.sessionId = data.session_id;
  localStorage.setItem('agentGuiSession', state.sessionId);
  $('sessionLabel').textContent = `Session ${state.sessionId}`;
  return state.sessionId;
}

async function loadAgents() {
  const data = await api('/api/agents');
  state.agents = data.agents;
  renderAgentSelectOptions();
  syncCrew();
  if (!state.selectedAgentId && state.crew[0]) state.selectedAgentId = state.crew[0].id;
  if (state.selectedAgentId) {
    $('agentSelect').value = state.selectedAgentId;
    const member = currentCrewMember();
    updateSelectedAgentCard(member);
    $('agentDescription').textContent = member?.description || '';
  }
  if (state.crew.length === 0) {
    setOfficeCaption('Aucun agent disponible.');
  }
}

async function loadSessions() {
  const data = await api('/api/sessions');
  const list = $('sessionsList');
  list.innerHTML = '';
  for (const session of data.sessions.slice(0, 10)) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.textContent = `${session.session_id} · ${session.events} events`;
    btn.onclick = () => openSession(session.session_id);
    list.appendChild(btn);
  }
}

async function openSession(sessionId) {
  state.sessionId = sessionId;
  localStorage.setItem('agentGuiSession', sessionId);
  $('sessionLabel').textContent = `Session ${sessionId}`;
  $('chatLog').innerHTML = '';
  $('timeline').innerHTML = '';
  const data = await api(`/api/history?session_id=${encodeURIComponent(sessionId)}`);
  data.events.forEach(renderEvent);
}

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);
  }
}

async function newSession() {
  const data = await api('/api/session', { method: 'POST', body: '{}' });
  state.sessionId = data.session_id;
  localStorage.setItem('agentGuiSession', state.sessionId);
  $('sessionLabel').textContent = `Session ${state.sessionId}`;
  $('chatLog').innerHTML = '';
  $('timeline').innerHTML = '';
  setStatus('idle');
  setOfficeCaption('Nouvelle session démarrée.');
  for (const member of state.crew) {
    member.mood = 'idle';
    member.bubble = '';
  }
  renderCrewList();
  updateSelectedAgentCard(currentCrewMember());
}

function resizeOfficeCanvas() {
  const canvas = $('officeCanvas');
  const rect = canvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.max(1, Math.round(rect.width * dpr));
  canvas.height = Math.max(1, Math.round(rect.height * dpr));
  const ctx = canvas.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.imageSmoothingEnabled = false;
}

function drawPixelRect(ctx, x, y, w, h, color) {
  ctx.fillStyle = color;
  ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
}

function drawOfficeBackground(ctx, w, h, now) {
  const horizon = h * 0.40;
  const floorTop = h * 0.56;

  const wallGrad = ctx.createLinearGradient(0, 0, 0, floorTop);
  wallGrad.addColorStop(0, '#17233b');
  wallGrad.addColorStop(1, '#0e1627');
  ctx.fillStyle = wallGrad;
  ctx.fillRect(0, 0, w, floorTop);

  const floorGrad = ctx.createLinearGradient(0, floorTop, 0, h);
  floorGrad.addColorStop(0, '#0f1726');
  floorGrad.addColorStop(1, '#060b12');
  ctx.fillStyle = floorGrad;
  ctx.fillRect(0, floorTop, w, h - floorTop);

  // windows
  for (let i = 0; i < 3; i += 1) {
    const wx = w * (0.18 + i * 0.24);
    const wy = h * 0.10;
    const ww = w * 0.16;
    const wh = h * 0.18;
    ctx.fillStyle = '#0a1730';
    ctx.fillRect(wx, wy, ww, wh);
    ctx.fillStyle = 'rgba(110,231,255,0.09)';
    ctx.fillRect(wx + 4, wy + 4, ww - 8, wh - 8);
    ctx.strokeStyle = 'rgba(255,255,255,0.10)';
    ctx.lineWidth = 2;
    ctx.strokeRect(wx, wy, ww, wh);
    ctx.beginPath();
    ctx.moveTo(wx + ww / 2, wy);
    ctx.lineTo(wx + ww / 2, wy + wh);
    ctx.moveTo(wx, wy + wh / 2);
    ctx.lineTo(wx + ww, wy + wh / 2);
    ctx.stroke();
  }

  // stars / city lights
  for (let i = 0; i < 34; i += 1) {
    const x = (i * 73 + 19) % w;
    const y = (i * 37 + 11) % Math.max(1, horizon - 10);
    ctx.fillStyle = i % 4 === 0 ? '#e4f7ff' : '#8ddcff';
    ctx.fillRect(x, y, 2, 2);
  }
  ctx.fillStyle = 'rgba(8, 18, 39, 0.72)';
  for (let i = 0; i < 10; i += 1) {
    const x = i * (w / 10) + ((i % 2) * 10);
    const bh = 28 + (i % 3) * 18;
    ctx.fillRect(x, horizon - bh * 0.6, 18 + (i % 2) * 12, bh);
  }

  // wall trim
  ctx.fillStyle = '#0d1626';
  ctx.fillRect(0, floorTop - 14, w, 14);

  // perspective floor grid
  const vanishingX = w * 0.52;
  const vanishingY = floorTop - 10;
  ctx.strokeStyle = 'rgba(161,193,236,0.14)';
  ctx.lineWidth = 1;
  for (let i = -8; i <= 8; i += 1) {
    const t = i / 8;
    ctx.beginPath();
    ctx.moveTo(w * 0.5, floorTop);
    ctx.lineTo(vanishingX + t * w * 0.7, h);
    ctx.stroke();
  }
  for (let i = 0; i <= 10; i += 1) {
    const y = lerp(floorTop, h, i / 10);
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(w, y);
    ctx.stroke();
  }

  // central desk and workstation
  const deskW = w * 0.22;
  const deskH = h * 0.10;
  const deskX = w * 0.41;
  const deskY = h * 0.68;
  ctx.fillStyle = '#2a1f35';
  ctx.fillRect(deskX, deskY, deskW, deskH);
  ctx.fillStyle = '#1b2437';
  ctx.fillRect(deskX + 8, deskY + 8, deskW - 16, deskH - 16);
  ctx.fillStyle = '#0b1120';
  ctx.fillRect(deskX + deskW * 0.35, deskY - 40, deskW * 0.30, 26);
  ctx.fillStyle = '#5fe5ff';
  ctx.fillRect(deskX + deskW * 0.37, deskY - 36, deskW * 0.26, 18);
  ctx.fillStyle = '#7f8ea8';
  ctx.fillRect(deskX + deskW * 0.42, deskY - 12, deskW * 0.16, 18);
  ctx.fillStyle = '#2f3d56';
  ctx.fillRect(deskX + deskW * 0.48, deskY + 10, deskW * 0.04, 40);
  ctx.fillRect(deskX + deskW * 0.12, deskY + 24, deskW * 0.16, 12);
  ctx.fillRect(deskX + deskW * 0.72, deskY + 24, deskW * 0.16, 12);

  // side furniture
  ctx.fillStyle = '#1a2438';
  ctx.fillRect(w * 0.08, h * 0.67, w * 0.12, h * 0.11);
  ctx.fillStyle = '#22324c';
  ctx.fillRect(w * 0.09, h * 0.69, w * 0.10, h * 0.07);
  ctx.fillStyle = '#1e2d41';
  ctx.fillRect(w * 0.80, h * 0.64, w * 0.10, h * 0.13);
  ctx.fillStyle = '#5af0a6';
  ctx.fillRect(w * 0.83, h * 0.60, 14, 26);
  ctx.fillRect(w * 0.79, h * 0.62, 22, 8);

  // plant
  const pulse = 0.5 + Math.sin(now / 500) * 0.5;
  ctx.fillStyle = '#14311f';
  ctx.fillRect(w * 0.88, h * 0.70, 18, 20);
  ctx.fillStyle = `rgba(90, 240, 166, ${0.6 + pulse * 0.2})`;
  ctx.fillRect(w * 0.885, h * 0.66, 8, 18);
  ctx.fillRect(w * 0.895, h * 0.63, 12, 24);
  ctx.fillRect(w * 0.902, h * 0.66, 8, 18);

  // wall board
  ctx.fillStyle = '#111a2b';
  ctx.fillRect(w * 0.06, h * 0.20, w * 0.18, h * 0.18);
  ctx.strokeStyle = 'rgba(110,231,255,.22)';
  ctx.strokeRect(w * 0.06, h * 0.20, w * 0.18, h * 0.18);
  ctx.fillStyle = 'rgba(110,231,255,.10)';
  ctx.fillRect(w * 0.08, h * 0.24, w * 0.14, 6);
  ctx.fillRect(w * 0.08, h * 0.29, w * 0.10, 6);
}

function drawSpeechBubble(ctx, x, y, text, color = '#e8f1ff') {
  const paddingX = 8;
  const paddingY = 6;
  const lines = wrapText(text, 16);
  const lineHeight = 12;
  const width = Math.max(...lines.map((line) => line.length), 1) * 7 + paddingX * 2;
  const height = lines.length * lineHeight + paddingY * 2;
  const bx = x - width / 2;
  const by = y - height - 16;

  ctx.fillStyle = 'rgba(5, 10, 18, 0.92)';
  ctx.fillRect(bx, by, width, height);
  ctx.strokeStyle = 'rgba(110,231,255,0.35)';
  ctx.lineWidth = 1;
  ctx.strokeRect(bx, by, width, height);
  ctx.fillStyle = color;
  ctx.font = '11px monospace';
  lines.forEach((line, index) => {
    ctx.fillText(line, bx + paddingX, by + paddingY + 10 + index * lineHeight);
  });
  ctx.fillStyle = 'rgba(5, 10, 18, 0.92)';
  ctx.beginPath();
  ctx.moveTo(x - 5, by + height);
  ctx.lineTo(x + 2, by + height + 8);
  ctx.lineTo(x + 8, by + height);
  ctx.closePath();
  ctx.fill();
}

function wrapText(text, maxChars) {
  const words = String(text).split(/\s+/).filter(Boolean);
  const lines = [];
  let current = '';
  for (const word of words) {
    const next = current ? `${current} ${word}` : word;
    if (next.length > maxChars && current) {
      lines.push(current);
      current = word;
    } else {
      current = next;
    }
  }
  if (current) lines.push(current);
  return lines.slice(0, 3);
}

function drawLittlePerson(ctx, member, now) {
  const palette = member.palette;
  const x = member.x;
  const y = member.y + member.bob;
  const pose = member.mood;
  const blink = now > member.blinkUntil && Math.floor(now / 260) % 14 < 1;
  const outline = palette.outline;

  ctx.save();
  ctx.translate(x, y);
  ctx.scale(member.face || 1, 1);

  // shadow
  ctx.fillStyle = 'rgba(0,0,0,.35)';
  ctx.beginPath();
  ctx.ellipse(0, 2, 12, 4, 0, 0, Math.PI * 2);
  ctx.fill();

  // backpack / jetpack for the Metal-Slug-like vibe
  drawPixelRect(ctx, -7, -20, 4, 12, palette.shirtDark);
  drawPixelRect(ctx, 3, -20, 4, 12, palette.shirtDark);

  // legs / boots
  const walkPhase = Math.floor(now / 180) % 2;
  const step = pose === 'thinking' ? 0 : walkPhase;
  const leftForward = step === 0;
  const legColor = palette.pants;
  const bootColor = '#0b0f18';
  const legY = -10;

  if (pose === 'error') {
    drawPixelRect(ctx, -6, legY, 5, 9, legColor);
    drawPixelRect(ctx, 1, legY + 1, 5, 8, legColor);
  } else {
    drawPixelRect(ctx, leftForward ? -6 : -5, legY, 5, 10, legColor);
    drawPixelRect(ctx, leftForward ? 1 : 0, legY, 5, 10, legColor);
    drawPixelRect(ctx, leftForward ? -7 : -6, legY + 8, 6, 3, bootColor);
    drawPixelRect(ctx, leftForward ? 0 : 1, legY + 8, 6, 3, bootColor);
  }

  // torso
  const torsoY = -25;
  drawPixelRect(ctx, -7, torsoY, 14, 12, outline);
  drawPixelRect(ctx, -6, torsoY + 1, 12, 10, palette.shirt);
  drawPixelRect(ctx, -5, torsoY + 3, 10, 6, palette.shirtDark);
  drawPixelRect(ctx, -2, torsoY + 1, 4, 3, palette.accent);

  // arms
  if (pose === 'speaking') {
    drawPixelRect(ctx, -11, torsoY + 2, 5, 3, outline);
    drawPixelRect(ctx, -10, torsoY + 3, 4, 2, palette.skin);
    drawPixelRect(ctx, 6, torsoY, 4, 3, outline);
    drawPixelRect(ctx, 7, torsoY + 1, 3, 2, palette.skin);
  } else if (pose === 'thinking' || pose === 'tool') {
    drawPixelRect(ctx, -11, torsoY + 4, 5, 3, outline);
    drawPixelRect(ctx, -10, torsoY + 5, 4, 2, palette.skin);
    drawPixelRect(ctx, 6, torsoY + 2, 5, 3, outline);
    drawPixelRect(ctx, 7, torsoY + 3, 4, 2, palette.skin);
  } else if (pose === 'error') {
    drawPixelRect(ctx, -11, torsoY + 5, 4, 3, outline);
    drawPixelRect(ctx, 6, torsoY + 5, 4, 3, outline);
  } else {
    drawPixelRect(ctx, -11, torsoY + 3, 4, 3, outline);
    drawPixelRect(ctx, 6, torsoY + 3, 4, 3, outline);
    drawPixelRect(ctx, -10, torsoY + 4, 3, 2, palette.skin);
    drawPixelRect(ctx, 7, torsoY + 4, 3, 2, palette.skin);
  }

  // head + hair
  drawPixelRect(ctx, -7, -36, 14, 11, outline);
  drawPixelRect(ctx, -6, -35, 12, 9, palette.skin);
  drawPixelRect(ctx, -7, -38, 14, 4, palette.hair);
  drawPixelRect(ctx, -6, -37, 12, 2, palette.hair);
  drawPixelRect(ctx, 2, -34, 3, 3, outline);
  drawPixelRect(ctx, -4, -34, 2, 2, blink ? palette.hair : palette.light);
  drawPixelRect(ctx, 1, -34, 2, 2, blink ? palette.hair : palette.light);
  drawPixelRect(ctx, -3, -30, 6, 2, outline);

  if (pose === 'error') {
    drawPixelRect(ctx, -4, -33, 2, 2, palette.accent);
    drawPixelRect(ctx, 2, -33, 2, 2, palette.accent);
    drawPixelRect(ctx, -3, -31, 6, 1, palette.accent);
  }

  if (pose === 'tool') {
    drawPixelRect(ctx, 9, -20, 5, 2, palette.accent);
    drawPixelRect(ctx, 12, -21, 2, 6, palette.accent);
  }

  if (member.active) {
    ctx.strokeStyle = 'rgba(110,231,255,.65)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(0, -18, 16, 0, Math.PI * 2);
    ctx.stroke();
  }

  if (member.bubble && now < member.bubbleUntil) {
    ctx.restore();
    drawSpeechBubble(ctx, member.x, member.y - 18 + member.bob, member.bubble);
    ctx.save();
    ctx.translate(x, y);
    ctx.scale(member.face || 1, 1);
  }

  ctx.fillStyle = member.selected ? '#6ee7ff' : '#8aa1c0';
  ctx.font = '10px monospace';
  ctx.fillText(member.name, -18, 22);

  ctx.restore();
}

function updateCrew(now) {
  const w = $('officeCanvas').clientWidth || OFFICE.baseWidth;
  const h = $('officeCanvas').clientHeight || OFFICE.baseHeight;
  const floorTop = h * 0.56;
  const deskX = w * 0.41;
  const deskY = h * 0.68;
  const deskW = w * 0.22;
  const targetZone = {
    x: deskX + deskW * 0.50,
    y: deskY - 18,
  };

  for (const member of state.crew) {
    member.selected = member.id === state.selectedAgentId;
    member.active = member.selected;
    member.bob = Math.sin(now / 260 + hashString(member.id) * 0.001) * 1.1;

    if (member.mood === 'speaking') {
      member.tx = targetZone.x;
      member.ty = targetZone.y;
      member.face = member.x <= targetZone.x ? 1 : -1;
    }

    if (member.mood === 'thinking' || member.mood === 'tool') {
      member.tx = targetZone.x - 40 + (hashString(member.id) % 80);
      member.ty = targetZone.y + 10 + ((hashString(member.id) >> 4) % 30);
      member.face = member.x <= member.tx ? 1 : -1;
    }

    if (member.mood === 'idle' && now > member.wanderUntil) {
      const wanderPoints = [
        { x: w * 0.16, y: floorTop + 55 },
        { x: w * 0.30, y: floorTop + 35 },
        { x: w * 0.60, y: floorTop + 40 },
        { x: w * 0.78, y: floorTop + 50 },
        { x: w * 0.50, y: floorTop + 60 },
      ];
      const point = wanderPoints[(hashString(member.id) + Math.floor(now / 5000)) % wanderPoints.length];
      member.tx = point.x;
      member.ty = point.y;
      member.wanderUntil = now + 3500 + (hashString(member.id) % 2500);
      member.face = member.x <= member.tx ? 1 : -1;
    }

    const dx = member.tx - member.x;
    const dy = member.ty - member.y;
    const dist = Math.hypot(dx, dy);

    if (member.mood === 'error') {
      member.x += Math.sin(now / 60 + hashString(member.id)) * 0.12;
      member.y += Math.cos(now / 54 + hashString(member.id)) * 0.10;
      continue;
    }

    if (dist > 1.5) {
      const speed = member.speed * (member.mood === 'thinking' ? 0.6 : member.mood === 'tool' ? 0.85 : 1);
      const step = Math.min(speed * 0.016, dist);
      member.x += (dx / dist) * step;
      member.y += (dy / dist) * step;
      member.face = dx >= 0 ? 1 : -1;
      member.walkFrame = Math.floor(now / 170) % 2;
    } else {
      member.x = member.tx;
      member.y = member.ty;
      member.walkFrame = 0;
      if (member.mood === 'thinking' || member.mood === 'tool') {
        member.mood = 'idle';
        member.bubble = '';
      }
      if (member.mood === 'speaking' && now - member.lastTalkAt > 2400) {
        member.mood = 'idle';
        member.bubble = '';
      }
    }
  }
}

function drawOffice(now) {
  const canvas = $('officeCanvas');
  const ctx = canvas.getContext('2d');
  const w = canvas.clientWidth || OFFICE.baseWidth;
  const h = canvas.clientHeight || OFFICE.baseHeight;

  ctx.save();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.scale((canvas.width / canvas.clientWidth) || 1, (canvas.height / canvas.clientHeight) || 1);
  ctx.imageSmoothingEnabled = false;

  drawOfficeBackground(ctx, w, h, now);
  updateCrew(now);

  // desk chair glow & monitors for ambience
  const glow = 0.35 + Math.sin(now / 420) * 0.08;
  ctx.fillStyle = `rgba(110,231,255,${glow})`;
  ctx.fillRect(w * 0.47, h * 0.54, 52, 4);

  for (const member of state.crew) {
    drawLittlePerson(ctx, member, now);
  }

  ctx.restore();
}

function animate(now) {
  drawOffice(now);
  requestAnimationFrame(animate);
}

function initEvents() {
  $('chatForm').addEventListener('submit', sendMessage);
  $('newSessionBtn').addEventListener('click', newSession);
  $('reloadBtn').addEventListener('click', loadAgents);
  $('agentSelect').addEventListener('change', () => {
    setSelectedAgent($('agentSelect').value);
  });
  $('messageInput').addEventListener('keydown', (event) => {
    if (event.ctrlKey && event.key === 'Enter') sendMessage(event);
  });
  window.addEventListener('resize', resizeOfficeCanvas);
}

async function init() {
  initEvents();
  resizeOfficeCanvas();
  await loadAgents();
  if (!state.selectedAgentId && state.crew[0]) {
    setSelectedAgent(state.crew[0].id);
  } else if (state.selectedAgentId) {
    setSelectedAgent(state.selectedAgentId);
  }
  if (state.sessionId) await openSession(state.sessionId);
  else await newSession();
  await loadSessions();
  setOfficeCaption('Le bureau est prêt.');
  requestAnimationFrame(animate);
}

init().catch(async (error) => {
  console.error(error);
  setStatus('error');
  setOfficeCaption('Erreur de démarrage de l’interface.');
});


cat /mnt/DATA/WORK/LAM_Audio2Expression/app_lam_audio2exp.py | head -100

Read LAM app to understand inference API


"""
Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
import base64

import gradio as gr
import argparse
from omegaconf import OmegaConf
from gradio_gaussian_render import gaussian_render

from engines.defaults import (
    default_argument_parser,
    default_config_parser,
    default_setup,
)
from engines.infer import INFER
from pathlib import Path

try:
    import spaces
except:
    pass

import patoolib

h5_rendering = True

def assert_input_image(input_image,input_zip_textbox):
    if(os.path.exists(input_zip_textbox)):
        return
    if input_image is None:
        raise gr.Error('No image selected or uploaded!')

def prepare_working_dir():
    import tempfile
    working_dir = tempfile.TemporaryDirectory()
    return working_dir

def get_image_base64(path):
    with open(path, 'rb') as image_file:
        encoded_string = base64.b64encode(image_file.read()).decode()
    return f'data:image/png;base64,{encoded_string}'

def do_render():
    print('WebGL rendering ....')
    return

def audio_loading():
    print("Audio loading ....")
    return "None"

def parse_configs():
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", type=str)
    parser.add_argument("--infer", type=str)
    args, unknown = parser.parse_known_args()

    cfg = OmegaConf.create()
    cli_cfg = OmegaConf.from_cli(unknown)

    # parse from ENV
    if os.environ.get("APP_INFER") is not None:
        args.infer = os.environ.get("APP_INFER")
    if os.environ.get("APP_MODEL_NAME") is not None:
        cli_cfg.model_name = os.environ.get("APP_MODEL_NAME")

    args.config = args.infer if args.config is None else args.config

    if args.config is not None:
        cfg_train = OmegaConf.load(args.config)
        cfg.source_size = cfg_train.dataset.source_image_res
        try:
            cfg.src_head_size = cfg_train.dataset.src_head_size
        except:
            cfg.src_head_size = 112
        cfg.render_size = cfg_train.dataset.render_image.high
        _relative_path = os.path.join(
            cfg_train.experiment.parent,
            cfg_train.experiment.child,
            os.path.basename(cli_cfg.model_name).split("_")[-1],
        )

        cfg.save_tmp_dump = os.path.join("exps", "save_tmp", _relative_path)
        cfg.image_dump = os.path.join("exps", "images", _relative_path)


Je vais examiner l'API d'inférence A2E et les options TTS disponibles avant d'implémenter.