vfven commited on
Commit
877c670
Β·
verified Β·
1 Parent(s): fb5356b

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +1 -1
  2. README.md +16 -9
  3. main.py +472 -148
  4. requirements.txt +2 -0
  5. templates/index.html +698 -1006
Dockerfile CHANGED
@@ -5,7 +5,7 @@ WORKDIR /app
5
  COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt
7
 
8
- RUN mkdir -p templates
9
 
10
  COPY templates/ templates/
11
  COPY main.py .
 
5
  COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt
7
 
8
+ RUN mkdir -p templates docs
9
 
10
  COPY templates/ templates/
11
  COPY main.py .
README.md CHANGED
@@ -7,18 +7,25 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Mission Control AI
11
 
12
- Full-stack agent orchestration β€” FastAPI backend with direct LLM calls + pixel office frontend.
13
 
14
- ## Setup
15
 
16
- Add these secrets in your Space settings (Settings β†’ Variables and secrets):
17
-
18
- - `GOOGLE_API_KEY` β€” Google AI Studio key
19
- - `OPENROUTER_API_KEY` β€” OpenRouter key
20
- - `GROQ_API_KEY` β€” Groq key (substitute/fallback)
 
 
21
 
22
  ## Health check
 
23
 
24
- Visit `/api/health` to verify all providers are configured.
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # Mission Control AI v3
11
 
12
+ Dynamic agent orchestration with document generation.
13
 
14
+ ## Secrets (Settings β†’ Variables and secrets)
15
 
16
+ | Secret | Required | Source |
17
+ |--------|----------|--------|
18
+ | `GOOGLE_API_KEY` | Yes | aistudio.google.com |
19
+ | `OPENROUTER_API_KEY` | Yes | openrouter.ai |
20
+ | `GROQ_API_KEY` | Yes | console.groq.com |
21
+ | `PEXELS_API_KEY` | Optional | pexels.com/api (free) |
22
+ | `HF_TOKEN` | Optional | huggingface.co/settings/tokens |
23
 
24
  ## Health check
25
+ Visit `/api/health` to verify all providers.
26
 
27
+ ## Features
28
+ - Manager dynamically delegates to Writer, ImageAgent, Analyst
29
+ - Word document (.docx) generation with images
30
+ - Add custom agents from the UI
31
+ - Groq as automatic fallback when other providers fail
main.py CHANGED
@@ -1,31 +1,42 @@
1
  import os
2
  import asyncio
3
  import httpx
 
 
 
 
 
4
  from fastapi import FastAPI, Request
5
- from fastapi.responses import HTMLResponse, JSONResponse
6
  from fastapi.middleware.cors import CORSMiddleware
7
  from datetime import datetime
8
  from pathlib import Path
9
 
 
 
 
 
 
 
 
10
  app = FastAPI()
11
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
12
 
13
- # ── ENV KEYS (set in HuggingFace Space Secrets) ──────────────────────────
14
- GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "")
15
  OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
16
- GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
 
 
 
 
 
17
 
18
- # ── PROVIDERS ─────────────────────────────────────────────────────────────
19
  PROVIDERS = {
20
  "gemini": {
21
- "name": "Google Gemini",
22
- "type": "gemini",
23
- "key": GOOGLE_API_KEY,
24
  },
25
  "openrouter": {
26
- "name": "OpenRouter",
27
- "type": "openai_compat",
28
- "key": OPENROUTER_API_KEY,
29
  "base_url": "https://openrouter.ai/api/v1/chat/completions",
30
  "headers": {
31
  "HTTP-Referer": "https://huggingface.co/spaces/vfven/mission-control-ui",
@@ -33,165 +44,307 @@ PROVIDERS = {
33
  },
34
  },
35
  "groq": {
36
- "name": "Groq",
37
- "type": "openai_compat",
38
- "key": GROQ_API_KEY,
39
  "base_url": "https://api.groq.com/openai/v1/chat/completions",
40
  "headers": {},
41
  },
42
  }
43
 
44
- # ── AGENTS ────────────────────────────────────────────────────────────────
45
- AGENTS = [
46
  {
47
- "key": "manager",
48
- "name": "Manager",
49
- "role": "Gerente de proyecto experto en coordinar equipos y planificar estrategias.",
50
- "provider": "gemini",
51
- "models": [
52
- "gemini-2.5-flash-preview-04-17",
53
- "gemini-2.0-flash",
54
- "gemini-1.5-flash",
55
- ],
56
  },
57
  {
58
- "key": "developer",
59
- "name": "Developer",
60
  "role": "Programador senior especialista en crear aplicaciones y soluciones tΓ©cnicas.",
61
- "provider": "openrouter",
62
- "models": [
63
- "qwen/qwen3-4b:free",
64
- "meta-llama/llama-3.3-70b-instruct:free",
65
- "mistralai/mistral-small-3.1-24b-instruct:free",
66
- "google/gemma-3-12b-it:free",
67
- "qwen/qwen-2.5-72b-instruct:free",
68
- ],
 
 
 
69
  },
70
  {
71
- "key": "analyst",
72
- "name": "Analyst",
73
- "role": "Analista de negocios experto en evaluar viabilidad, riesgos y oportunidades.",
74
- "provider": "openrouter",
75
- "models": [
76
- "meta-llama/llama-3.3-70b-instruct:free",
77
- "mistralai/mistral-small-3.1-24b-instruct:free",
78
- "google/gemma-3-27b-it:free",
79
- "qwen/qwen3-4b:free",
80
- "google/gemma-3-12b-it:free",
81
- ],
82
  },
83
  ]
84
 
85
- # Groq substitute β€” used when primary provider fails
86
- SUBSTITUTE = {
87
- "provider": "groq",
88
- "models": [
89
- "llama-3.3-70b-versatile",
90
- "llama3-70b-8192",
91
- "gemma2-9b-it",
92
- "mixtral-8x7b-32768",
93
- ],
94
  }
95
 
 
 
96
  mission_history = []
97
 
98
 
99
- # ── CALL GEMINI ────────────────────────────────────────────────────────────
100
  async def call_gemini(model: str, system: str, user: str, key: str) -> str:
101
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
102
  payload = {
103
  "contents": [{"role": "user", "parts": [{"text": f"{system}\n\n{user}"}]}],
104
- "generationConfig": {"maxOutputTokens": 1024, "temperature": 0.7},
105
  }
106
- async with httpx.AsyncClient(timeout=60) as client:
107
  r = await client.post(url, json=payload)
108
  r.raise_for_status()
109
- data = r.json()
110
- return data["candidates"][0]["content"]["parts"][0]["text"]
111
 
112
 
113
- # ── CALL OPENAI-COMPAT (OpenRouter / Groq) ────────────────────────────────
114
  async def call_openai_compat(base_url: str, model: str, system: str, user: str,
115
  key: str, extra_headers: dict) -> str:
116
- headers = {
117
- "Authorization": f"Bearer {key}",
118
- "Content-Type": "application/json",
119
- **extra_headers,
120
- }
121
  payload = {
122
  "model": model,
123
- "messages": [
124
- {"role": "system", "content": system},
125
- {"role": "user", "content": user},
126
- ],
127
- "max_tokens": 1024,
128
- "temperature": 0.7,
129
  }
130
- async with httpx.AsyncClient(timeout=60) as client:
131
  r = await client.post(base_url, json=payload, headers=headers)
132
  r.raise_for_status()
133
- data = r.json()
134
- return data["choices"][0]["message"]["content"]
135
-
136
-
137
- # ── RUN ONE AGENT (with model fallback + groq substitute) ─────────────────
138
- async def run_agent(agent: dict, task: str) -> dict:
139
- prov_key = agent["provider"]
140
- provider = PROVIDERS[prov_key]
141
- models = agent["models"]
142
- system = (
143
- f"Eres {agent['name']}. {agent['role']} "
144
- "Responde de forma concisa y profesional en espaΓ±ol. "
145
- "MΓ‘ximo 3 pΓ‘rrafos."
146
- )
147
 
148
- # Try each model of primary provider
 
 
 
149
  last_err = None
150
- for model in models:
 
151
  try:
152
  if provider["type"] == "gemini":
153
- text = await call_gemini(model, system, task, provider["key"])
154
  else:
155
- text = await call_openai_compat(
156
  provider["base_url"], model, system, task,
157
- provider["key"], provider.get("headers", {})
158
- )
159
- return {
160
- "status": "active",
161
- "message": text.strip(),
162
- "model": model,
163
- "provider": provider["name"],
164
- }
165
  except Exception as e:
166
  last_err = str(e)
167
- continue
168
 
169
- # Primary failed β€” try Groq substitute
170
- groq_prov = PROVIDERS["groq"]
171
- if groq_prov["key"]:
172
- for model in SUBSTITUTE["models"]:
173
  try:
174
- text = await call_openai_compat(
175
- groq_prov["base_url"], model, system, task,
176
- groq_prov["key"], {}
177
- )
178
- return {
179
- "status": "active",
180
- "message": text.strip(),
181
- "model": f"{model} (via Groq substitute)",
182
- "provider": "Groq",
183
- }
184
  except Exception as e:
185
  last_err = str(e)
186
- continue
187
 
188
- # All failed
189
- return {
190
- "status": "resting",
191
- "message": f"All providers unavailable. Last error: {last_err}",
192
- "model": "",
193
- "reason": "all_providers_failed",
194
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
 
197
  # ── ROUTES ─────────────────────────────────────────────────────────────────
@@ -202,7 +355,35 @@ async def root():
202
 
203
  @app.get("/api/agents")
204
  async def get_agents():
205
- return {"agents": [{"key": a["key"], "name": a["name"], "role": a["role"]} for a in AGENTS]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
 
208
  @app.get("/api/history")
@@ -210,6 +391,18 @@ async def get_history():
210
  return {"history": mission_history[-20:]}
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  @app.post("/api/mission")
214
  async def run_mission(request: Request):
215
  body = await request.json()
@@ -218,39 +411,168 @@ async def run_mission(request: Request):
218
  return JSONResponse({"error": "No task provided"}, status_code=400)
219
 
220
  started_at = datetime.now().isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
- # Run all agents in parallel
223
- tasks = [run_agent(agent, task) for agent in AGENTS]
224
- results_list = await asyncio.gather(*tasks, return_exceptions=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
- results = {}
227
- for agent, result in zip(AGENTS, results_list):
228
- if isinstance(result, Exception):
229
- results[agent["key"]] = {
230
- "status": "resting", "message": str(result), "model": ""
231
- }
232
  else:
233
- results[agent["key"]] = result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
- # Simple final summary from manager response
236
- manager_msg = results.get("manager", {}).get("message", "")
237
- final = manager_msg[:200] + "..." if len(manager_msg) > 200 else manager_msg
238
 
239
  entry = {
240
- "id": len(mission_history) + 1,
241
- "task": task,
242
  "started_at": started_at,
243
- "ended_at": datetime.now().isoformat(),
244
- "results": results,
245
- "final": final,
 
 
246
  }
247
  mission_history.append(entry)
248
 
249
  return JSONResponse({
250
- "success": True,
251
- "task": task,
252
- "results": results,
253
- "final": final,
254
  "mission_id": entry["id"],
255
  })
256
 
@@ -260,8 +582,10 @@ async def health():
260
  return {
261
  "status": "ok",
262
  "providers": {
263
- "gemini": "configured" if GOOGLE_API_KEY else "missing key",
264
- "openrouter": "configured" if OPENROUTER_API_KEY else "missing key",
265
- "groq": "configured" if GROQ_API_KEY else "missing key",
 
 
266
  }
267
  }
 
1
  import os
2
  import asyncio
3
  import httpx
4
+ import json
5
+ import re
6
+ import uuid
7
+ import base64
8
+ from io import BytesIO
9
  from fastapi import FastAPI, Request
10
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from datetime import datetime
13
  from pathlib import Path
14
 
15
+ # python-docx imports
16
+ from docx import Document as DocxDocument
17
+ from docx.shared import Inches, Pt, RGBColor
18
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
19
+ from docx.oxml.ns import qn
20
+ from docx.oxml import OxmlElement
21
+
22
  app = FastAPI()
23
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
24
 
25
+ GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "")
 
26
  OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
27
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
28
+ PEXELS_API_KEY = os.getenv("PEXELS_API_KEY", "") # free at pexels.com/api
29
+ HF_API_KEY = os.getenv("HF_TOKEN", "") # HuggingFace token for image gen
30
+
31
+ DOCS_DIR = Path("docs")
32
+ DOCS_DIR.mkdir(exist_ok=True)
33
 
 
34
  PROVIDERS = {
35
  "gemini": {
36
+ "name": "Google Gemini", "type": "gemini", "key": GOOGLE_API_KEY,
 
 
37
  },
38
  "openrouter": {
39
+ "name": "OpenRouter", "type": "openai_compat", "key": OPENROUTER_API_KEY,
 
 
40
  "base_url": "https://openrouter.ai/api/v1/chat/completions",
41
  "headers": {
42
  "HTTP-Referer": "https://huggingface.co/spaces/vfven/mission-control-ui",
 
44
  },
45
  },
46
  "groq": {
47
+ "name": "Groq", "type": "openai_compat", "key": GROQ_API_KEY,
 
 
48
  "base_url": "https://api.groq.com/openai/v1/chat/completions",
49
  "headers": {},
50
  },
51
  }
52
 
53
+ # ── DEFAULT AGENTS (can be extended via UI) ───────────────────────────────
54
+ DEFAULT_AGENTS = [
55
  {
56
+ "key": "manager", "name": "Manager", "provider": "gemini",
57
+ "role": "Gerente de proyecto experto en coordinar equipos y planificar estrategias. Cuando el usuario pide un documento o informe, DEBES indicar en tu respuesta quΓ© agentes necesitas activar usando el formato JSON: {\"delegate\": [\"writer\", \"analyst\"]} al final de tu respuesta.",
58
+ "models": ["gemini-2.5-flash-preview-04-17", "gemini-2.0-flash", "gemini-1.5-flash"],
 
 
 
 
 
 
59
  },
60
  {
61
+ "key": "developer", "name": "Developer", "provider": "openrouter",
 
62
  "role": "Programador senior especialista en crear aplicaciones y soluciones tΓ©cnicas.",
63
+ "models": ["qwen/qwen3-4b:free", "meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-small-3.1-24b-instruct:free", "google/gemma-3-12b-it:free"],
64
+ },
65
+ {
66
+ "key": "analyst", "name": "Analyst", "provider": "openrouter",
67
+ "role": "Analista de negocios experto en evaluar viabilidad, riesgos y oportunidades. TambiΓ©n revisa y critica documentos formales.",
68
+ "models": ["meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-small-3.1-24b-instruct:free", "google/gemma-3-27b-it:free", "qwen/qwen3-4b:free"],
69
+ },
70
+ {
71
+ "key": "writer", "name": "Writer", "provider": "openrouter",
72
+ "role": "Especialista en redacciΓ³n de documentos formales, informes ejecutivos y reportes tΓ©cnicos. Escribe en formato estructurado con secciones claras usando ### para tΓ­tulos y ** para subtΓ­tulos.",
73
+ "models": ["meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-small-3.1-24b-instruct:free", "qwen/qwen3-4b:free", "google/gemma-3-12b-it:free"],
74
  },
75
  {
76
+ "key": "image_agent", "name": "ImageAgent", "provider": "gemini",
77
+ "role": "Agente especializado en buscar y proveer imΓ‘genes relevantes. Cuando se te pida imΓ‘genes sobre un tema, responde con una lista JSON de tΓ©rminos de bΓΊsqueda en inglΓ©s: {\"image_queries\": [\"term1\", \"term2\", \"term3\"]}",
78
+ "models": ["gemini-2.0-flash", "gemini-1.5-flash"],
 
 
 
 
 
 
 
 
79
  },
80
  ]
81
 
82
+ SUBSTITUTE_MODELS = {
83
+ "groq": ["llama-3.3-70b-versatile", "llama3-70b-8192", "gemma2-9b-it"],
 
 
 
 
 
 
 
84
  }
85
 
86
+ # Runtime agent registry (can be extended)
87
+ agent_registry = {a["key"]: dict(a) for a in DEFAULT_AGENTS}
88
  mission_history = []
89
 
90
 
91
+ # ── LLM CALLERS ───────────────────────────────────────────────────────────
92
  async def call_gemini(model: str, system: str, user: str, key: str) -> str:
93
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
94
  payload = {
95
  "contents": [{"role": "user", "parts": [{"text": f"{system}\n\n{user}"}]}],
96
+ "generationConfig": {"maxOutputTokens": 2048, "temperature": 0.7},
97
  }
98
+ async with httpx.AsyncClient(timeout=90) as client:
99
  r = await client.post(url, json=payload)
100
  r.raise_for_status()
101
+ return r.json()["candidates"][0]["content"]["parts"][0]["text"]
 
102
 
103
 
 
104
  async def call_openai_compat(base_url: str, model: str, system: str, user: str,
105
  key: str, extra_headers: dict) -> str:
106
+ headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", **extra_headers}
 
 
 
 
107
  payload = {
108
  "model": model,
109
+ "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
110
+ "max_tokens": 2048, "temperature": 0.7,
 
 
 
 
111
  }
112
+ async with httpx.AsyncClient(timeout=90) as client:
113
  r = await client.post(base_url, json=payload, headers=headers)
114
  r.raise_for_status()
115
+ return r.json()["choices"][0]["message"]["content"]
116
+
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ async def call_agent_llm(agent: dict, task: str) -> str:
119
+ prov_key = agent["provider"]
120
+ provider = PROVIDERS[prov_key]
121
+ system = f"Eres {agent['name']}. {agent['role']} Responde en espaΓ±ol. SΓ© conciso y profesional."
122
  last_err = None
123
+
124
+ for model in agent["models"]:
125
  try:
126
  if provider["type"] == "gemini":
127
+ return await call_gemini(model, system, task, provider["key"])
128
  else:
129
+ return await call_openai_compat(
130
  provider["base_url"], model, system, task,
131
+ provider["key"], provider.get("headers", {}))
 
 
 
 
 
 
 
132
  except Exception as e:
133
  last_err = str(e)
 
134
 
135
+ # Groq fallback
136
+ if GROQ_API_KEY:
137
+ groq = PROVIDERS["groq"]
138
+ for m in SUBSTITUTE_MODELS["groq"]:
139
  try:
140
+ return await call_openai_compat(
141
+ groq["base_url"], m, system, task, groq["key"], {})
 
 
 
 
 
 
 
 
142
  except Exception as e:
143
  last_err = str(e)
 
144
 
145
+ raise Exception(f"All providers failed: {last_err}")
146
+
147
+
148
+ # ── IMAGE FETCHING ─────────────────────────────────────────────────────────
149
+ async def fetch_pexels_image(query: str) -> bytes | None:
150
+ if not PEXELS_API_KEY:
151
+ return None
152
+ try:
153
+ async with httpx.AsyncClient(timeout=20) as client:
154
+ r = await client.get(
155
+ "https://api.pexels.com/v1/search",
156
+ params={"query": query, "per_page": 1, "orientation": "landscape"},
157
+ headers={"Authorization": PEXELS_API_KEY}
158
+ )
159
+ data = r.json()
160
+ if data.get("photos"):
161
+ img_url = data["photos"][0]["src"]["medium"]
162
+ img_r = await client.get(img_url)
163
+ return img_r.content
164
+ except Exception:
165
+ pass
166
+ return None
167
+
168
+
169
+ async def generate_hf_image(prompt: str) -> bytes | None:
170
+ if not HF_API_KEY:
171
+ return None
172
+ try:
173
+ async with httpx.AsyncClient(timeout=60) as client:
174
+ r = await client.post(
175
+ "https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0",
176
+ headers={"Authorization": f"Bearer {HF_API_KEY}"},
177
+ json={"inputs": prompt, "parameters": {"width": 512, "height": 384}},
178
+ )
179
+ if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"):
180
+ return r.content
181
+ except Exception:
182
+ pass
183
+ return None
184
+
185
+
186
+ async def get_image_for_query(query: str) -> bytes | None:
187
+ img = await fetch_pexels_image(query)
188
+ if img:
189
+ return img
190
+ return await generate_hf_image(query)
191
+
192
+
193
+ # ── DOCX BUILDER ──────────────────────────────────────────────────────────
194
+ def build_docx(title: str, sections: dict, images: list[bytes], analyst_review: str) -> bytes:
195
+ doc = DocxDocument()
196
+
197
+ # Page margins
198
+ for section in doc.sections:
199
+ section.top_margin = Inches(1)
200
+ section.bottom_margin = Inches(1)
201
+ section.left_margin = Inches(1.2)
202
+ section.right_margin = Inches(1.2)
203
+
204
+ # Title
205
+ title_para = doc.add_heading(title, 0)
206
+ title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
207
+ run = title_para.runs[0]
208
+ run.font.color.rgb = RGBColor(0x1a, 0x56, 0xdb)
209
+
210
+ # Date + subtitle
211
+ sub = doc.add_paragraph()
212
+ sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
213
+ sub.add_run(f"Mission Control AI β€” {datetime.now().strftime('%B %d, %Y')}").italic = True
214
+ doc.add_paragraph()
215
+
216
+ # Horizontal rule
217
+ p = doc.add_paragraph()
218
+ pPr = p._p.get_or_add_pPr()
219
+ pBdr = OxmlElement("w:pBdr")
220
+ bottom = OxmlElement("w:bottom")
221
+ bottom.set(qn("w:val"), "single")
222
+ bottom.set(qn("w:sz"), "6")
223
+ bottom.set(qn("w:color"), "1a56db")
224
+ pBdr.append(bottom)
225
+ pPr.append(pBdr)
226
+
227
+ img_index = 0
228
+
229
+ # Writer content sections
230
+ writer_text = sections.get("writer", "")
231
+ current_lines = []
232
+
233
+ def flush_lines():
234
+ nonlocal current_lines
235
+ if current_lines:
236
+ para_text = " ".join(current_lines).strip()
237
+ if para_text:
238
+ p = doc.add_paragraph(para_text)
239
+ p.paragraph_format.space_after = Pt(6)
240
+ current_lines = []
241
+
242
+ for line in writer_text.split("\n"):
243
+ stripped = line.strip()
244
+ if not stripped:
245
+ flush_lines()
246
+ continue
247
+ if stripped.startswith("### "):
248
+ flush_lines()
249
+ h = doc.add_heading(stripped[4:], level=2)
250
+ # Insert image after each major section heading if available
251
+ if img_index < len(images) and images[img_index]:
252
+ try:
253
+ img_stream = BytesIO(images[img_index])
254
+ doc.add_picture(img_stream, width=Inches(5))
255
+ last_para = doc.paragraphs[-1]
256
+ last_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
257
+ cap = doc.add_paragraph(f"Figure {img_index + 1}")
258
+ cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
259
+ cap.runs[0].italic = True
260
+ cap.runs[0].font.size = Pt(9)
261
+ img_index += 1
262
+ except Exception:
263
+ pass
264
+ elif stripped.startswith("## "):
265
+ flush_lines()
266
+ doc.add_heading(stripped[3:], level=1)
267
+ elif stripped.startswith("**") and stripped.endswith("**"):
268
+ flush_lines()
269
+ p = doc.add_paragraph()
270
+ run = p.add_run(stripped.strip("**"))
271
+ run.bold = True
272
+ elif stripped.startswith("- ") or stripped.startswith("* "):
273
+ flush_lines()
274
+ doc.add_paragraph(stripped[2:], style="List Bullet")
275
+ else:
276
+ current_lines.append(stripped)
277
+
278
+ flush_lines()
279
+
280
+ # Remaining images
281
+ while img_index < len(images):
282
+ if images[img_index]:
283
+ try:
284
+ img_stream = BytesIO(images[img_index])
285
+ doc.add_picture(img_stream, width=Inches(5))
286
+ last_para = doc.paragraphs[-1]
287
+ last_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
288
+ img_index += 1
289
+ except Exception:
290
+ img_index += 1
291
+ else:
292
+ img_index += 1
293
+
294
+ # Analyst review section
295
+ if analyst_review:
296
+ doc.add_page_break()
297
+ doc.add_heading("AnΓ‘lisis y RevisiΓ³n", level=1)
298
+ for line in analyst_review.split("\n"):
299
+ if line.strip():
300
+ doc.add_paragraph(line.strip())
301
+
302
+ # Footer
303
+ doc.add_paragraph()
304
+ footer_p = doc.add_paragraph()
305
+ footer_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
306
+ footer_run = footer_p.add_run("β€” Generado por Mission Control AI β€”")
307
+ footer_run.italic = True
308
+ footer_run.font.size = Pt(9)
309
+ footer_run.font.color.rgb = RGBColor(0x6b, 0x72, 0x80)
310
+
311
+ buf = BytesIO()
312
+ doc.save(buf)
313
+ buf.seek(0)
314
+ return buf.read()
315
+
316
+
317
+ # ── PARSE MANAGER DELEGATION ───────────────────────────────────────────────
318
+ def parse_delegation(manager_text: str) -> list[str]:
319
+ match = re.search(r'\{[^}]*"delegate"\s*:\s*\[([^\]]*)\][^}]*\}', manager_text)
320
+ if match:
321
+ raw = match.group(1)
322
+ keys = re.findall(r'"(\w+)"', raw)
323
+ return keys
324
+ # Heuristic fallback: detect keywords in manager response
325
+ delegates = []
326
+ lower = manager_text.lower()
327
+ if any(w in lower for w in ["informe", "documento", "redact", "escrib", "report", "word"]):
328
+ delegates.extend(["writer"])
329
+ if any(w in lower for w in ["imagen", "image", "foto", "visual", "ilustr"]):
330
+ delegates.extend(["image_agent"])
331
+ if any(w in lower for w in ["analiz", "revisar", "evalua", "critic"]):
332
+ delegates.extend(["analyst"])
333
+ return list(dict.fromkeys(delegates)) # deduplicate
334
+
335
+
336
+ def parse_image_queries(image_agent_text: str) -> list[str]:
337
+ match = re.search(r'"image_queries"\s*:\s*\[([^\]]*)\]', image_agent_text)
338
+ if match:
339
+ return re.findall(r'"([^"]+)"', match.group(1))
340
+ return []
341
+
342
+
343
+ def clean_text(text: str) -> str:
344
+ # Remove JSON blocks from display text
345
+ text = re.sub(r'\{[^}]*"delegate"[^}]*\}', '', text)
346
+ text = re.sub(r'\{[^}]*"image_queries"[^}]*\}', '', text)
347
+ return text.strip()
348
 
349
 
350
  # ── ROUTES ─────────────────────────────────────────────────────────────────
 
355
 
356
  @app.get("/api/agents")
357
  async def get_agents():
358
+ return {"agents": [
359
+ {"key": a["key"], "name": a["name"], "role": a["role"]}
360
+ for a in agent_registry.values()
361
+ ]}
362
+
363
+
364
+ @app.post("/api/agents/add")
365
+ async def add_agent(request: Request):
366
+ body = await request.json()
367
+ key = re.sub(r'\W+', '_', body.get("key", "").lower().strip())
368
+ if not key:
369
+ return JSONResponse({"error": "key required"}, status_code=400)
370
+ agent_registry[key] = {
371
+ "key": key,
372
+ "name": body.get("name", key.capitalize()),
373
+ "role": body.get("role", "Agente de propΓ³sito general."),
374
+ "provider": body.get("provider", "openrouter"),
375
+ "models": body.get("models", ["meta-llama/llama-3.3-70b-instruct:free"]),
376
+ }
377
+ return {"success": True, "agent": agent_registry[key]}
378
+
379
+
380
+ @app.delete("/api/agents/{key}")
381
+ async def delete_agent(key: str):
382
+ if key in ("manager", "developer", "analyst"):
383
+ return JSONResponse({"error": "Cannot delete core agents"}, status_code=400)
384
+ if key in agent_registry:
385
+ del agent_registry[key]
386
+ return {"success": True}
387
 
388
 
389
  @app.get("/api/history")
 
391
  return {"history": mission_history[-20:]}
392
 
393
 
394
+ @app.get("/api/docs/{filename}")
395
+ async def download_doc(filename: str):
396
+ path = DOCS_DIR / filename
397
+ if not path.exists() or not filename.endswith(".docx"):
398
+ return JSONResponse({"error": "File not found"}, status_code=404)
399
+ return FileResponse(
400
+ path,
401
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
402
+ filename=filename,
403
+ )
404
+
405
+
406
  @app.post("/api/mission")
407
  async def run_mission(request: Request):
408
  body = await request.json()
 
411
  return JSONResponse({"error": "No task provided"}, status_code=400)
412
 
413
  started_at = datetime.now().isoformat()
414
+ results = {}
415
+ doc_file = None
416
+ events = [] # orchestration event log
417
+
418
+ def log(msg: str):
419
+ events.append({"time": datetime.now().strftime("%H:%M:%S"), "msg": msg})
420
+
421
+ # ── STEP 1: Manager plans ──────────────────────────────────────────────
422
+ log("Manager analyzing task...")
423
+ manager_agent = agent_registry["manager"]
424
+ try:
425
+ manager_raw = await call_agent_llm(manager_agent, task)
426
+ delegates = parse_delegation(manager_raw)
427
+ manager_msg = clean_text(manager_raw)
428
+ results["manager"] = {
429
+ "status": "active", "message": manager_msg,
430
+ "model": manager_agent["models"][0],
431
+ "delegates": delegates,
432
+ }
433
+ log(f"Manager delegated to: {delegates or 'none'}")
434
+ except Exception as e:
435
+ results["manager"] = {"status": "resting", "message": str(e), "model": ""}
436
+ delegates = []
437
+ log(f"Manager failed: {e}")
438
+
439
+ # ── STEP 2: Run delegated agents ───────────────────────────────────────
440
+ image_bytes = []
441
+ writer_text = ""
442
+ analyst_text = ""
443
+
444
+ async def run_delegated(key: str):
445
+ nonlocal writer_text, analyst_text
446
+ if key not in agent_registry:
447
+ log(f"Agent '{key}' not found in registry")
448
+ return
449
+
450
+ agent = agent_registry[key]
451
+ log(f"{agent['name']} starting...")
452
+
453
+ if key == "image_agent":
454
+ try:
455
+ img_prompt = f"Proporciona consultas de bΓΊsqueda de imΓ‘genes en inglΓ©s para ilustrar: {task}"
456
+ raw = await call_agent_llm(agent, img_prompt)
457
+ queries = parse_image_queries(raw)
458
+ if not queries:
459
+ queries = [task[:40]]
460
+ log(f"ImageAgent searching: {queries}")
461
+ imgs = await asyncio.gather(*[get_image_for_query(q) for q in queries[:3]])
462
+ for img in imgs:
463
+ if img:
464
+ image_bytes.append(img)
465
+ results["image_agent"] = {
466
+ "status": "active",
467
+ "message": f"Encontradas {len(image_bytes)} imΓ‘genes para: {', '.join(queries[:3])}",
468
+ "model": agent["models"][0],
469
+ }
470
+ log(f"ImageAgent: {len(image_bytes)} images found")
471
+ except Exception as e:
472
+ results["image_agent"] = {"status": "resting", "message": str(e), "model": ""}
473
+ log(f"ImageAgent failed: {e}")
474
 
475
+ elif key == "writer":
476
+ try:
477
+ writer_prompt = (
478
+ f"Redacta un informe formal y completo sobre: {task}\n\n"
479
+ "Usa este formato:\n"
480
+ "## Resumen Ejecutivo\n[contenido]\n\n"
481
+ "### IntroducciΓ³n\n[contenido]\n\n"
482
+ "### Desarrollo\n[contenido con subsecciones]\n\n"
483
+ "### Conclusiones\n[contenido]\n\n"
484
+ "### Recomendaciones\n[contenido]\n\n"
485
+ "SΓ© formal, detallado y profesional."
486
+ )
487
+ writer_text = await call_agent_llm(agent, writer_prompt)
488
+ results["writer"] = {
489
+ "status": "active",
490
+ "message": writer_text[:200] + "..." if len(writer_text) > 200 else writer_text,
491
+ "model": agent["models"][0],
492
+ }
493
+ log("Writer completed document")
494
+ except Exception as e:
495
+ results["writer"] = {"status": "resting", "message": str(e), "model": ""}
496
+ log(f"Writer failed: {e}")
497
+
498
+ elif key == "analyst":
499
+ try:
500
+ content_to_review = writer_text or manager_msg
501
+ analyst_prompt = (
502
+ f"Revisa y analiza el siguiente contenido sobre: {task}\n\n"
503
+ f"Contenido:\n{content_to_review[:1000]}\n\n"
504
+ "Proporciona: 1) EvaluaciΓ³n de calidad, 2) Puntos fuertes, "
505
+ "3) Áreas de mejora, 4) Conclusión final."
506
+ )
507
+ analyst_text = await call_agent_llm(agent, analyst_prompt)
508
+ results["analyst"] = {
509
+ "status": "active",
510
+ "message": analyst_text[:200] + "..." if len(analyst_text) > 200 else analyst_text,
511
+ "model": agent["models"][0],
512
+ }
513
+ log("Analyst review completed")
514
+ except Exception as e:
515
+ results["analyst"] = {"status": "resting", "message": str(e), "model": ""}
516
+ log(f"Analyst failed: {e}")
517
 
 
 
 
 
 
 
518
  else:
519
+ # Generic agent
520
+ try:
521
+ raw = await call_agent_llm(agent, task)
522
+ results[key] = {"status": "active", "message": raw, "model": agent["models"][0]}
523
+ log(f"{agent['name']} completed")
524
+ except Exception as e:
525
+ results[key] = {"status": "resting", "message": str(e), "model": ""}
526
+
527
+ # Run image_agent and writer in parallel, then analyst
528
+ parallel_first = [k for k in delegates if k in ("writer", "image_agent")]
529
+ sequential_after = [k for k in delegates if k == "analyst"]
530
+ other = [k for k in delegates if k not in ("writer", "image_agent", "analyst")]
531
+
532
+ if parallel_first or other:
533
+ await asyncio.gather(*[run_delegated(k) for k in parallel_first + other])
534
+
535
+ if sequential_after:
536
+ for k in sequential_after:
537
+ await run_delegated(k)
538
+
539
+ # ── STEP 3: Build .docx if writer was involved ─────────────────────────
540
+ if writer_text:
541
+ log("Assembling Word document...")
542
+ try:
543
+ doc_bytes = build_docx(task, {"writer": writer_text}, image_bytes, analyst_text)
544
+ safe_name = re.sub(r'[^\w\-]', '_', task[:40])
545
+ doc_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx"
546
+ (DOCS_DIR / doc_filename).write_bytes(doc_bytes)
547
+ doc_file = doc_filename
548
+ results["manager"]["doc_file"] = doc_filename
549
+ log(f"Document ready: {doc_filename}")
550
+ except Exception as e:
551
+ log(f"Document build failed: {e}")
552
+
553
+ # Mark idle agents
554
+ for key in agent_registry:
555
+ if key not in results:
556
+ results[key] = {"status": "idle", "message": "", "model": ""}
557
 
558
+ final = results.get("manager", {}).get("message", "")[:300]
 
 
559
 
560
  entry = {
561
+ "id": len(mission_history) + 1,
562
+ "task": task,
563
  "started_at": started_at,
564
+ "ended_at": datetime.now().isoformat(),
565
+ "results": results,
566
+ "final": final,
567
+ "doc_file": doc_file,
568
+ "events": events,
569
  }
570
  mission_history.append(entry)
571
 
572
  return JSONResponse({
573
+ "success": True, "task": task,
574
+ "results": results, "final": final,
575
+ "doc_file": doc_file, "events": events,
 
576
  "mission_id": entry["id"],
577
  })
578
 
 
582
  return {
583
  "status": "ok",
584
  "providers": {
585
+ "gemini": "ok" if GOOGLE_API_KEY else "missing",
586
+ "openrouter": "ok" if OPENROUTER_API_KEY else "missing",
587
+ "groq": "ok" if GROQ_API_KEY else "missing",
588
+ "pexels": "ok" if PEXELS_API_KEY else "missing (optional)",
589
+ "hf_images": "ok" if HF_API_KEY else "missing (optional)",
590
  }
591
  }
requirements.txt CHANGED
@@ -2,3 +2,5 @@ fastapi==0.115.0
2
  uvicorn==0.30.6
3
  httpx==0.27.2
4
  python-multipart==0.0.9
 
 
 
2
  uvicorn==0.30.6
3
  httpx==0.27.2
4
  python-multipart==0.0.9
5
+ python-docx==1.1.2
6
+ Pillow==10.4.0
templates/index.html CHANGED
@@ -8,788 +8,509 @@
8
  @import url('https://fonts.googleapis.com/css2?family=VT323&family=IBM+Plex+Mono:wght@400;500&display=swap');
9
 
10
  :root {
11
- --floor: #2a3142;
12
- --wall-dark: #1e2535;
13
- --wall-mid: #252d3e;
14
- --wall-light: #2d3750;
15
- --carpet: #1a2040;
16
- --carpet2: #1d2347;
17
- --accent: #4a9eff;
18
- --green: #4ade80;
19
- --amber: #fbbf24;
20
- --red: #f87171;
21
- --text: #c8d4e8;
22
- --text-dim: #5a6a8a;
23
- --border: #2d3f5a;
24
- --panel: #111827;
25
- --wood: #8b6914;
26
- --wood-light: #a07820;
27
- --metal: #4a5568;
28
- --metal-light: #6b7280;
29
- --glass: rgba(74,158,255,0.15);
30
- --server-rack: #1a1f2e;
31
- }
32
-
33
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
-
35
- body {
36
- background: var(--panel);
37
- font-family: 'IBM Plex Mono', monospace;
38
- color: var(--text);
39
- min-height: 100vh;
40
- display: flex;
41
- flex-direction: column;
42
- overflow-x: hidden;
43
  }
44
 
45
  /* ── TOPBAR ── */
46
- .topbar {
47
- background: #0d1117;
48
- border-bottom: 2px solid var(--border);
49
- padding: 0 20px;
50
- height: 48px;
51
- display: flex;
52
- align-items: center;
53
- justify-content: space-between;
54
- flex-shrink: 0;
55
- position: relative;
56
- z-index: 50;
57
- }
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- .tb-logo {
60
- display: flex; align-items: center; gap: 10px;
61
- }
62
-
63
- .tb-logo-icon {
64
- width: 28px; height: 28px;
65
- background: var(--accent);
66
- display: flex; align-items: center; justify-content: center;
67
- font-family: 'VT323', monospace; font-size: 16px; color: #fff;
68
- image-rendering: pixelated;
69
- }
70
-
71
- .tb-title {
72
- font-family: 'VT323', monospace;
73
- font-size: 22px;
74
- color: var(--accent);
75
- letter-spacing: 2px;
76
- }
77
-
78
- .tb-version {
79
- font-size: 10px;
80
- color: var(--text-dim);
81
- margin-left: 4px;
82
- letter-spacing: 1px;
83
- }
84
-
85
- .tb-right {
86
- display: flex; align-items: center; gap: 20px;
87
- }
88
-
89
- .tb-stat {
90
- font-size: 11px;
91
- color: var(--text-dim);
92
- display: flex; align-items: center; gap: 5px;
93
- }
94
-
95
- .tb-stat .dot {
96
- width: 6px; height: 6px; border-radius: 50%;
97
- }
98
-
99
- .dot-green { background: var(--green); animation: pulse-dot 2s ease infinite; }
100
- .dot-amber { background: var(--amber); animation: pulse-dot 1.2s ease infinite; }
101
- .dot-red { background: var(--red); animation: blink-dot .5s step-end infinite; }
102
- .dot-dim { background: var(--text-dim); }
103
-
104
- @keyframes pulse-dot {
105
- 0%,100% { opacity:1; box-shadow:0 0 0 0 currentColor; }
106
- 50% { box-shadow: 0 0 0 4px transparent; }
107
- }
108
- @keyframes blink-dot { 0%,100%{opacity:1} 50%{opacity:0} }
109
-
110
- .clock {
111
- font-family: 'VT323', monospace;
112
- font-size: 20px;
113
- color: var(--accent);
114
- letter-spacing: 2px;
115
  }
 
116
 
117
  /* ── MISSION BAR ── */
118
- .mission-bar {
119
- background: #0d1117;
120
- border-bottom: 1px solid var(--border);
121
- padding: 10px 20px;
122
- display: flex;
123
- gap: 10px;
124
- align-items: center;
125
- flex-shrink: 0;
126
- }
127
-
128
- .mission-input {
129
- flex: 1;
130
- height: 36px;
131
- background: var(--wall-dark);
132
- border: 1px solid var(--border);
133
- color: var(--text);
134
- font-family: 'IBM Plex Mono', monospace;
135
- font-size: 13px;
136
- padding: 0 12px;
137
- outline: none;
138
- transition: border-color .15s;
139
- }
140
-
141
- .mission-input:focus { border-color: var(--accent); }
142
- .mission-input::placeholder { color: var(--text-dim); }
143
-
144
- .launch-btn {
145
- height: 36px;
146
- padding: 0 18px;
147
- background: var(--accent);
148
- border: none;
149
- color: #fff;
150
- font-family: 'VT323', monospace;
151
- font-size: 18px;
152
- letter-spacing: 2px;
153
- cursor: pointer;
154
- transition: background .15s, transform .1s;
155
- display: flex; align-items: center; gap: 6px;
156
- }
157
-
158
- .launch-btn:hover { background: #3a8eef; }
159
- .launch-btn:active { transform: scale(.97); }
160
- .launch-btn:disabled { background: #2a4a6a; cursor: not-allowed; }
161
-
162
- .mission-count {
163
- font-family: 'VT323', monospace;
164
- font-size: 16px;
165
- color: var(--text-dim);
166
- white-space: nowrap;
167
- }
168
-
169
- /* ── PROGRESS ── */
170
- .progress-strip {
171
- height: 2px;
172
- background: var(--wall-dark);
173
- overflow: hidden;
174
- display: none;
175
- }
176
- .progress-strip.active { display: block; }
177
- .progress-strip::after {
178
- content: '';
179
- display: block;
180
- height: 100%;
181
- width: 30%;
182
- background: var(--accent);
183
- animation: progress-run 1.4s ease-in-out infinite;
184
- }
185
- @keyframes progress-run { 0%{margin-left:-30%} 100%{margin-left:110%} }
186
 
187
  /* ── MAIN LAYOUT ── */
188
- .main {
189
- display: grid;
190
- grid-template-columns: 1fr 260px;
191
- flex: 1;
192
- min-height: 0;
193
- overflow: hidden;
194
- }
195
-
196
- @media(max-width:900px) {
197
- .main { grid-template-columns: 1fr; }
198
- .sidebar { display: none; }
199
- }
200
 
201
- /* ── OFFICE FLOOR ── */
202
- .office-floor {
203
- background: var(--floor);
204
- overflow-y: auto;
205
- overflow-x: hidden;
206
- padding: 16px;
207
- position: relative;
208
  }
209
-
210
- /* Floor tile pattern */
211
- .office-floor::before {
212
- content: '';
213
- position: fixed;
214
- inset: 0;
215
  background-image:
216
- linear-gradient(var(--carpet2) 1px, transparent 1px),
217
- linear-gradient(90deg, var(--carpet2) 1px, transparent 1px);
218
- background-size: 32px 32px;
219
- opacity: .25;
220
- pointer-events: none;
221
  }
222
-
223
- .floor-label {
224
- font-family: 'VT323', monospace;
225
- font-size: 14px;
226
- color: var(--text-dim);
227
- letter-spacing: 3px;
228
- margin-bottom: 14px;
229
- text-transform: uppercase;
230
  }
231
 
232
  /* ── OFFICE GRID ── */
233
- .office-grid {
234
- display: grid;
235
- grid-template-columns: repeat(3, 1fr);
236
- gap: 12px;
237
- position: relative;
238
- z-index: 1;
239
- }
240
-
241
- @media(max-width:1100px) { .office-grid { grid-template-columns: repeat(2,1fr); } }
242
- @media(max-width:700px) { .office-grid { grid-template-columns: 1fr; } }
243
-
244
- /* ── ROOM CARD ── */
245
- .room {
246
- background: var(--wall-mid);
247
- border: 2px solid var(--border);
248
- position: relative;
249
- overflow: hidden;
250
- transition: border-color .3s;
251
- min-height: 240px;
252
- display: flex;
253
- flex-direction: column;
254
- }
255
-
256
- .room.active { border-color: var(--accent); }
257
- .room.resting { border-color: var(--amber); }
258
- .room.done { border-color: var(--green); }
259
-
260
- /* Room wall top */
261
- .room-wall {
262
- background: var(--wall-light);
263
- height: 32px;
264
- border-bottom: 2px solid var(--border);
265
- display: flex;
266
- align-items: center;
267
- justify-content: space-between;
268
- padding: 0 10px;
269
- flex-shrink: 0;
270
- }
271
-
272
- .room-name {
273
- font-family: 'VT323', monospace;
274
- font-size: 16px;
275
- letter-spacing: 2px;
276
- color: var(--text);
277
- }
278
-
279
- .room-badge {
280
- font-family: 'VT323', monospace;
281
- font-size: 13px;
282
- padding: 1px 8px;
283
- letter-spacing: 1px;
284
- }
285
-
286
- .badge-idle { color: var(--text-dim); border: 1px solid var(--text-dim); }
287
- .badge-active { color: var(--accent); border: 1px solid var(--accent); animation: badge-pulse 1s ease infinite; }
288
- .badge-done { color: var(--green); border: 1px solid var(--green); background: rgba(74,222,128,.1); }
289
- .badge-resting { color: var(--amber); border: 1px solid var(--amber); }
290
- .badge-error { color: var(--red); border: 1px solid var(--red); }
291
-
292
- @keyframes badge-pulse { 0%,100%{opacity:1} 50%{opacity:.6} }
293
-
294
- /* Room scene */
295
- .room-scene {
296
- flex: 1;
297
- position: relative;
298
- overflow: hidden;
299
- background: var(--carpet);
300
- display: flex;
301
- align-items: flex-end;
302
- padding: 8px;
303
- gap: 10px;
304
- }
305
-
306
- /* carpet checker */
307
- .room-scene::before {
308
- content: '';
309
- position: absolute; inset: 0;
310
  background-image:
311
- linear-gradient(45deg, rgba(255,255,255,.02) 25%, transparent 25%),
312
- linear-gradient(-45deg, rgba(255,255,255,.02) 25%, transparent 25%),
313
- linear-gradient(45deg, transparent 75%, rgba(255,255,255,.02) 75%),
314
- linear-gradient(-45deg, transparent 75%, rgba(255,255,255,.02) 75%);
315
- background-size: 16px 16px;
316
- pointer-events: none;
317
- }
318
-
319
- /* ── PIXEL FURNITURE ── */
320
-
321
- /* Desk */
322
- .pixel-desk {
323
- flex-shrink: 0;
324
- display: flex;
325
- flex-direction: column;
326
- align-items: flex-end;
327
- gap: 3px;
328
- width: 110px;
329
- }
330
-
331
- .desk-top {
332
- width: 100%;
333
- height: 8px;
334
- background: var(--wood-light);
335
- border-top: 2px solid #c09828;
336
- position: relative;
337
- }
338
-
339
- .desk-top::after {
340
- content: '';
341
- position: absolute;
342
- bottom: -2px; left: 0; right: 0;
343
- height: 2px;
344
- background: var(--wood);
345
- }
346
-
347
- .desk-legs {
348
- width: 90%;
349
- height: 22px;
350
- background: var(--wood);
351
- display: flex;
352
- justify-content: space-between;
353
- padding: 0 4px;
354
- }
355
-
356
- .desk-leg {
357
- width: 6px;
358
- height: 100%;
359
- background: #6b4e0a;
360
- }
361
-
362
- .desk-monitor {
363
- position: absolute;
364
- bottom: 36px;
365
- left: 12px;
366
- width: 56px;
367
- height: 42px;
368
- background: #0d1117;
369
- border: 2px solid var(--metal);
370
- border-radius: 2px;
371
- display: flex;
372
- flex-direction: column;
373
- overflow: hidden;
374
- }
375
-
376
- .monitor-screen {
377
- flex: 1;
378
- background: #050a0f;
379
- padding: 4px;
380
- display: flex;
381
- flex-direction: column;
382
- gap: 3px;
383
- position: relative;
384
- overflow: hidden;
385
- }
386
-
387
- .monitor-base {
388
- height: 5px;
389
- background: var(--metal);
390
- position: relative;
391
- }
392
-
393
- .monitor-base::after {
394
- content: '';
395
- position: absolute;
396
- bottom: -3px; left: 50%; transform: translateX(-50%);
397
- width: 16px; height: 3px;
398
- background: var(--metal-light);
399
- }
400
-
401
- /* Screen states */
402
- .screen-line {
403
- height: 3px;
404
- background: var(--green);
405
- border-radius: 1px;
406
- opacity: .6;
407
- }
408
-
409
- .screen-line.scanning {
410
- width: 10%;
411
- animation: scan-right .6s ease-in-out infinite alternate;
412
- }
413
-
414
- @keyframes scan-right { 0%{width:10%} 100%{width:85%} }
415
-
416
- .screen-cursor-blink {
417
- position: absolute;
418
- bottom: 3px; left: 4px;
419
- width: 4px; height: 8px;
420
- background: var(--green);
421
- animation: blink-cur .7s step-end infinite;
422
- }
423
-
424
- @keyframes blink-cur { 0%,100%{opacity:1} 50%{opacity:0} }
425
-
426
- .think-dots {
427
- display: flex; gap: 3px;
428
- position: absolute; top:50%; left:50%; transform:translate(-50%,-50%);
429
- }
430
-
431
- .think-dot {
432
- width:4px; height:4px; border-radius:50%;
433
- background: var(--accent);
434
- animation: think .8s ease-in-out infinite;
435
- }
436
- .think-dot:nth-child(2){animation-delay:.15s}
437
- .think-dot:nth-child(3){animation-delay:.3s}
438
- @keyframes think { 0%,80%,100%{transform:translateY(0);opacity:.4} 40%{transform:translateY(-5px);opacity:1} }
439
-
440
- /* Keyboard */
441
- .desk-keyboard {
442
- position: absolute;
443
- bottom: 30px; left: 72px;
444
- width: 30px; height: 8px;
445
- background: var(--metal-light);
446
- border: 1px solid var(--metal);
447
- display: flex; gap: 1px; padding: 1px 2px; align-items: center;
448
- }
449
- .kbd-key { flex:1; height:4px; background: var(--metal); }
450
-
451
- /* Coffee mug */
452
- .coffee {
453
- position: absolute;
454
- bottom: 34px; right: 14px;
455
- }
456
-
457
- /* Chair */
458
- .chair {
459
- position: absolute;
460
- bottom: 0;
461
- right: 20px;
462
- }
463
-
464
- /* OOO overlay */
465
- .ooo-overlay {
466
- position: absolute;
467
- inset: 0;
468
- background: rgba(26,20,0,.65);
469
- display: flex;
470
- flex-direction: column;
471
- align-items: center;
472
- justify-content: center;
473
- gap: 4px;
474
- backdrop-filter: blur(1px);
475
- }
476
-
477
- .ooo-text {
478
- font-family: 'VT323', monospace;
479
- font-size: 18px;
480
- color: var(--amber);
481
- letter-spacing: 2px;
482
- }
483
-
484
- .ooo-sub {
485
- font-size: 10px;
486
- color: rgba(251,191,36,.6);
487
- font-family: 'IBM Plex Mono', monospace;
488
- }
489
-
490
- /* ── SERVER RACK (always animated) ── */
491
- .server-rack {
492
- background: var(--server-rack);
493
- border: 2px solid #2a3550;
494
- padding: 6px 5px;
495
- width: 44px;
496
- flex-shrink: 0;
497
- display: flex;
498
- flex-direction: column;
499
- gap: 3px;
500
- align-self: flex-end;
501
- margin-bottom: 8px;
502
- position: relative;
503
- }
504
-
505
- .server-rack::before {
506
- content: 'SRV';
507
- position: absolute;
508
- top: -20px; left: 0; right: 0;
509
- text-align: center;
510
- font-family: 'VT323', monospace;
511
- font-size: 11px;
512
- color: var(--text-dim);
513
- letter-spacing: 1px;
514
- }
515
-
516
- .rack-unit {
517
- height: 10px;
518
- background: #1a2235;
519
- border: 1px solid #2a3550;
520
- display: flex;
521
- align-items: center;
522
- gap: 2px;
523
- padding: 0 3px;
524
- }
525
-
526
- .rack-led {
527
- width: 4px; height: 4px;
528
- border-radius: 50%;
529
- }
530
-
531
- .led-green { background: var(--green); animation: led-blink var(--d, 1.5s) ease-in-out infinite; }
532
- .led-amber { background: var(--amber); animation: led-blink var(--d, 2.1s) ease-in-out infinite; }
533
- .led-blue { background: var(--accent); animation: led-blink var(--d, .9s) ease-in-out infinite; }
534
- .led-off { background: #1a2235; border: 1px solid #2a3550; }
535
-
536
- @keyframes led-blink { 0%,100%{opacity:.3} 50%{opacity:1; box-shadow:0 0 4px currentColor} }
537
-
538
- .rack-bar { flex:1; height:2px; background:#2a3550; }
539
- .rack-bar.active { background:#4a9eff; animation:rack-activity .4s ease-in-out infinite alternate; }
540
- @keyframes rack-activity { 0%{opacity:.3} 100%{opacity:1} }
541
-
542
- /* ── PIXEL PERSON ── */
543
- .pixel-person { flex-shrink:0; image-rendering:pixelated; }
544
-
545
- /* ── RESULT AREA ── */
546
- .room-result {
547
- background: #0d1117;
548
- border-top: 2px solid var(--border);
549
- padding: 10px 12px;
550
- min-height: 68px;
551
- flex-shrink: 0;
552
- }
553
-
554
- .result-placeholder {
555
- font-size: 11px;
556
- color: var(--text-dim);
557
- font-style: italic;
558
- }
559
-
560
- .result-content {
561
- font-size: 11px;
562
- color: var(--text);
563
- line-height: 1.6;
564
- white-space: pre-wrap;
565
- word-break: break-word;
566
- }
567
-
568
- .result-model {
569
- margin-top: 6px;
570
- font-size: 10px;
571
- color: var(--text-dim);
572
- display: flex; align-items: center; gap: 5px;
573
- }
574
-
575
- /* ── SERVER ROOM (bottom span) ── */
576
- .server-room {
577
- grid-column: 1 / -1;
578
- background: var(--wall-dark);
579
- border: 2px solid var(--border);
580
- padding: 12px 16px;
581
- display: flex;
582
- align-items: center;
583
- gap: 16px;
584
- min-height: 80px;
585
- position: relative;
586
- overflow: hidden;
587
- }
588
-
589
- .server-room::before {
590
- content: 'SERVER ROOM';
591
- position: absolute;
592
- top: 8px; left: 16px;
593
- font-family: 'VT323', monospace;
594
- font-size: 13px;
595
- color: var(--text-dim);
596
- letter-spacing: 3px;
597
- }
598
-
599
- .server-room-racks {
600
- display: flex;
601
- gap: 10px;
602
- margin-top: 16px;
603
- flex-wrap: wrap;
604
- }
605
-
606
- .big-rack {
607
- background: var(--server-rack);
608
- border: 2px solid #2a3550;
609
- padding: 5px;
610
- width: 48px;
611
- display: flex;
612
- flex-direction: column;
613
- gap: 2px;
614
- }
615
-
616
- .big-rack-unit {
617
- height: 8px;
618
- background: #111827;
619
- border: 1px solid #1e2a40;
620
- display: flex;
621
- align-items: center;
622
- gap: 2px;
623
- padding: 0 2px;
624
- }
625
-
626
- .server-status {
627
- margin-left: auto;
628
- display: flex;
629
- flex-direction: column;
630
- gap: 5px;
631
- font-size: 10px;
632
- color: var(--text-dim);
633
- }
634
-
635
- .srv-stat {
636
- display: flex;
637
- align-items: center;
638
- gap: 6px;
639
- }
640
-
641
- /* ── NOTIFICATION ── */
642
- .notif {
643
- position: fixed;
644
- top: 58px; right: 16px;
645
- background: #0d1117;
646
- border: 1px solid var(--border);
647
- border-left: 3px solid var(--green);
648
- padding: 12px 16px;
649
- max-width: 280px;
650
- z-index: 999;
651
- transform: translateX(300px);
652
- transition: transform .3s ease;
653
- font-size: 12px;
654
- box-shadow: 0 8px 24px rgba(0,0,0,.4);
655
- }
656
-
657
- .notif.show { transform: translateX(0); }
658
- .notif-title { font-family:'VT323',monospace; font-size:18px; color:var(--green); letter-spacing:1px; }
659
- .notif-msg { color:var(--text-dim); margin-top:2px; line-height:1.4; }
660
 
661
  /* ── SIDEBAR ── */
662
- .sidebar {
663
- background: #0d1117;
664
- border-left: 1px solid var(--border);
665
- display: flex;
666
- flex-direction: column;
667
- overflow: hidden;
668
- }
669
-
670
- .sb-section { border-bottom: 1px solid var(--border); flex-shrink:0; }
671
-
672
- .sb-title {
673
- font-family: 'VT323', monospace;
674
- font-size: 14px;
675
- letter-spacing: 3px;
676
- color: var(--text-dim);
677
- padding: 10px 14px 8px;
678
- border-bottom: 1px solid var(--border);
679
- }
680
-
681
- .activity-scroll {
682
- flex:1;
683
- overflow-y: auto;
684
- max-height: 280px;
685
- }
686
-
687
- .activity-item {
688
- padding: 7px 14px;
689
- border-bottom: 1px solid rgba(45,63,90,.4);
690
- display: flex;
691
- gap: 8px;
692
- align-items: flex-start;
693
- }
694
-
695
- .act-dot { width:5px; height:5px; border-radius:50%; margin-top:5px; flex-shrink:0; }
696
- .act-msg { font-size:11px; color:var(--text); line-height:1.4; }
697
- .act-time { font-size:10px; color:var(--text-dim); margin-top:1px; }
698
-
699
- .history-scroll { overflow-y:auto; max-height:220px; }
700
-
701
- .hist-item {
702
- padding: 8px 14px;
703
- border-bottom: 1px solid rgba(45,63,90,.4);
704
- cursor: pointer;
705
- transition: background .1s;
706
- }
707
- .hist-item:hover { background: var(--wall-dark); }
708
-
709
- .hist-task { font-size:11px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:500; }
710
- .hist-meta { font-size:10px; color:var(--text-dim); margin-top:2px; display:flex; gap:8px; }
711
-
712
- /* scrollbar */
713
- ::-webkit-scrollbar { width:4px; }
714
- ::-webkit-scrollbar-track { background:transparent; }
715
- ::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
716
  </style>
717
  </head>
718
  <body>
719
 
720
- <!-- TOPBAR -->
721
  <header class="topbar">
722
- <div class="tb-logo">
723
- <div class="tb-logo-icon">MC</div>
724
- <span class="tb-title">MISSION CONTROL</span>
725
- <span class="tb-version">v4.0</span>
726
  </div>
727
  <div class="tb-right">
728
- <div class="tb-stat"><div class="dot dot-green"></div><span id="top-active">0 active</span></div>
729
- <div class="tb-stat"><div class="dot dot-dim" id="top-dot2"></div><span id="top-status">Idle</span></div>
730
  <div class="clock" id="clock">00:00:00</div>
731
  </div>
732
  </header>
733
 
734
- <div class="progress-strip" id="progress"></div>
735
 
736
- <!-- MISSION BAR -->
737
- <div class="mission-bar">
738
- <input class="mission-input" id="task-input" placeholder="Enter mission for agents..." autocomplete="off"/>
739
- <button class="launch-btn" id="launch-btn" onclick="launchMission()">&#9658; LAUNCH</button>
740
- <span class="mission-count" id="mission-count">MISSIONS: 0</span>
741
  </div>
742
 
743
- <!-- MAIN -->
744
  <div class="main">
745
- <div class="office-floor">
746
- <div class="floor-label">&#9632; Operations Floor</div>
747
- <div class="office-grid" id="office-grid">
748
- <!-- rooms injected by JS -->
749
  </div>
 
750
  </div>
751
 
752
  <aside class="sidebar">
753
- <div class="sb-section">
754
- <div class="sb-title">ACTIVITY LOG</div>
755
- <div class="activity-scroll" id="activity-log"></div>
756
  </div>
757
- <div class="sb-section">
758
- <div class="sb-title">MISSION HISTORY</div>
759
- <div class="history-scroll" id="history-log">
760
- <div style="padding:14px;font-size:11px;color:var(--text-dim);text-align:center">No missions yet</div>
761
  </div>
762
  </div>
763
  </aside>
764
  </div>
765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  <!-- NOTIFICATION -->
767
  <div class="notif" id="notif">
768
- <div class="notif-title" id="notif-title">Mission Complete</div>
769
- <div class="notif-msg" id="notif-msg"></div>
770
  </div>
771
 
772
  <script>
773
- const AGENTS = [
774
- { key:'manager', name:'MANAGER', role:'Project Coordinator', color:'#4a9eff' },
775
- { key:'developer', name:'DEVELOPER', role:'Senior Engineer', color:'#4ade80' },
776
- { key:'analyst', name:'ANALYST', role:'Business Analyst', color:'#fbbf24' },
777
- ];
778
 
779
  const SPEECH = {
780
- manager: ['Coordinating team...','Reviewing scope...','Briefing Developer...','Checking Analyst...'],
781
- developer: ['Building solution...','Writing code...','Testing logic...','Manager approved!'],
782
- analyst: ['Analyzing data...','Evaluating risks...','Sending report...','Processing...'],
 
 
783
  };
784
 
785
- let states = {};
786
- let activity = [];
787
- let history = [];
788
- let missionCount = 0;
789
- let speechTimers = {};
790
- let interactionTimers = [];
791
 
792
- AGENTS.forEach(a => { states[a.key] = { status:'idle', message:'', model:'' }; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
 
794
  // CLOCK
795
  setInterval(() => {
@@ -798,27 +519,19 @@ setInterval(() => {
798
  [n.getHours(),n.getMinutes(),n.getSeconds()].map(x=>String(x).padStart(2,'0')).join(':');
799
  }, 1000);
800
 
801
- // PIXEL PERSON SVG
802
- function pixelPerson(color, status) {
803
- const op = status === 'resting' ? '0.2' : '1';
804
- const shirt = color;
805
- const anim = status === 'active' && Math.random() > .5
806
- ? 'style="animation:arm-idle 3s ease-in-out infinite"'
807
  : '';
808
- const wAnim = status === 'working'
809
- ? 'style="animation:arm-type .28s ease-in-out infinite alternate;transform-origin:13px 18px"'
810
- : anim;
811
-
812
  return `<svg viewBox="0 0 32 52" width="32" height="52" style="image-rendering:pixelated;display:block;opacity:${op}">
813
- <style>
814
- @keyframes arm-type{0%{transform:translateY(0)rotate(0)}100%{transform:translateY(2px)rotate(-5deg)}}
815
- @keyframes arm-idle{0%,100%{transform:rotate(0)}50%{transform:rotate(3deg)}}
816
- </style>
817
- <rect x="11" y="0" w="10" width="10" height="3" fill="#111"/>
818
- <rect x="9" y="3" width="14" height="2" fill="#111"/>
819
- <rect x="9" y="3" width="2" height="5" fill="#111"/>
820
- <rect x="21" y="3" width="2" height="5" fill="#111"/>
821
- <rect x="9" y="5" width="14" height="11" fill="#f0c8a0"/>
822
  <rect x="7" y="8" width="2" height="4" fill="#f0c8a0"/>
823
  <rect x="23" y="8" width="2" height="4" fill="#f0c8a0"/>
824
  <rect x="11" y="9" width="4" height="3" fill="${color}" opacity=".9"/>
@@ -826,383 +539,362 @@ function pixelPerson(color, status) {
826
  <rect x="12" y="10" width="2" height="2" fill="#111"/>
827
  <rect x="18" y="10" width="2" height="2" fill="#111"/>
828
  <rect x="15" y="10" width="2" height="1" fill="${color}"/>
829
- <rect x="13" y="13" width="6" height="1" fill="#c88060" opacity=".5"/>
830
- <rect x="9" y="16" width="14" height="11" fill="${shirt}" opacity=".9"/>
831
- <rect x="11" y="16" width="10" height="3" fill="${shirt}"/>
832
- <g ${wAnim}>
833
- <rect x="4" y="17" width="5" height="8" fill="${shirt}" opacity=".9"/>
834
- <rect x="23" y="17" width="5" height="8" fill="${shirt}" opacity=".9"/>
835
- <rect x="3" y="24" width="5" height="3" fill="#f0c8a0"/>
836
- <rect x="24" y="24" width="5" height="3" fill="#f0c8a0"/>
837
  </g>
838
- <rect x="9" y="27" width="14" height="12" fill="#1e2535"/>
839
- <rect x="13" y="39" width="5" height="7" fill="#1e2535"/>
840
- <rect x="9" y="39" width="5" height="7" fill="#1e2535"/>
841
- <rect x="7" y="46" width="7" height="4" fill="#111"/>
842
- <rect x="18" y="46" width="7" height="4" fill="#111"/>
843
  </svg>`;
844
  }
845
 
846
- // CHAIR SVG
847
- function chairSVG(isEmpty) {
848
- const c = isEmpty ? '#1a2235' : '#2a3550';
849
- const s = isEmpty ? '#111827' : '#1e2a40';
850
- return `<svg viewBox="0 0 28 32" width="28" height="32" style="image-rendering:pixelated;display:block">
851
- <rect x="4" y="4" width="20" height="14" fill="${c}" rx="1"/>
852
- <rect x="5" y="5" width="18" height="12" fill="${s}"/>
853
- <rect x="4" y="18" width="20" height="4" fill="${c}"/>
854
- <rect x="4" y="0" width="4" height="18" fill="${c}"/>
855
- <rect x="6" y="22" width="3" height="8" fill="${c}"/>
856
- <rect x="19" y="22" width="3" height="8" fill="${c}"/>
857
- <rect x="4" y="29" width="5" height="3" fill="#111"/>
858
- <rect x="19" y="29" width="5" height="3" fill="#111"/>
859
  </svg>`;
860
  }
861
 
862
- // COFFEE SVG
863
  function coffeeSVG() {
864
- return `<svg viewBox="0 0 14 14" width="14" height="14" style="image-rendering:pixelated;display:block">
865
- <rect x="2" y="4" width="8" height="8" fill="#5c3a1e"/>
866
- <rect x="2" y="4" width="8" height="2" fill="#7a4e2a"/>
867
- <rect x="3" y="5" width="6" height="1" fill="#9a6840" opacity=".4"/>
868
- <rect x="10" y="6" width="2" height="4" fill="#5c3a1e"/>
869
- <rect x="1" y="12" width="10" height="2" fill="#4a2e14"/>
870
- <rect x="4" y="2" width="1" height="2" fill="#888" opacity=".4"/>
871
- <rect x="7" y="1" width="1" height="3" fill="#888" opacity=".4"/>
872
  </svg>`;
873
  }
874
 
875
- // SCREEN CONTENT
876
- function screenContent(status) {
877
- if (status === 'working') {
878
- return `<div class="think-dots">
879
- <div class="think-dot"></div>
880
- <div class="think-dot"></div>
881
- <div class="think-dot"></div>
882
- </div>`;
883
- }
884
- if (status === 'active' || status === 'done') {
885
- return `
886
- <div class="screen-line" style="width:80%"></div>
887
- <div class="screen-line" style="width:55%;opacity:.5"></div>
888
- <div class="screen-line" style="width:70%;opacity:.4"></div>
889
- <div class="screen-line" style="width:40%;opacity:.3"></div>
890
- <div class="screen-cursor-blink"></div>`;
891
- }
892
- if (status === 'resting') {
893
- return `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:'VT323',monospace;font-size:11px;color:#fbbf2466;letter-spacing:1px">AWAY</div>`;
894
- }
895
- return `
896
- <div class="screen-line" style="width:50%;opacity:.2"></div>
897
- <div class="screen-line" style="width:35%;opacity:.15"></div>`;
898
- }
899
-
900
- // SERVER RACK
901
- function serverRackHTML() {
902
  const units = [
903
- { l1:'green', l2:'green', d1:'1.1s', d2:'1.7s', active:true },
904
- { l1:'amber', l2:'blue', d1:'2.3s', d2:'.8s', active:true },
905
- { l1:'green', l2:'off', d1:'1.8s', d2:null, active:false },
906
- { l1:'blue', l2:'green', d1:'.7s', d2:'2.1s', active:true },
907
- { l1:'amber', l2:'amber', d1:'1.4s', d2:'1.9s', active:false },
908
- { l1:'green', l2:'blue', d1:'2.6s', d2:'.6s', active:true },
909
  ];
910
- return units.map(u => `
911
- <div class="rack-unit">
912
- <div class="rack-led led-${u.l1}" style="--d:${u.d1}"></div>
913
- ${u.l2 !== 'off' ? `<div class="rack-led led-${u.l2}" style="--d:${u.d2}"></div>` : '<div class="rack-led led-off"></div>'}
914
- <div class="rack-bar ${u.active ? 'active' : ''}"></div>
915
- </div>`).join('');
 
 
 
 
 
 
 
 
916
  }
917
 
918
- // BUILD ROOM HTML
919
  function buildRoom(agent) {
920
- const st = states[agent.key];
921
  const status = st.status;
922
- const isEmpty = status === 'resting';
923
-
924
- const cardCls = `room ${status === 'working' ? 'active' : status === 'active' ? 'done' : status === 'resting' ? 'resting' : ''}`;
925
- const badgeLbl = { idle:'IDLE', working:'PROCESSING', active:'DONE', resting:'AWAY', error:'ERROR' }[status] || 'IDLE';
926
- const badgeCls = `room-badge badge-${status === 'active' ? 'done' : status}`;
927
-
928
- const resultHTML = status === 'idle'
929
- ? `<div class="result-placeholder">Waiting for mission...</div>`
930
- : status === 'working'
931
- ? `<div class="result-placeholder" style="color:var(--accent)">Processing...</div>`
932
- : status === 'resting'
933
- ? `<div class="result-placeholder" style="color:var(--amber)">Rate limit β€” back in ~1-2 min</div>`
934
- : `<div class="result-content" id="result-${agent.key}"></div>
935
- ${st.model ? `<div class="result-model"><div class="dot dot-green" style="width:5px;height:5px;margin:0"></div>${st.model}</div>` : ''}`;
936
-
937
- return `<div class="${cardCls}" id="room-${agent.key}">
938
- <div class="room-wall">
939
- <span class="room-name" style="color:${agent.color}">${agent.name}</span>
940
- <span class="${badgeCls}">${badgeLbl}</span>
941
- </div>
942
-
943
- <div class="room-scene" id="scene-${agent.key}">
944
- <!-- server rack always animated -->
945
- <div class="server-rack">${serverRackHTML()}</div>
946
 
947
- <!-- desk with monitor -->
948
- <div style="position:relative;flex:1;height:100%;display:flex;align-items:flex-end">
949
- <div class="desk-monitor" id="monitor-${agent.key}">
950
- <div class="monitor-screen">${screenContent(status)}</div>
951
- <div class="monitor-base"></div>
952
- </div>
953
- <div class="desk-keyboard"><div class="kbd-key"></div><div class="kbd-key"></div><div class="kbd-key"></div></div>
954
- <div class="coffee">${coffeeSVG()}</div>
955
- <div class="pixel-desk">
956
- <div class="desk-top"></div>
957
- <div class="desk-legs"><div class="desk-leg"></div><div class="desk-leg"></div></div>
958
  </div>
959
- <div class="chair" style="position:absolute;bottom:0;right:6px">${chairSVG(isEmpty)}</div>
960
- ${!isEmpty
961
- ? `<div style="position:absolute;bottom:28px;left:50%;transform:translateX(-50%)">${pixelPerson(agent.color, status)}</div>`
962
- : ''}
963
  </div>
964
-
965
- ${isEmpty
966
- ? `<div class="ooo-overlay">
967
- <div class="ooo-text">β˜• AWAY</div>
968
- <div class="ooo-sub">Rate limited β€” available soon</div>
969
- </div>`
970
- : ''}
971
-
972
- <!-- speech bubble -->
973
- <div id="bubble-${agent.key}" style="
974
- position:absolute;top:8px;left:54px;
975
- background:#0d1117;border:1px solid var(--border);
976
- padding:4px 8px;font-size:10px;color:var(--text);max-width:140px;
977
- box-shadow:0 4px 12px rgba(0,0,0,.4);line-height:1.3;
978
- opacity:0;transform:translateY(-4px);
979
- transition:opacity .3s,transform .3s;pointer-events:none;
980
- font-family:'IBM Plex Mono',monospace;
981
- z-index:10;
982
- "></div>
983
- </div>
984
-
985
- <div class="room-result" id="result-area-${agent.key}">
986
- ${resultHTML}
987
  </div>
 
988
  </div>`;
989
  }
990
 
991
- // SERVER ROOM (bottom)
992
  function buildServerRoom() {
993
- const racks = Array.from({length:6}, (_,i) => {
994
- const units = Array.from({length:5}, (_,j) => {
995
- const colors = ['green','blue','amber','green','blue'];
996
- const delays = [1.1,2.3,.8,1.7,.5];
997
- return `<div class="big-rack-unit">
998
- <div class="rack-led led-${colors[j]}" style="--d:${delays[j]}s"></div>
999
- <div class="rack-bar active"></div>
1000
- </div>`;
1001
- }).join('');
1002
  return `<div class="big-rack">${units}</div>`;
1003
  }).join('');
1004
 
1005
  return `<div class="server-room">
1006
- <div class="server-room-racks">${racks}</div>
1007
- <div class="server-status">
1008
- <div class="srv-stat"><div class="dot dot-green"></div><span>n8n online</span></div>
1009
- <div class="srv-stat"><div class="dot dot-green"></div><span>Replit API</span></div>
1010
- <div class="srv-stat"><div class="dot dot-amber" id="srv-llm"></div><span id="srv-llm-label">LLM providers</span></div>
 
1011
  </div>
1012
  </div>`;
1013
  }
1014
 
1015
- function renderOffice() {
1016
- const grid = document.getElementById('office-grid');
1017
- grid.innerHTML = AGENTS.map(buildRoom).join('') + buildServerRoom();
1018
- const active = AGENTS.filter(a => states[a.key].status === 'working').length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1019
  document.getElementById('top-active').textContent = `${active} active`;
1020
  }
1021
 
1022
  // TYPEWRITER
1023
- function typeWriter(el, text, speed=16) {
1024
- el.textContent = '';
1025
- let i = 0;
1026
  const iv = setInterval(() => {
1027
  if (i < text.length) el.textContent += text[i++];
1028
  else clearInterval(iv);
1029
  }, speed);
1030
  }
1031
 
1032
- // SPEECH BUBBLE
1033
  function showBubble(key, text, ms=2800) {
1034
  const b = document.getElementById(`bubble-${key}`);
1035
  if (!b) return;
1036
- if (speechTimers[key]) clearTimeout(speechTimers[key]);
1037
  b.textContent = text;
1038
- b.style.opacity = '1';
1039
- b.style.transform = 'translateY(0)';
1040
- speechTimers[key] = setTimeout(() => {
1041
- b.style.opacity = '0';
1042
- b.style.transform = 'translateY(-4px)';
1043
- }, ms);
1044
  }
1045
 
1046
  // ACTIVITY
1047
- function addActivity(msg, color='#4a9eff') {
1048
  const now = new Date();
1049
  const t = [now.getHours(),now.getMinutes(),now.getSeconds()].map(x=>String(x).padStart(2,'0')).join(':');
1050
  activity.unshift({ msg, t, color });
1051
- const feed = document.getElementById('activity-log');
1052
- feed.innerHTML = activity.slice(0,30).map(a=>`
1053
- <div class="activity-item">
1054
  <div class="act-dot" style="background:${a.color}"></div>
1055
- <div>
1056
- <div class="act-msg">${a.msg}</div>
1057
- <div class="act-time">${a.t}</div>
1058
- </div>
1059
- </div>`).join('');
1060
  }
1061
 
1062
- // NOTIFICATION
1063
- function notify(title, msg, color='#4ade80') {
1064
- document.getElementById('notif-title').textContent = title;
1065
- document.getElementById('notif-title').style.color = color;
1066
- document.getElementById('notif-msg').textContent = msg;
 
1067
  const el = document.getElementById('notif');
1068
- el.style.borderLeftColor = color;
1069
  el.classList.add('show');
1070
  setTimeout(() => el.classList.remove('show'), 5000);
1071
  }
1072
 
1073
  // HISTORY
1074
  function updateHistory() {
1075
- const list = document.getElementById('history-log');
1076
  if (!history.length) {
1077
- list.innerHTML = `<div style="padding:14px;font-size:11px;color:var(--text-dim);text-align:center">No missions yet</div>`;
1078
  return;
1079
  }
1080
- list.innerHTML = [...history].reverse().map(m=>`
1081
- <div class="hist-item">
1082
  <div class="hist-task">${m.task}</div>
1083
  <div class="hist-meta">
1084
  <span>${m.time}</span>
1085
  <span style="color:${m.ok?'var(--green)':'var(--amber)'}">${m.ok?'Done':'Partial'}</span>
 
1086
  </div>
1087
- </div>`).join('');
 
1088
  }
1089
 
1090
- // AGENT INTERACTIONS
1091
- function startInteractions() {
1092
- interactionTimers.forEach(t => clearTimeout(t));
1093
- interactionTimers = [];
1094
- const events = [
1095
- { key:'manager', msg:'Briefing Developer...', delay:3000 },
1096
- { key:'developer', msg:'Manager confirmed scope', delay:6000 },
1097
- { key:'manager', msg:'Checking with Analyst...', delay:9000 },
1098
- { key:'analyst', msg:'Flagging findings...', delay:12000 },
1099
- { key:'developer', msg:'Almost done...', delay:15000 },
1100
- { key:'analyst', msg:'Report ready!', delay:18000 },
1101
- ];
1102
- events.forEach(e => {
1103
- const t = setTimeout(() => {
1104
- if (states[e.key]?.status === 'working') {
1105
- showBubble(e.key, e.msg, 2500);
1106
- addActivity(`${e.key}: ${e.msg}`, '#a78bfa');
1107
- }
1108
- }, e.delay);
1109
- interactionTimers.push(t);
1110
  });
 
 
 
 
 
 
 
 
 
1111
  }
1112
 
1113
- // LAUNCH
1114
  async function launchMission() {
1115
  const task = document.getElementById('task-input').value.trim();
1116
  if (!task) return;
1117
 
1118
  const btn = document.getElementById('launch-btn');
1119
  btn.disabled = true;
1120
- document.getElementById('progress').classList.add('active');
1121
- document.getElementById('top-status').textContent = 'Mission running';
1122
- document.getElementById('top-dot2').className = 'dot dot-amber';
1123
- document.getElementById('mission-count').textContent = `MISSIONS: ${missionCount}`;
1124
 
1125
- AGENTS.forEach(a => { states[a.key] = { status:'working', message:'', model:'' }; });
 
1126
  renderOffice();
1127
 
1128
- AGENTS.forEach((a,i) => {
 
1129
  setTimeout(() => {
1130
- const phrases = SPEECH[a.key];
1131
  showBubble(a.key, phrases[Math.floor(Math.random()*phrases.length)], 3000);
1132
- }, i * 700);
1133
  });
1134
 
1135
- addActivity(`Mission: "${task.substring(0,40)}"`, '#4a9eff');
1136
- startInteractions();
1137
 
1138
  try {
1139
  const resp = await fetch('/api/mission', {
1140
- method:'POST',
1141
- headers:{'Content-Type':'application/json'},
1142
  body: JSON.stringify({ task }),
1143
  });
1144
-
1145
  const data = await resp.json();
1146
  if (!resp.ok || data.error) throw new Error(data.error || 'Server error');
1147
 
1148
  let anyResting = false;
1149
-
1150
- AGENTS.forEach(a => {
1151
- const r = data.results[a.key] || {};
1152
- const status = r.status === 'resting' ? 'resting' : 'active';
1153
- if (status === 'resting') anyResting = true;
1154
- states[a.key] = { status, message: r.message || '', model: r.model || '' };
 
 
 
 
 
 
 
 
 
 
 
 
 
1155
  });
1156
 
1157
- renderOffice();
1158
 
1159
- // Typewriter staggered
1160
- AGENTS.forEach((a, i) => {
1161
- const st = states[a.key];
1162
- if (st.status === 'active' && st.message) {
1163
  setTimeout(() => {
1164
- const el = document.getElementById(`result-${a.key}`);
1165
- if (el) typeWriter(el, st.message);
1166
- showBubble(a.key, 'Done! βœ“', 2000);
1167
- addActivity(`${a.name}: task complete`, '#4ade80');
1168
- }, i * 500);
1169
- } else if (st.status === 'resting') {
1170
- addActivity(`${a.name}: rate-limited`, '#fbbf24');
1171
- document.getElementById('srv-llm').className = 'dot dot-amber';
1172
  }
1173
  });
1174
 
1175
- missionCount++;
1176
- document.getElementById('mission-count').textContent = `MISSIONS: ${missionCount}`;
1177
 
 
 
1178
  const time = new Date().toLocaleTimeString();
1179
- history.push({ task, time, ok: !anyResting });
1180
  updateHistory();
1181
 
1182
  setTimeout(() => {
1183
  notify(
1184
- anyResting ? 'Mission Partial' : 'Mission Complete βœ“',
1185
  anyResting
1186
- ? 'Some agents are rate-limited. Try again in ~2 min.'
1187
- : `All agents responded. Mission #${missionCount} complete.`,
1188
- anyResting ? '#fbbf24' : '#4ade80'
 
 
1189
  );
1190
- }, AGENTS.length * 500 + 400);
1191
 
1192
- document.getElementById('top-status').textContent = anyResting ? 'Partial' : 'Complete';
1193
- document.getElementById('top-dot2').className = `dot ${anyResting ? 'dot-amber' : 'dot-green'}`;
 
1194
 
1195
  } catch(err) {
1196
- AGENTS.forEach(a => { states[a.key] = { status:'error', message: err.message, model:'' }; });
1197
  renderOffice();
1198
- addActivity(`Error: ${err.message}`, '#f87171');
1199
- document.getElementById('top-dot2').className = 'dot dot-red';
 
1200
  document.getElementById('top-status').textContent = 'Error';
1201
- notify('Mission Failed', err.message, '#f87171');
1202
  }
1203
 
1204
  btn.disabled = false;
1205
- document.getElementById('progress').classList.remove('active');
1206
  }
1207
 
1208
  document.getElementById('task-input').addEventListener('keydown', e => {
@@ -1210,9 +902,9 @@ document.getElementById('task-input').addEventListener('keydown', e => {
1210
  });
1211
 
1212
  // INIT
1213
- renderOffice();
1214
- addActivity('System online β€” all agents ready', '#4ade80');
1215
- addActivity('Server rack active β€” LEDs nominal', '#4a9eff');
1216
  </script>
1217
  </body>
1218
  </html>
 
8
  @import url('https://fonts.googleapis.com/css2?family=VT323&family=IBM+Plex+Mono:wght@400;500&display=swap');
9
 
10
  :root {
11
+ --bg:#0d1117; --surface:#161b22; --surface2:#1c2128; --surface3:#21262d;
12
+ --border:#30363d; --border2:#444c56;
13
+ --text:#e6edf3; --text2:#8b949e; --text3:#484f58;
14
+ --accent:#58a6ff; --accent-dim:#1f3f6e;
15
+ --green:#3fb950; --green-dim:#1a3d24;
16
+ --amber:#d29922; --amber-dim:#3d2f0a;
17
+ --red:#f85149; --red-dim:#3d1210;
18
+ --purple:#bc8cff;
19
+ --wood:#6b4e0a; --wood-l:#8b6914;
20
+ --metal:#2d333b; --metal-l:#444c56;
21
+ --carpet:#0d1620; --wall:#1a2030;
22
+ --shadow:0 8px 24px rgba(0,0,0,.4);
23
+ --r:6px;
24
+ }
25
+
26
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
27
+
28
+ body{
29
+ font-family:'IBM Plex Mono',monospace;
30
+ background:var(--bg); color:var(--text);
31
+ min-height:100vh; display:flex; flex-direction:column;
32
+ font-size:13px; line-height:1.5; overflow-x:hidden;
 
 
 
 
 
 
 
 
 
 
33
  }
34
 
35
  /* ── TOPBAR ── */
36
+ .topbar{
37
+ background:var(--surface); border-bottom:1px solid var(--border);
38
+ height:50px; padding:0 20px;
39
+ display:flex; align-items:center; justify-content:space-between;
40
+ flex-shrink:0; position:sticky; top:0; z-index:100;
41
+ }
42
+ .tb-left{display:flex;align-items:center;gap:10px}
43
+ .tb-logo{
44
+ background:var(--accent); color:#000;
45
+ font-family:'VT323',monospace; font-size:15px; letter-spacing:1px;
46
+ padding:2px 8px; border-radius:3px;
47
+ }
48
+ .tb-title{font-family:'VT323',monospace;font-size:20px;color:var(--accent);letter-spacing:2px}
49
+ .tb-right{display:flex;align-items:center;gap:16px}
50
+ .tb-stat{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:5px}
51
+ .sdot{width:7px;height:7px;border-radius:50%}
52
+ .dot-g{background:var(--green);animation:pulse-g 2s ease infinite}
53
+ .dot-a{background:var(--amber);animation:pulse-g 1.2s ease infinite}
54
+ .dot-r{background:var(--red);animation:blink-r .5s step-end infinite}
55
+ .dot-d{background:var(--text3)}
56
+ @keyframes pulse-g{0%,100%{box-shadow:0 0 0 0 currentColor}50%{box-shadow:0 0 0 4px transparent}}
57
+ @keyframes blink-r{0%,100%{opacity:1}50%{opacity:0}}
58
+ .clock{font-family:'VT323',monospace;font-size:20px;color:var(--accent);letter-spacing:2px}
59
 
60
+ /* ── PROGRESS ── */
61
+ .progress{height:2px;background:var(--surface2);overflow:hidden;display:none}
62
+ .progress.on{display:block}
63
+ .progress::after{
64
+ content:'';display:block;height:100%;width:30%;background:var(--accent);
65
+ animation:prog 1.4s ease-in-out infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
67
+ @keyframes prog{0%{margin-left:-30%}100%{margin-left:110%}}
68
 
69
  /* ── MISSION BAR ── */
70
+ .mbar{
71
+ background:var(--surface); border-bottom:1px solid var(--border);
72
+ padding:12px 20px; display:flex; gap:10px; flex-shrink:0; flex-wrap:wrap;
73
+ }
74
+ .minput{
75
+ flex:1; min-width:200px; height:36px;
76
+ background:var(--surface2); border:1px solid var(--border);
77
+ color:var(--text); font-family:'IBM Plex Mono',monospace; font-size:13px;
78
+ padding:0 12px; outline:none; border-radius:var(--r);
79
+ transition:border-color .15s;
80
+ }
81
+ .minput:focus{border-color:var(--accent)}
82
+ .minput::placeholder{color:var(--text3)}
83
+ .launch{
84
+ height:36px; padding:0 18px;
85
+ background:var(--accent); border:none; color:#000;
86
+ font-family:'VT323',monospace; font-size:18px; letter-spacing:2px;
87
+ cursor:pointer; border-radius:var(--r); transition:background .15s;
88
+ display:flex; align-items:center; gap:6px;
89
+ }
90
+ .launch:hover{background:#79b8ff}
91
+ .launch:active{transform:scale(.98)}
92
+ .launch:disabled{background:var(--accent-dim);color:var(--text3);cursor:not-allowed}
93
+ .add-agent-btn{
94
+ height:36px; padding:0 14px;
95
+ background:transparent; border:1px solid var(--border);
96
+ color:var(--text2); font-family:'IBM Plex Mono',monospace; font-size:12px;
97
+ cursor:pointer; border-radius:var(--r); transition:all .15s;
98
+ display:flex; align-items:center; gap:6px;
99
+ }
100
+ .add-agent-btn:hover{border-color:var(--accent);color:var(--accent)}
101
+ .mc{font-family:'VT323',monospace;font-size:16px;color:var(--text3)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
  /* ── MAIN LAYOUT ── */
104
+ .main{display:grid;grid-template-columns:1fr 280px;flex:1;min-height:0;overflow:hidden}
105
+ @media(max-width:900px){.main{grid-template-columns:1fr}.sidebar{display:none}}
 
 
 
 
 
 
 
 
 
 
106
 
107
+ /* ── OFFICE ── */
108
+ .office{
109
+ background:var(--carpet);
110
+ overflow-y:auto; padding:16px; position:relative;
 
 
 
111
  }
112
+ .office::before{
113
+ content:''; position:fixed; inset:0;
 
 
 
 
114
  background-image:
115
+ linear-gradient(rgba(255,255,255,.03) 1px,transparent 1px),
116
+ linear-gradient(90deg,rgba(255,255,255,.03) 1px,transparent 1px);
117
+ background-size:32px 32px; pointer-events:none;
 
 
118
  }
119
+ .floor-label{
120
+ font-family:'VT323',monospace; font-size:13px; letter-spacing:3px;
121
+ color:var(--text3); margin-bottom:14px; text-transform:uppercase;
122
+ display:flex; align-items:center; justify-content:space-between;
 
 
 
 
123
  }
124
 
125
  /* ── OFFICE GRID ── */
126
+ .ogrid{
127
+ display:grid; grid-template-columns:repeat(3,1fr); gap:12px;
128
+ position:relative; z-index:1;
129
+ }
130
+ @media(max-width:1100px){.ogrid{grid-template-columns:repeat(2,1fr)}}
131
+ @media(max-width:700px){.ogrid{grid-template-columns:1fr}}
132
+
133
+ /* ── ROOM ── */
134
+ .room{
135
+ background:var(--wall); border:1px solid var(--border);
136
+ border-radius:var(--r); overflow:hidden;
137
+ display:flex; flex-direction:column; transition:border-color .3s;
138
+ min-height:220px;
139
+ }
140
+ .room.working{border-color:var(--accent)}
141
+ .room.active{border-color:var(--green)}
142
+ .room.resting{border-color:var(--amber)}
143
+ .room.idle{border-color:var(--border)}
144
+
145
+ /* room wall header */
146
+ .rwall{
147
+ background:var(--surface2); height:30px;
148
+ border-bottom:1px solid var(--border);
149
+ display:flex; align-items:center; justify-content:space-between;
150
+ padding:0 10px; flex-shrink:0;
151
+ }
152
+ .rname{font-family:'VT323',monospace;font-size:15px;letter-spacing:2px}
153
+ .rbadge{
154
+ font-family:'VT323',monospace;font-size:12px;
155
+ padding:1px 7px; border:1px solid; letter-spacing:1px;
156
+ }
157
+ .b-idle{color:var(--text3);border-color:var(--text3)}
158
+ .b-working{color:var(--accent);border-color:var(--accent);animation:badgep .9s ease infinite}
159
+ .b-active{color:var(--green);border-color:var(--green);background:var(--green-dim)}
160
+ .b-resting{color:var(--amber);border-color:var(--amber)}
161
+ .b-error{color:var(--red);border-color:var(--red)}
162
+ @keyframes badgep{0%,100%{opacity:1}50%{opacity:.5}}
163
+
164
+ /* room scene */
165
+ .rscene{
166
+ flex:1; background:var(--carpet); position:relative;
167
+ display:flex; align-items:flex-end; padding:8px; gap:8px;
168
+ overflow:hidden; min-height:120px;
169
+ }
170
+ .rscene::before{
171
+ content:''; position:absolute; inset:0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  background-image:
173
+ linear-gradient(45deg,rgba(255,255,255,.015) 25%,transparent 25%),
174
+ linear-gradient(-45deg,rgba(255,255,255,.015) 25%,transparent 25%),
175
+ linear-gradient(45deg,transparent 75%,rgba(255,255,255,.015) 75%),
176
+ linear-gradient(-45deg,transparent 75%,rgba(255,255,255,.015) 75%);
177
+ background-size:16px 16px;
178
+ }
179
+
180
+ /* SERVER RACK */
181
+ .srack{
182
+ width:38px; flex-shrink:0; align-self:flex-end; margin-bottom:6px;
183
+ background:#0d1117; border:1px solid #1e2a40; padding:4px 3px;
184
+ display:flex; flex-direction:column; gap:2px; position:relative; z-index:1;
185
+ }
186
+ .srack::before{
187
+ content:'SRV';position:absolute;top:-16px;left:0;right:0;
188
+ text-align:center;font-family:'VT323',monospace;font-size:10px;color:var(--text3);
189
+ }
190
+ .runit{height:8px;background:#111827;border:1px solid #1e2a40;display:flex;align-items:center;gap:2px;padding:0 2px}
191
+ .rled{width:4px;height:4px;border-radius:50%}
192
+ .lg{background:var(--green);animation:led calc(var(--d,1.5s)) ease-in-out infinite}
193
+ .la{background:var(--amber);animation:led calc(var(--d,2s)) ease-in-out infinite}
194
+ .lb{background:var(--accent);animation:led calc(var(--d,.9s)) ease-in-out infinite}
195
+ .lo{background:#1a2235;border:1px solid #2a3550}
196
+ .rbar{flex:1;height:2px;background:#1e2a40}
197
+ .rbar.on{background:var(--accent);animation:rba .4s ease-in-out infinite alternate}
198
+ @keyframes led{0%,100%{opacity:.3}50%{opacity:1;box-shadow:0 0 4px currentColor}}
199
+ @keyframes rba{0%{opacity:.3}100%{opacity:1}}
200
+
201
+ /* DESK AREA */
202
+ .deskarea{flex:1;display:flex;flex-direction:column;gap:3px;position:relative;z-index:1}
203
+
204
+ .monitor{
205
+ width:100%;height:48px;background:#050a0f;
206
+ border:2px solid var(--metal-l);border-radius:2px;
207
+ position:relative;overflow:hidden;display:flex;flex-direction:column;
208
+ }
209
+ .monitor.thinking{border-color:var(--accent)}
210
+ .monitor.done{border-color:var(--green)}
211
+ .monitor.away{border-color:var(--amber)}
212
+
213
+ .mscreen{flex:1;padding:4px;display:flex;flex-direction:column;gap:3px;position:relative;overflow:hidden}
214
+ .mbase{height:5px;background:var(--metal);position:relative}
215
+ .mbase::after{
216
+ content:'';position:absolute;bottom:-3px;left:50%;transform:translateX(-50%);
217
+ width:14px;height:3px;background:var(--metal-l);
218
+ }
219
+
220
+ .sline{height:3px;background:var(--green);border-radius:1px;opacity:.6}
221
+ .sline.scan{width:10%;animation:scan .6s ease-in-out infinite alternate}
222
+ @keyframes scan{0%{width:10%}100%{width:85%}}
223
+ .scur{
224
+ position:absolute;bottom:3px;left:4px;
225
+ width:4px;height:8px;background:var(--green);
226
+ animation:cur .7s step-end infinite;
227
+ }
228
+ @keyframes cur{0%,100%{opacity:1}50%{opacity:0}}
229
+ .tdots{display:flex;gap:3px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
230
+ .tdot{width:4px;height:4px;border-radius:50%;background:var(--accent);animation:td .8s ease-in-out infinite}
231
+ .tdot:nth-child(2){animation-delay:.15s}.tdot:nth-child(3){animation-delay:.3s}
232
+ @keyframes td{0%,80%,100%{transform:translateY(0);opacity:.4}40%{transform:translateY(-5px);opacity:1}}
233
+
234
+ .desk-surface{height:7px;background:var(--wood-l);border-radius:1px;border-top:2px solid #c09828}
235
+ .keyboard{
236
+ height:6px;background:#c8b89a;border:1px solid #a09070;
237
+ border-radius:1px;display:flex;gap:1px;padding:1px 2px;align-items:center;
238
+ }
239
+ .key{flex:1;height:3px;background:#a09070;border-radius:.5px}
240
+
241
+ /* COFFEE */
242
+ .coffee{position:absolute;bottom:30px;right:10px;z-index:2}
243
+
244
+ /* PERSON */
245
+ .person{position:absolute;bottom:22px;left:50%;transform:translateX(-50%);z-index:2}
246
+
247
+ /* CHAIR */
248
+ .chair{position:absolute;bottom:0;right:4px;z-index:1}
249
+
250
+ /* OOO */
251
+ .ooo{
252
+ position:absolute;inset:0;background:rgba(20,15,0,.75);
253
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;
254
+ z-index:10;
255
+ }
256
+ .ooo-t{font-family:'VT323',monospace;font-size:18px;color:var(--amber);letter-spacing:2px}
257
+ .ooo-s{font-size:10px;color:rgba(210,153,34,.6)}
258
+
259
+ /* SPEECH BUBBLE */
260
+ .bubble{
261
+ position:absolute;top:6px;left:44px;
262
+ background:var(--surface);border:1px solid var(--border2);
263
+ border-radius:var(--r);padding:4px 8px;font-size:10px;
264
+ max-width:140px;line-height:1.3;z-index:20;
265
+ opacity:0;transform:translateY(-4px);pointer-events:none;
266
+ transition:opacity .3s,transform .3s;
267
+ box-shadow:var(--shadow);
268
+ }
269
+ .bubble.show{opacity:1;transform:translateY(0)}
270
+ .bubble::before{
271
+ content:'';position:absolute;left:-6px;top:8px;
272
+ width:0;height:0;border-top:5px solid transparent;
273
+ border-bottom:5px solid transparent;border-right:6px solid var(--border2);
274
+ }
275
+
276
+ /* RESULT */
277
+ .rresult{
278
+ background:var(--surface); border-top:1px solid var(--border);
279
+ padding:10px 12px; flex-shrink:0; min-height:60px;
280
+ }
281
+ .rph{font-size:11px;color:var(--text3);font-style:italic}
282
+ .rtxt{font-size:11px;color:var(--text);line-height:1.6;word-break:break-word}
283
+ .rmodel{font-size:10px;color:var(--text3);margin-top:5px;display:flex;align-items:center;gap:4px}
284
+
285
+ /* DOWNLOAD BUTTON */
286
+ .dl-btn{
287
+ display:inline-flex;align-items:center;gap:6px;
288
+ margin-top:8px;padding:6px 14px;
289
+ background:var(--green-dim);border:1px solid var(--green);
290
+ color:var(--green);font-family:'IBM Plex Mono',monospace;
291
+ font-size:11px;font-weight:500;cursor:pointer;border-radius:var(--r);
292
+ text-decoration:none;transition:all .15s;
293
+ }
294
+ .dl-btn:hover{background:var(--green);color:#000}
295
+
296
+ /* ORCHESTRATION LOG */
297
+ .orch-log{
298
+ grid-column:1/-1;
299
+ background:var(--surface);border:1px solid var(--border);
300
+ border-radius:var(--r);padding:10px 14px;margin-top:4px;
301
+ display:none;
302
+ }
303
+ .orch-log.show{display:block}
304
+ .orch-title{font-family:'VT323',monospace;font-size:14px;color:var(--text2);letter-spacing:2px;margin-bottom:8px}
305
+ .orch-events{display:flex;flex-direction:column;gap:3px}
306
+ .orch-event{font-size:10px;color:var(--text2);display:flex;gap:8px}
307
+ .orch-time{color:var(--text3);flex-shrink:0}
308
+
309
+ /* SERVER ROOM */
310
+ .server-room{
311
+ grid-column:1/-1;
312
+ background:var(--surface);border:1px solid var(--border);
313
+ border-radius:var(--r);padding:10px 14px;
314
+ display:flex;align-items:center;gap:14px;
315
+ position:relative;overflow:hidden;
316
+ }
317
+ .server-room::before{
318
+ content:'SERVER ROOM';
319
+ position:absolute;top:8px;left:14px;
320
+ font-family:'VT323',monospace;font-size:12px;color:var(--text3);letter-spacing:3px;
321
+ }
322
+ .sroom-racks{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
323
+ .big-rack{background:#0d1117;border:1px solid #1e2a40;padding:4px;width:44px;display:flex;flex-direction:column;gap:2px}
324
+ .big-unit{height:8px;background:#111827;border:1px solid #1e2a40;display:flex;align-items:center;gap:2px;padding:0 2px}
325
+ .srv-stats{margin-left:auto;display:flex;flex-direction:column;gap:4px;font-size:10px;color:var(--text2)}
326
+ .srv-stat{display:flex;align-items:center;gap:5px}
327
+
328
+ /* ── ADD AGENT MODAL ── */
329
+ .modal-bg{
330
+ position:fixed;inset:0;background:rgba(0,0,0,.7);
331
+ z-index:200;display:none;align-items:center;justify-content:center;
332
+ }
333
+ .modal-bg.show{display:flex}
334
+ .modal{
335
+ background:var(--surface);border:1px solid var(--border2);
336
+ border-radius:var(--r);padding:24px;width:400px;
337
+ box-shadow:var(--shadow);
338
+ }
339
+ .modal-title{font-family:'VT323',monospace;font-size:22px;color:var(--accent);letter-spacing:2px;margin-bottom:18px}
340
+ .field{margin-bottom:12px}
341
+ .field label{display:block;font-size:11px;color:var(--text2);letter-spacing:1px;margin-bottom:4px;text-transform:uppercase}
342
+ .field input,.field select,.field textarea{
343
+ width:100%;background:var(--surface2);border:1px solid var(--border);
344
+ color:var(--text);font-family:'IBM Plex Mono',monospace;font-size:12px;
345
+ padding:7px 10px;outline:none;border-radius:var(--r);
346
+ transition:border-color .15s;
347
+ }
348
+ .field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent)}
349
+ .field textarea{resize:vertical;min-height:60px}
350
+ .field select option{background:var(--surface2)}
351
+ .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
352
+ .mbtn{
353
+ padding:7px 18px;border-radius:var(--r);
354
+ font-family:'IBM Plex Mono',monospace;font-size:12px;cursor:pointer;
355
+ transition:all .15s;border:1px solid;
356
+ }
357
+ .mbtn-confirm{background:var(--accent);border-color:var(--accent);color:#000}
358
+ .mbtn-confirm:hover{background:#79b8ff}
359
+ .mbtn-cancel{background:transparent;border-color:var(--border);color:var(--text2)}
360
+ .mbtn-cancel:hover{border-color:var(--text2)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
  /* ── SIDEBAR ── */
363
+ .sidebar{background:var(--surface);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
364
+ .sb-sec{border-bottom:1px solid var(--border);flex-shrink:0}
365
+ .sb-title{font-family:'VT323',monospace;font-size:14px;letter-spacing:3px;color:var(--text2);padding:10px 14px 8px;border-bottom:1px solid var(--border)}
366
+ .act-scroll{flex:1;overflow-y:auto;max-height:260px}
367
+ .act-item{padding:7px 14px;border-bottom:1px solid rgba(48,54,61,.4);display:flex;gap:8px}
368
+ .act-dot{width:5px;height:5px;border-radius:50%;margin-top:5px;flex-shrink:0}
369
+ .act-msg{font-size:11px;color:var(--text);line-height:1.4}
370
+ .act-time{font-size:10px;color:var(--text3);margin-top:1px}
371
+ .hist-scroll{overflow-y:auto;max-height:200px}
372
+ .hist-item{padding:8px 14px;border-bottom:1px solid rgba(48,54,61,.4);cursor:pointer;transition:background .1s}
373
+ .hist-item:hover{background:var(--surface2)}
374
+ .hist-task{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
375
+ .hist-meta{font-size:10px;color:var(--text3);margin-top:2px;display:flex;gap:8px}
376
+
377
+ /* notification */
378
+ .notif{
379
+ position:fixed;top:60px;right:16px;
380
+ background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--green);
381
+ padding:12px 16px;max-width:280px;z-index:999;
382
+ transform:translateX(300px);transition:transform .3s ease;
383
+ border-radius:var(--r);box-shadow:var(--shadow);
384
+ }
385
+ .notif.show{transform:translateX(0)}
386
+ .notif-t{font-family:'VT323',monospace;font-size:18px;letter-spacing:1px}
387
+ .notif-m{font-size:11px;color:var(--text2);margin-top:2px;line-height:1.4}
388
+
389
+ ::-webkit-scrollbar{width:4px}
390
+ ::-webkit-scrollbar-track{background:transparent}
391
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  </style>
393
  </head>
394
  <body>
395
 
 
396
  <header class="topbar">
397
+ <div class="tb-left">
398
+ <div class="tb-logo">MC</div>
399
+ <span class="tb-title">MISSION CONTROL AI</span>
 
400
  </div>
401
  <div class="tb-right">
402
+ <div class="tb-stat"><div class="sdot dot-g"></div><span id="top-active">0 active</span></div>
403
+ <div class="tb-stat"><div class="sdot dot-d" id="top-dot"></div><span id="top-status">Idle</span></div>
404
  <div class="clock" id="clock">00:00:00</div>
405
  </div>
406
  </header>
407
 
408
+ <div class="progress" id="progress"></div>
409
 
410
+ <div class="mbar">
411
+ <input class="minput" id="task-input" placeholder="Describe the mission... (e.g. 'Write a formal report about space exploration with images')" autocomplete="off"/>
412
+ <button class="launch" id="launch-btn" onclick="launchMission()">&#9658; LAUNCH</button>
413
+ <button class="add-agent-btn" onclick="openModal()">+ Add Agent</button>
414
+ <span class="mc" id="mc-count">MISSIONS: 0</span>
415
  </div>
416
 
 
417
  <div class="main">
418
+ <div class="office">
419
+ <div class="floor-label">
420
+ <span>&#9632; Operations Floor</span>
421
+ <span style="font-size:10px;color:var(--text3)" id="floor-subtitle">Waiting for mission</span>
422
  </div>
423
+ <div class="ogrid" id="ogrid"></div>
424
  </div>
425
 
426
  <aside class="sidebar">
427
+ <div class="sb-sec">
428
+ <div class="sb-title">ACTIVITY</div>
429
+ <div class="act-scroll" id="act-log"></div>
430
  </div>
431
+ <div class="sb-sec">
432
+ <div class="sb-title">MISSIONS</div>
433
+ <div class="hist-scroll" id="hist-log">
434
+ <div style="padding:14px;font-size:11px;color:var(--text3);text-align:center">No missions yet</div>
435
  </div>
436
  </div>
437
  </aside>
438
  </div>
439
 
440
+ <!-- ADD AGENT MODAL -->
441
+ <div class="modal-bg" id="modal-bg" onclick="if(event.target===this)closeModal()">
442
+ <div class="modal">
443
+ <div class="modal-title">+ NEW AGENT</div>
444
+ <div class="field">
445
+ <label>Key (unique ID)</label>
446
+ <input id="m-key" placeholder="e.g. researcher" />
447
+ </div>
448
+ <div class="field">
449
+ <label>Display Name</label>
450
+ <input id="m-name" placeholder="e.g. Researcher" />
451
+ </div>
452
+ <div class="field">
453
+ <label>Role / System Prompt</label>
454
+ <textarea id="m-role" placeholder="e.g. Especialista en investigaciΓ³n cientΓ­fica..."></textarea>
455
+ </div>
456
+ <div class="field">
457
+ <label>Provider</label>
458
+ <select id="m-provider">
459
+ <option value="openrouter">OpenRouter (free models)</option>
460
+ <option value="gemini">Google Gemini</option>
461
+ <option value="groq">Groq</option>
462
+ </select>
463
+ </div>
464
+ <div class="modal-actions">
465
+ <button class="mbtn mbtn-cancel" onclick="closeModal()">Cancel</button>
466
+ <button class="mbtn mbtn-confirm" onclick="submitAgent()">Add Agent</button>
467
+ </div>
468
+ </div>
469
+ </div>
470
+
471
  <!-- NOTIFICATION -->
472
  <div class="notif" id="notif">
473
+ <div class="notif-t" id="notif-t">Mission Complete</div>
474
+ <div class="notif-m" id="notif-m"></div>
475
  </div>
476
 
477
  <script>
478
+ const PROVIDER_MODELS = {
479
+ openrouter: ['meta-llama/llama-3.3-70b-instruct:free','qwen/qwen3-4b:free','mistralai/mistral-small-3.1-24b-instruct:free'],
480
+ gemini: ['gemini-2.5-flash-preview-04-17','gemini-2.0-flash','gemini-1.5-flash'],
481
+ groq: ['llama-3.3-70b-versatile','llama3-70b-8192','gemma2-9b-it'],
482
+ };
483
 
484
  const SPEECH = {
485
+ manager: ['Analyzing task...','Planning approach...','Delegating to team...','Reviewing scope...'],
486
+ developer: ['Building solution...','Writing code...','Testing logic...'],
487
+ analyst: ['Analyzing data...','Evaluating risks...','Reviewing document...'],
488
+ writer: ['Drafting content...','Structuring report...','Writing sections...'],
489
+ image_agent: ['Searching images...','Finding visuals...','Fetching resources...'],
490
  };
491
 
492
+ const COLORS = {
493
+ manager:'#58a6ff', developer:'#3fb950', analyst:'#d29922',
494
+ writer:'#bc8cff', image_agent:'#f78166',
495
+ };
 
 
496
 
497
+ let agentDefs = [];
498
+ let states = {};
499
+ let activity = [];
500
+ let history = [];
501
+ let mCount = 0;
502
+ let bubbleT = {};
503
+
504
+ // Load agents from API
505
+ async function loadAgents() {
506
+ const r = await fetch('/api/agents');
507
+ const d = await r.json();
508
+ agentDefs = d.agents;
509
+ agentDefs.forEach(a => {
510
+ if (!states[a.key]) states[a.key] = { status:'idle', message:'', model:'' };
511
+ });
512
+ renderOffice();
513
+ }
514
 
515
  // CLOCK
516
  setInterval(() => {
 
519
  [n.getHours(),n.getMinutes(),n.getSeconds()].map(x=>String(x).padStart(2,'0')).join(':');
520
  }, 1000);
521
 
522
+ // ── SVG BUILDERS ─────────────────────────────────────────────────────────
523
+ function personSVG(color, status) {
524
+ const op = status === 'resting' ? '0.2' : '1';
525
+ const anim = status === 'working'
526
+ ? 'style="animation:arm-t .28s ease-in-out infinite alternate;transform-origin:13px 18px"'
 
527
  : '';
 
 
 
 
528
  return `<svg viewBox="0 0 32 52" width="32" height="52" style="image-rendering:pixelated;display:block;opacity:${op}">
529
+ <style>@keyframes arm-t{0%{transform:translateY(0)rotate(0)}100%{transform:translateY(2px)rotate(-5deg)}}</style>
530
+ <rect x="11" y="0" width="10" height="3" fill="#1a1a2a"/>
531
+ <rect x="9" y="3" width="14" height="2" fill="#1a1a2a"/>
532
+ <rect x="9" y="3" width="2" height="5" fill="#1a1a2a"/>
533
+ <rect x="21" y="3" width="2" height="5" fill="#1a1a2a"/>
534
+ <rect x="9" y="5" width="14" height="12" fill="#f0c8a0"/>
 
 
 
535
  <rect x="7" y="8" width="2" height="4" fill="#f0c8a0"/>
536
  <rect x="23" y="8" width="2" height="4" fill="#f0c8a0"/>
537
  <rect x="11" y="9" width="4" height="3" fill="${color}" opacity=".9"/>
 
539
  <rect x="12" y="10" width="2" height="2" fill="#111"/>
540
  <rect x="18" y="10" width="2" height="2" fill="#111"/>
541
  <rect x="15" y="10" width="2" height="1" fill="${color}"/>
542
+ <rect x="13" y="14" width="6" height="1" fill="#c88060" opacity=".5"/>
543
+ <rect x="9" y="17" width="14" height="11" fill="${color}" opacity=".85"/>
544
+ <rect x="11" y="17" width="10" height="3" fill="${color}"/>
545
+ <g ${anim}>
546
+ <rect x="4" y="18" width="5" height="8" fill="${color}" opacity=".85"/>
547
+ <rect x="23" y="18" width="5" height="8" fill="${color}" opacity=".85"/>
548
+ <rect x="3" y="25" width="5" height="4" fill="#f0c8a0"/>
549
+ <rect x="24" y="25" width="5" height="4" fill="#f0c8a0"/>
550
  </g>
551
+ <rect x="9" y="28" width="14" height="12" fill="#1e2535"/>
552
+ <rect x="13" y="40" width="5" height="7" fill="#1e2535"/>
553
+ <rect x="9" y="40" width="5" height="7" fill="#1e2535"/>
554
+ <rect x="7" y="47" width="7" height="4" fill="#111"/>
555
+ <rect x="18" y="47" width="7" height="4" fill="#111"/>
556
  </svg>`;
557
  }
558
 
559
+ function chairSVG(empty) {
560
+ const c = empty ? '#111827' : '#1e2a40', s = empty ? '#0d1117' : '#161b22';
561
+ return `<svg viewBox="0 0 26 30" width="26" height="30" style="image-rendering:pixelated;display:block">
562
+ <rect x="3" y="3" width="20" height="13" fill="${c}" rx="1"/>
563
+ <rect x="4" y="4" width="18" height="11" fill="${s}"/>
564
+ <rect x="3" y="16" width="20" height="4" fill="${c}"/>
565
+ <rect x="3" y="0" width="4" height="16" fill="${c}"/>
566
+ <rect x="5" y="20" width="3" height="8" fill="${c}"/>
567
+ <rect x="18" y="20" width="3" height="8" fill="${c}"/>
568
+ <rect x="3" y="27" width="5" height="3" fill="#0d1117"/>
569
+ <rect x="18" y="27" width="5" height="3" fill="#0d1117"/>
 
 
570
  </svg>`;
571
  }
572
 
 
573
  function coffeeSVG() {
574
+ return `<svg viewBox="0 0 13 13" width="13" height="13" style="image-rendering:pixelated;display:block">
575
+ <rect x="2" y="4" width="7" height="7" fill="#5c3a1e"/>
576
+ <rect x="2" y="4" width="7" height="2" fill="#7a4e2a"/>
577
+ <rect x="9" y="6" width="2" height="3" fill="#5c3a1e"/>
578
+ <rect x="1" y="11" width="9" height="2" fill="#4a2e14"/>
579
+ <rect x="4" y="2" width="1" height="2" fill="#555" opacity=".5"/>
580
+ <rect x="6" y="1" width="1" height="3" fill="#555" opacity=".4"/>
 
581
  </svg>`;
582
  }
583
 
584
+ function serverRack() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  const units = [
586
+ ['g','g','1.1s','1.7s',true],['a','b','2.3s','.8s',true],
587
+ ['g','o','1.8s',null,false], ['b','g','.7s','2.1s',true],
588
+ ['a','a','1.4s','1.9s',false],['g','b','2.6s','.6s',true],
 
 
 
589
  ];
590
+ return units.map(([l1,l2,d1,d2,act]) =>
591
+ `<div class="runit">
592
+ <div class="rled l${l1}" style="--d:${d1}"></div>
593
+ <div class="rled l${l2 !== 'o' ? l2 : 'o'}" ${d2 ? `style="--d:${d2}"` : ''}></div>
594
+ <div class="rbar ${act?'on':''}"></div>
595
+ </div>`
596
+ ).join('');
597
+ }
598
+
599
+ function screenContent(status) {
600
+ if (status === 'working') return `<div class="tdots"><div class="tdot"></div><div class="tdot"></div><div class="tdot"></div></div>`;
601
+ if (status === 'active') return `<div class="sline" style="width:80%"></div><div class="sline" style="width:55%;opacity:.4"></div><div class="sline" style="width:68%;opacity:.3"></div><div class="scur"></div>`;
602
+ if (status === 'resting') return `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:'VT323',monospace;font-size:11px;color:var(--amber);letter-spacing:1px;opacity:.6">AWAY</div>`;
603
+ return `<div class="sline" style="width:40%;opacity:.2"></div><div class="sline" style="width:28%;opacity:.15"></div>`;
604
  }
605
 
606
+ // ── BUILD ROOM ─────────────────────────────────────────────────────────────
607
  function buildRoom(agent) {
608
+ const st = states[agent.key] || { status:'idle', message:'', model:'' };
609
  const status = st.status;
610
+ const color = COLORS[agent.key] || '#58a6ff';
611
+ const empty = status === 'resting';
612
+
613
+ const badgeMap = { idle:'IDLE', working:'PROCESSING', active:'DONE', resting:'AWAY', error:'ERROR' };
614
+ const badgeCls = { idle:'b-idle', working:'b-working', active:'b-active', resting:'b-resting', error:'b-error' };
615
+ const roomCls = { idle:'idle', working:'working', active:'active', resting:'resting', error:'resting' };
616
+
617
+ const monCls = status === 'working' ? 'thinking' : status === 'active' ? 'done' : status === 'resting' ? 'away' : '';
618
+
619
+ let resultHTML = '';
620
+ if (status === 'idle') {
621
+ resultHTML = `<div class="rph">Waiting for mission...</div>`;
622
+ } else if (status === 'working') {
623
+ resultHTML = `<div class="rph" style="color:var(--accent)">Processing task...</div>`;
624
+ } else if (status === 'resting') {
625
+ resultHTML = `<div class="rph" style="color:var(--amber)">Rate limit β€” back in ~1-2 min</div>`;
626
+ } else {
627
+ const msg = st.message || '';
628
+ resultHTML = `<div class="rtxt" id="result-${agent.key}"></div>
629
+ ${st.model ? `<div class="rmodel"><div class="sdot dot-g" style="width:5px;height:5px"></div>${st.model}</div>` : ''}
630
+ ${st.doc_file ? `<a class="dl-btn" href="/api/docs/${st.doc_file}" download>⬇ Download .docx</a>` : ''}`;
631
+ }
 
 
632
 
633
+ return `<div class="room ${roomCls[status]||'idle'}" id="room-${agent.key}">
634
+ <div class="rwall">
635
+ <span class="rname" style="color:${color}">${agent.name}</span>
636
+ <span class="rbadge ${badgeCls[status]||'b-idle'}">${badgeMap[status]||'IDLE'}</span>
637
+ </div>
638
+ <div class="rscene" id="scene-${agent.key}">
639
+ <div class="srack">${serverRack()}</div>
640
+ <div class="deskarea">
641
+ <div class="monitor ${monCls}">
642
+ <div class="mscreen">${screenContent(status)}</div>
643
+ <div class="mbase"></div>
644
  </div>
645
+ <div class="desk-surface"></div>
646
+ <div class="keyboard"><div class="key"></div><div class="key"></div><div class="key"></div><div class="key"></div></div>
 
 
647
  </div>
648
+ <div style="position:absolute;bottom:0;right:4px;z-index:1">${chairSVG(empty)}</div>
649
+ <div class="coffee">${coffeeSVG()}</div>
650
+ ${!empty ? `<div class="person">${personSVG(color, status)}</div>` : ''}
651
+ ${empty ? `<div class="ooo"><div class="ooo-t">β˜• AWAY</div><div class="ooo-s">Rate limited</div></div>` : ''}
652
+ <div class="bubble" id="bubble-${agent.key}"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  </div>
654
+ <div class="rresult" id="result-area-${agent.key}">${resultHTML}</div>
655
  </div>`;
656
  }
657
 
 
658
  function buildServerRoom() {
659
+ const racks = Array.from({length:5},()=>{
660
+ const units = [['g','1.1s'],['b','.8s'],['a','2s'],['g','1.6s'],['b','.5s']].map(([c,d])=>
661
+ `<div class="big-unit"><div class="rled l${c}" style="--d:${d}"></div><div class="rbar on"></div></div>`
662
+ ).join('');
 
 
 
 
 
663
  return `<div class="big-rack">${units}</div>`;
664
  }).join('');
665
 
666
  return `<div class="server-room">
667
+ <div class="sroom-racks">${racks}</div>
668
+ <div class="srv-stats">
669
+ <div class="srv-stat"><div class="sdot dot-g"></div><span>FastAPI online</span></div>
670
+ <div class="srv-stat"><div class="sdot dot-g" id="dot-gemini"></div><span>Gemini</span></div>
671
+ <div class="srv-stat"><div class="sdot dot-g" id="dot-orouter"></div><span>OpenRouter</span></div>
672
+ <div class="srv-stat"><div class="sdot dot-g" id="dot-groq"></div><span>Groq (backup)</span></div>
673
  </div>
674
  </div>`;
675
  }
676
 
677
+ function renderOffice(orchEvents) {
678
+ const grid = document.getElementById('ogrid');
679
+ let html = agentDefs.map(buildRoom).join('');
680
+ html += buildServerRoom();
681
+
682
+ if (orchEvents && orchEvents.length) {
683
+ const evHtml = orchEvents.map(e =>
684
+ `<div class="orch-event"><span class="orch-time">${e.time}</span><span>${e.msg}</span></div>`
685
+ ).join('');
686
+ html += `<div class="orch-log show">
687
+ <div class="orch-title">ORCHESTRATION LOG</div>
688
+ <div class="orch-events">${evHtml}</div>
689
+ </div>`;
690
+ }
691
+
692
+ grid.innerHTML = html;
693
+
694
+ // Fill typewriter results
695
+ agentDefs.forEach(a => {
696
+ const st = states[a.key];
697
+ if (st && st.status === 'active' && st.message && st._typewrite) {
698
+ const el = document.getElementById(`result-${a.key}`);
699
+ if (el) typeWriter(el, st.message);
700
+ }
701
+ });
702
+
703
+ const active = agentDefs.filter(a => (states[a.key]||{}).status === 'working').length;
704
  document.getElementById('top-active').textContent = `${active} active`;
705
  }
706
 
707
  // TYPEWRITER
708
+ function typeWriter(el, text, speed=14) {
709
+ el.textContent = ''; let i = 0;
 
710
  const iv = setInterval(() => {
711
  if (i < text.length) el.textContent += text[i++];
712
  else clearInterval(iv);
713
  }, speed);
714
  }
715
 
716
+ // SPEECH
717
  function showBubble(key, text, ms=2800) {
718
  const b = document.getElementById(`bubble-${key}`);
719
  if (!b) return;
720
+ if (bubbleT[key]) clearTimeout(bubbleT[key]);
721
  b.textContent = text;
722
+ b.classList.add('show');
723
+ bubbleT[key] = setTimeout(() => b.classList.remove('show'), ms);
 
 
 
 
724
  }
725
 
726
  // ACTIVITY
727
+ function addAct(msg, color='#58a6ff') {
728
  const now = new Date();
729
  const t = [now.getHours(),now.getMinutes(),now.getSeconds()].map(x=>String(x).padStart(2,'0')).join(':');
730
  activity.unshift({ msg, t, color });
731
+ document.getElementById('act-log').innerHTML = activity.slice(0,30).map(a=>
732
+ `<div class="act-item">
 
733
  <div class="act-dot" style="background:${a.color}"></div>
734
+ <div><div class="act-msg">${a.msg}</div><div class="act-time">${a.t}</div></div>
735
+ </div>`
736
+ ).join('');
 
 
737
  }
738
 
739
+ // NOTIFY
740
+ function notify(title, msg, color='#3fb950') {
741
+ document.getElementById('notif-t').textContent = title;
742
+ document.getElementById('notif-t').style.color = color;
743
+ document.getElementById('notif-m').textContent = msg;
744
+ document.getElementById('notif').style.borderLeftColor = color;
745
  const el = document.getElementById('notif');
 
746
  el.classList.add('show');
747
  setTimeout(() => el.classList.remove('show'), 5000);
748
  }
749
 
750
  // HISTORY
751
  function updateHistory() {
752
+ const list = document.getElementById('hist-log');
753
  if (!history.length) {
754
+ list.innerHTML = `<div style="padding:14px;font-size:11px;color:var(--text3);text-align:center">No missions yet</div>`;
755
  return;
756
  }
757
+ list.innerHTML = [...history].reverse().map(m=>
758
+ `<div class="hist-item">
759
  <div class="hist-task">${m.task}</div>
760
  <div class="hist-meta">
761
  <span>${m.time}</span>
762
  <span style="color:${m.ok?'var(--green)':'var(--amber)'}">${m.ok?'Done':'Partial'}</span>
763
+ ${m.doc_file ? '<span style="color:var(--purple)">πŸ“„ docx</span>' : ''}
764
  </div>
765
+ </div>`
766
+ ).join('');
767
  }
768
 
769
+ // MODAL
770
+ function openModal() { document.getElementById('modal-bg').classList.add('show'); }
771
+ function closeModal() { document.getElementById('modal-bg').classList.remove('show'); }
772
+
773
+ async function submitAgent() {
774
+ const key = document.getElementById('m-key').value.trim();
775
+ const name = document.getElementById('m-name').value.trim() || key;
776
+ const role = document.getElementById('m-role').value.trim() || 'General purpose agent.';
777
+ const prov = document.getElementById('m-provider').value;
778
+ if (!key) { alert('Key is required'); return; }
779
+
780
+ const r = await fetch('/api/agents/add', {
781
+ method:'POST', headers:{'Content-Type':'application/json'},
782
+ body: JSON.stringify({ key, name, role, provider: prov, models: PROVIDER_MODELS[prov] }),
 
 
 
 
 
 
783
  });
784
+ const d = await r.json();
785
+ if (d.success) {
786
+ agentDefs.push({ key: d.agent.key, name: d.agent.name, role: d.agent.role });
787
+ states[d.agent.key] = { status:'idle', message:'', model:'' };
788
+ renderOffice();
789
+ addAct(`Agent "${name}" added`, '#bc8cff');
790
+ closeModal();
791
+ ['m-key','m-name','m-role'].forEach(id => document.getElementById(id).value = '');
792
+ }
793
  }
794
 
795
+ // ── LAUNCH ─────────────────────────────────────────────────────────────────
796
  async function launchMission() {
797
  const task = document.getElementById('task-input').value.trim();
798
  if (!task) return;
799
 
800
  const btn = document.getElementById('launch-btn');
801
  btn.disabled = true;
802
+ document.getElementById('progress').classList.add('on');
803
+ document.getElementById('top-status').textContent = 'Running';
804
+ document.getElementById('top-dot').className = 'sdot dot-a';
805
+ document.getElementById('floor-subtitle').textContent = `Mission: "${task.substring(0,50)}${task.length>50?'...':''}"`;
806
 
807
+ // Set all agents working
808
+ agentDefs.forEach(a => { states[a.key] = { status:'working', message:'', model:'' }; });
809
  renderOffice();
810
 
811
+ // Stagger speech bubbles
812
+ agentDefs.forEach((a, i) => {
813
  setTimeout(() => {
814
+ const phrases = SPEECH[a.key] || ['Working...'];
815
  showBubble(a.key, phrases[Math.floor(Math.random()*phrases.length)], 3000);
816
+ }, i * 600);
817
  });
818
 
819
+ addAct(`Mission: "${task.substring(0,40)}"`, '#58a6ff');
 
820
 
821
  try {
822
  const resp = await fetch('/api/mission', {
823
+ method:'POST', headers:{'Content-Type':'application/json'},
 
824
  body: JSON.stringify({ task }),
825
  });
 
826
  const data = await resp.json();
827
  if (!resp.ok || data.error) throw new Error(data.error || 'Server error');
828
 
829
  let anyResting = false;
830
+ let hasDoc = false;
831
+
832
+ // Update states from results
833
+ agentDefs.forEach(a => {
834
+ const r = data.results[a.key];
835
+ if (!r) {
836
+ states[a.key] = { status:'idle', message:'', model:'' };
837
+ return;
838
+ }
839
+ if (r.status === 'resting') anyResting = true;
840
+ const docFile = (a.key === 'manager' && data.doc_file) ? data.doc_file : null;
841
+ if (docFile) hasDoc = true;
842
+ states[a.key] = {
843
+ status: r.status === 'resting' ? 'resting' : r.status === 'idle' ? 'idle' : 'active',
844
+ message: r.message || '',
845
+ model: r.model || '',
846
+ doc_file: docFile,
847
+ _typewrite: true,
848
+ };
849
  });
850
 
851
+ renderOffice(data.events);
852
 
853
+ // Stagger bubbles for active agents
854
+ agentDefs.forEach((a, i) => {
855
+ if ((states[a.key]||{}).status === 'active') {
 
856
  setTimeout(() => {
857
+ showBubble(a.key, 'Done βœ“', 2000);
858
+ addAct(`${a.name}: complete`, '#3fb950');
859
+ }, i * 400);
 
 
 
 
 
860
  }
861
  });
862
 
863
+ if (hasDoc) addAct('Word document ready for download', '#bc8cff');
 
864
 
865
+ mCount++;
866
+ document.getElementById('mc-count').textContent = `MISSIONS: ${mCount}`;
867
  const time = new Date().toLocaleTimeString();
868
+ history.push({ task, time, ok: !anyResting, doc_file: data.doc_file });
869
  updateHistory();
870
 
871
  setTimeout(() => {
872
  notify(
873
+ anyResting ? 'Mission Partial' : hasDoc ? 'Document Ready βœ“' : 'Mission Complete βœ“',
874
  anyResting
875
+ ? 'Some agents rate-limited. Try again in ~2 min.'
876
+ : hasDoc
877
+ ? 'Your .docx document is ready to download!'
878
+ : `All agents responded. Mission #${mCount} done.`,
879
+ anyResting ? '#d29922' : hasDoc ? '#bc8cff' : '#3fb950'
880
  );
881
+ }, agentDefs.length * 400 + 300);
882
 
883
+ document.getElementById('top-status').textContent = anyResting ? 'Partial' : 'Done';
884
+ document.getElementById('top-dot').className = `sdot ${anyResting ? 'dot-a' : 'dot-g'}`;
885
+ document.getElementById('floor-subtitle').textContent = anyResting ? 'Partial results' : 'Mission complete';
886
 
887
  } catch(err) {
888
+ agentDefs.forEach(a => { states[a.key] = { status:'resting', message: err.message, model:'' }; });
889
  renderOffice();
890
+ addAct(`Error: ${err.message}`, '#f85149');
891
+ notify('Mission Failed', err.message, '#f85149');
892
+ document.getElementById('top-dot').className = 'sdot dot-r';
893
  document.getElementById('top-status').textContent = 'Error';
 
894
  }
895
 
896
  btn.disabled = false;
897
+ document.getElementById('progress').classList.remove('on');
898
  }
899
 
900
  document.getElementById('task-input').addEventListener('keydown', e => {
 
902
  });
903
 
904
  // INIT
905
+ loadAgents();
906
+ addAct('System online β€” agents ready', '#3fb950');
907
+ addAct('Server rack nominal', '#58a6ff');
908
  </script>
909
  </body>
910
  </html>