robopark 2.8.25 → 2.8.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scheduler/main.py CHANGED
@@ -83,6 +83,7 @@ class Session(BaseModel):
83
83
  ended_at: Optional[datetime] = None
84
84
  end_reason: Optional[str] = None
85
85
  duration_seconds: Optional[int] = None
86
+ voice_config: Optional[dict] = None
86
87
 
87
88
  class GpuModel(BaseModel):
88
89
  name: str
@@ -183,6 +184,55 @@ class LiveKitTokenResponse(BaseModel):
183
184
  # DATABASE
184
185
  # =============================================================================
185
186
 
187
+ async def _seed_voice_stacks_and_characters(db):
188
+ """Seed default voice stacks and production park character presets.
189
+
190
+ Idempotent: uses INSERT OR IGNORE so existing rows are never overwritten.
191
+ This lets operators customize via the dashboard without losing changes on
192
+ scheduler restart."""
193
+ default_stacks = [
194
+ (
195
+ "english-default", "English Default",
196
+ "speaches", "Systran/faster-whisper-small", "en",
197
+ "ollama", "gemma4:e4b",
198
+ "elevenlabs", "21m00Tcm4TlvDq8ikWAM", "en",
199
+ 1, 0.7, 20, 0,
200
+ ),
201
+ (
202
+ "hebrew-default", "Hebrew Default",
203
+ "speaches", "ivrit-ai/whisper-large-v3-turbo-ct2", "he",
204
+ "ollama", "gemma4:e4b",
205
+ "elevenlabs", "21m00Tcm4TlvDq8ikWAM", "he",
206
+ 1, 0.7, 20, 0,
207
+ ),
208
+ ]
209
+ for row in default_stacks:
210
+ await db.execute(
211
+ """INSERT OR IGNORE INTO voice_stacks
212
+ (id, name, stt_provider, stt_model, stt_language,
213
+ llm_provider, llm_model, tts_provider, tts_voice, tts_language,
214
+ allow_interruptions, min_endpointing_delay, max_turns, wake_word_enabled)
215
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
216
+ row,
217
+ )
218
+
219
+ presets = [
220
+ ("volt", "VOLT", "A fast, electric-blue sports robot. Loves speed and racing.", "english-default", _json.dumps(["drive", "boost"])),
221
+ ("tesla", "TESLA", "A clever inventor robot with lightning motifs. Curious and talkative.", "english-default", _json.dumps(["drive", "light"])),
222
+ ("jaguar", "JAGUAR", "A sleek, powerful predator robot. Confident and agile.", "english-default", _json.dumps(["drive", "pounce"])),
223
+ ("vixen", "VIXEN (BMW)", "A cunning, stylish urban robot. Witty and resourceful.", "english-default", _json.dumps(["drive", "navigate"])),
224
+ ("panda", "PANDA", "A friendly, cuddly panda robot. Gentle and helpful.", "english-default", _json.dumps(["drive", "wave"])),
225
+ ]
226
+ for preset in presets:
227
+ await db.execute(
228
+ """INSERT OR IGNORE INTO character_presets
229
+ (id, name, description, system_prompt, voice_stack_id, motors)
230
+ VALUES (?, ?, ?, ?, ?, ?)""",
231
+ (preset[0], preset[1], preset[2], None, preset[3], preset[4]),
232
+ )
233
+ await db.commit()
234
+
235
+
186
236
  async def init_db():
187
237
  """Initialize SQLite database"""
188
238
  os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
@@ -283,6 +333,53 @@ async def init_db():
283
333
  actor TEXT,
284
334
  details TEXT
285
335
  );
336
+
337
+ -- Scheduler-managed voice stacks (STT/LLM/TTS configuration)
338
+ CREATE TABLE IF NOT EXISTS voice_stacks (
339
+ id TEXT PRIMARY KEY,
340
+ name TEXT NOT NULL,
341
+ stt_provider TEXT DEFAULT 'speaches',
342
+ stt_model TEXT DEFAULT 'Systran/faster-whisper-small',
343
+ stt_language TEXT DEFAULT 'en',
344
+ llm_provider TEXT DEFAULT 'ollama',
345
+ llm_model TEXT DEFAULT 'gemma4:e4b',
346
+ tts_provider TEXT DEFAULT 'elevenlabs',
347
+ tts_voice TEXT DEFAULT '21m00Tcm4TlvDq8ikWAM',
348
+ tts_language TEXT DEFAULT 'en',
349
+ allow_interruptions INTEGER DEFAULT 1,
350
+ min_endpointing_delay REAL DEFAULT 0.7,
351
+ max_turns INTEGER DEFAULT 20,
352
+ wake_word_enabled INTEGER DEFAULT 0,
353
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
354
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
355
+ );
356
+
357
+ -- Scheduler-managed character presets (production park characters)
358
+ CREATE TABLE IF NOT EXISTS character_presets (
359
+ id TEXT PRIMARY KEY,
360
+ name TEXT NOT NULL,
361
+ description TEXT,
362
+ system_prompt TEXT,
363
+ voice_stack_id TEXT,
364
+ motors TEXT, -- JSON array
365
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
366
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
367
+ FOREIGN KEY (voice_stack_id) REFERENCES voice_stacks(id)
368
+ );
369
+
370
+ -- Per-robot/device effective voice config override.
371
+ -- Falls back to the assigned character preset's voice stack.
372
+ CREATE TABLE IF NOT EXISTS robot_voice_configs (
373
+ robot_id TEXT PRIMARY KEY,
374
+ character_preset_id TEXT,
375
+ voice_stack_id TEXT,
376
+ override_config TEXT, -- JSON merge on top of voice_stack
377
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
378
+ FOREIGN KEY (robot_id) REFERENCES robots(id) ON DELETE CASCADE,
379
+ FOREIGN KEY (character_preset_id) REFERENCES character_presets(id),
380
+ FOREIGN KEY (voice_stack_id) REFERENCES voice_stacks(id)
381
+ );
382
+
286
383
  CREATE INDEX IF NOT EXISTS idx_history_category ON history_log(category);
287
384
  CREATE INDEX IF NOT EXISTS idx_history_entity ON history_log(entity_type, entity_id);
288
385
  """)
@@ -317,6 +414,9 @@ async def init_db():
317
414
  # fleet view honest — only real connected hardware appears.
318
415
  await db.commit()
319
416
 
417
+ # Seed default voice stacks and production park character presets.
418
+ await _seed_voice_stacks_and_characters(db)
419
+
320
420
  # Seed default enrollment token + production_mode flag (only on first run)
321
421
  async with db.execute("SELECT value FROM settings WHERE key = 'default_enrollment_token'") as c:
322
422
  row = await c.fetchone()
@@ -1159,7 +1259,15 @@ async def health_checker():
1159
1259
  if resp.status_code == 200:
1160
1260
  new_status = "online"
1161
1261
  else:
1162
- new_status = "offline"
1262
+ # A bare LiveKit server (no sidecar webhook
1263
+ # service in front of it, e.g. `--dev` mode
1264
+ # with no separate health endpoint) has no
1265
+ # /health route and returns 404 here even
1266
+ # though it's genuinely up — LiveKit's own
1267
+ # root path returns "OK" instead. Fall back
1268
+ # to that before declaring it offline.
1269
+ resp2 = await client.get(server["webhook_url"])
1270
+ new_status = "online" if resp2.status_code == 200 else "offline"
1163
1271
  except:
1164
1272
  new_status = "offline"
1165
1273
 
@@ -2084,12 +2192,19 @@ async def device_request_session(device_id: str,
2084
2192
  .to_jwt()
2085
2193
  )
2086
2194
 
2195
+ # Resolve the per-robot voice/character config so the ROBOVOICE agent and the
2196
+ # robot client can apply it for this session. The agent fetches it via
2197
+ # /api/devices/by-room/{room_name}/voice-config; we include it here too so
2198
+ # the robot client can log/adjust local settings if needed.
2199
+ voice_config = await _resolve_robot_voice_config(device_id)
2200
+
2087
2201
  return {
2088
2202
  "session_id": session_id,
2089
2203
  "server_id": server["id"],
2090
2204
  "server_url": server["url"],
2091
2205
  "room_name": room_name,
2092
2206
  "token": token,
2207
+ "voice_config": voice_config.model_dump() if voice_config else None,
2093
2208
  }
2094
2209
 
2095
2210
  # =============================================================================
@@ -2109,6 +2224,67 @@ class Character(CharacterSummary):
2109
2224
  system_prompt: Optional[str] = None
2110
2225
  raw: dict = {}
2111
2226
 
2227
+ # Scheduler-managed character presets (production park characters + custom).
2228
+ class CharacterPreset(BaseModel):
2229
+ id: str
2230
+ name: str
2231
+ description: Optional[str] = None
2232
+ system_prompt: Optional[str] = None
2233
+ voice_stack_id: Optional[str] = None
2234
+ motors: List[str] = []
2235
+ created_at: Optional[datetime] = None
2236
+ updated_at: Optional[datetime] = None
2237
+
2238
+ class CharacterPresetCreate(BaseModel):
2239
+ id: Optional[str] = None
2240
+ name: str
2241
+ description: Optional[str] = None
2242
+ system_prompt: Optional[str] = None
2243
+ voice_stack_id: Optional[str] = None
2244
+ motors: List[str] = []
2245
+
2246
+ class VoiceStack(BaseModel):
2247
+ id: str
2248
+ name: str
2249
+ stt_provider: str = "speaches"
2250
+ stt_model: str = "Systran/faster-whisper-small"
2251
+ stt_language: str = "en"
2252
+ llm_provider: str = "ollama"
2253
+ llm_model: str = "gemma4:e4b"
2254
+ tts_provider: str = "elevenlabs"
2255
+ tts_voice: str = "21m00Tcm4TlvDq8ikWAM"
2256
+ tts_language: str = "en"
2257
+ allow_interruptions: bool = True
2258
+ min_endpointing_delay: float = 0.7
2259
+ max_turns: int = 20
2260
+ wake_word_enabled: bool = False
2261
+ created_at: Optional[datetime] = None
2262
+ updated_at: Optional[datetime] = None
2263
+
2264
+ class VoiceStackCreate(BaseModel):
2265
+ id: Optional[str] = None
2266
+ name: str
2267
+ stt_provider: str = "speaches"
2268
+ stt_model: str = "Systran/faster-whisper-small"
2269
+ stt_language: str = "en"
2270
+ llm_provider: str = "ollama"
2271
+ llm_model: str = "gemma4:e4b"
2272
+ tts_provider: str = "elevenlabs"
2273
+ tts_voice: str = "21m00Tcm4TlvDq8ikWAM"
2274
+ tts_language: str = "en"
2275
+ allow_interruptions: bool = True
2276
+ min_endpointing_delay: float = 0.7
2277
+ max_turns: int = 20
2278
+ wake_word_enabled: bool = False
2279
+
2280
+ # Effective configuration returned to the ROBOVOICE agent for a given room/robot.
2281
+ class RobotVoiceConfig(BaseModel):
2282
+ character_id: Optional[str] = None
2283
+ character_name: Optional[str] = None
2284
+ system_prompt: Optional[str] = None
2285
+ voice_stack_id: Optional[str] = None
2286
+ voice_stack: Optional[VoiceStack] = None
2287
+
2112
2288
  def _load_robovoice_settings() -> dict:
2113
2289
  """Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
2114
2290
  try:
@@ -2161,6 +2337,348 @@ async def get_character(character_id: str):
2161
2337
  )
2162
2338
  raise HTTPException(404, f"Character '{character_id}' not found in {ROBOVOICE_SETTINGS_PATH}")
2163
2339
 
2340
+
2341
+ # ── Scheduler-managed character presets + voice stacks ─────────────────────
2342
+
2343
+ async def _resolve_voice_stack_for_robot(db, robot_id: str) -> dict:
2344
+ """Return the effective voice-stack dict for a robot/device id.
2345
+
2346
+ Resolution order:
2347
+ 1. robot_voice_configs.voice_stack_id override
2348
+ 2. character_presets.voice_stack_id assigned to the robot
2349
+ 3. 'english-default' fallback
2350
+ """
2351
+ # 1. robot_voice_configs row
2352
+ async with db.execute(
2353
+ "SELECT character_preset_id, voice_stack_id, override_config FROM robot_voice_configs WHERE robot_id = ?",
2354
+ (robot_id,),
2355
+ ) as c:
2356
+ rvc = await c.fetchone()
2357
+
2358
+ preset_id = None
2359
+ voice_stack_id = None
2360
+ override = {}
2361
+ if rvc:
2362
+ preset_id = rvc["character_preset_id"]
2363
+ voice_stack_id = rvc["voice_stack_id"]
2364
+ if rvc["override_config"]:
2365
+ try:
2366
+ override = _json.loads(rvc["override_config"])
2367
+ except Exception:
2368
+ pass
2369
+
2370
+ # 2. If no explicit voice_stack, fall back via character preset
2371
+ if not voice_stack_id:
2372
+ if not preset_id:
2373
+ async with db.execute(
2374
+ "SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
2375
+ (robot_id, robot_id),
2376
+ ) as c:
2377
+ row = await c.fetchone()
2378
+ if row:
2379
+ preset_id = row["character_id"]
2380
+ if preset_id:
2381
+ async with db.execute(
2382
+ "SELECT voice_stack_id FROM character_presets WHERE id = ?", (preset_id,)
2383
+ ) as c:
2384
+ row = await c.fetchone()
2385
+ if row:
2386
+ voice_stack_id = row["voice_stack_id"]
2387
+
2388
+ if not voice_stack_id:
2389
+ voice_stack_id = "english-default"
2390
+
2391
+ async with db.execute(
2392
+ "SELECT * FROM voice_stacks WHERE id = ?", (voice_stack_id,)
2393
+ ) as c:
2394
+ stack = await c.fetchone()
2395
+ if not stack:
2396
+ return {}
2397
+
2398
+ result = dict(stack)
2399
+ result.update(override)
2400
+ # SQLite stores booleans as 0/1; normalize
2401
+ for key in ("allow_interruptions", "wake_word_enabled"):
2402
+ if key in result:
2403
+ result[key] = bool(result[key])
2404
+ return result
2405
+
2406
+
2407
+ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2408
+ """Public helper to resolve the effective voice config for a robot id."""
2409
+ async with aiosqlite.connect(DB_PATH) as db:
2410
+ db.row_factory = aiosqlite.Row
2411
+ stack_dict = await _resolve_voice_stack_for_robot(db, robot_id)
2412
+
2413
+ # Determine character preset + name
2414
+ character_id = None
2415
+ character_name = None
2416
+ system_prompt = None
2417
+ async with db.execute(
2418
+ "SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (robot_id,)
2419
+ ) as c:
2420
+ rvc = await c.fetchone()
2421
+ if rvc and rvc["character_preset_id"]:
2422
+ character_id = rvc["character_preset_id"]
2423
+ else:
2424
+ async with db.execute(
2425
+ "SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
2426
+ (robot_id, robot_id),
2427
+ ) as c:
2428
+ row = await c.fetchone()
2429
+ if row:
2430
+ character_id = row["character_id"]
2431
+ if character_id:
2432
+ async with db.execute(
2433
+ "SELECT name, description, system_prompt FROM character_presets WHERE id = ?", (character_id,)
2434
+ ) as c:
2435
+ row = await c.fetchone()
2436
+ if row:
2437
+ character_name = row["name"]
2438
+ system_prompt = row["system_prompt"]
2439
+ if not character_name:
2440
+ async with db.execute(
2441
+ "SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
2442
+ (robot_id, robot_id),
2443
+ ) as c:
2444
+ row = await c.fetchone()
2445
+ if row:
2446
+ character_name = row["name"]
2447
+
2448
+ voice_stack = VoiceStack(**stack_dict) if stack_dict else None
2449
+ return RobotVoiceConfig(
2450
+ character_id=character_id,
2451
+ character_name=character_name,
2452
+ system_prompt=system_prompt,
2453
+ voice_stack_id=voice_stack.id if voice_stack else None,
2454
+ voice_stack=voice_stack,
2455
+ )
2456
+
2457
+
2458
+ @app.get("/api/voice-stacks", response_model=List[VoiceStack])
2459
+ async def list_voice_stacks():
2460
+ async with aiosqlite.connect(DB_PATH) as db:
2461
+ db.row_factory = aiosqlite.Row
2462
+ async with db.execute("SELECT * FROM voice_stacks ORDER BY name") as c:
2463
+ rows = await c.fetchall()
2464
+ return [VoiceStack(**dict(r)) for r in rows]
2465
+
2466
+
2467
+ @app.post("/api/voice-stacks", response_model=VoiceStack)
2468
+ async def create_voice_stack(payload: VoiceStackCreate):
2469
+ stack_id = payload.id or secrets.token_urlsafe(12)
2470
+ now = datetime.utcnow().isoformat()
2471
+ async with aiosqlite.connect(DB_PATH) as db:
2472
+ await db.execute(
2473
+ """INSERT INTO voice_stacks
2474
+ (id, name, stt_provider, stt_model, stt_language, llm_provider, llm_model,
2475
+ tts_provider, tts_voice, tts_language, allow_interruptions,
2476
+ min_endpointing_delay, max_turns, wake_word_enabled, created_at, updated_at)
2477
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
2478
+ (
2479
+ stack_id, payload.name, payload.stt_provider, payload.stt_model, payload.stt_language,
2480
+ payload.llm_provider, payload.llm_model, payload.tts_provider, payload.tts_voice,
2481
+ payload.tts_language, int(payload.allow_interruptions), payload.min_endpointing_delay,
2482
+ payload.max_turns, int(payload.wake_word_enabled), now, now,
2483
+ ),
2484
+ )
2485
+ await db.commit()
2486
+ return await _get_voice_stack(stack_id)
2487
+
2488
+
2489
+ async def _get_voice_stack(stack_id: str) -> VoiceStack:
2490
+ async with aiosqlite.connect(DB_PATH) as db:
2491
+ db.row_factory = aiosqlite.Row
2492
+ async with db.execute("SELECT * FROM voice_stacks WHERE id = ?", (stack_id,)) as c:
2493
+ row = await c.fetchone()
2494
+ if not row:
2495
+ raise HTTPException(404, "Voice stack not found")
2496
+ return VoiceStack(**dict(row))
2497
+
2498
+
2499
+ @app.get("/api/voice-stacks/{stack_id}", response_model=VoiceStack)
2500
+ async def get_voice_stack(stack_id: str):
2501
+ return await _get_voice_stack(stack_id)
2502
+
2503
+
2504
+ @app.put("/api/voice-stacks/{stack_id}", response_model=VoiceStack)
2505
+ async def update_voice_stack(stack_id: str, payload: VoiceStackCreate):
2506
+ now = datetime.utcnow().isoformat()
2507
+ async with aiosqlite.connect(DB_PATH) as db:
2508
+ async with db.execute("SELECT id FROM voice_stacks WHERE id = ?", (stack_id,)) as c:
2509
+ if not await c.fetchone():
2510
+ raise HTTPException(404, "Voice stack not found")
2511
+ await db.execute(
2512
+ """UPDATE voice_stacks SET
2513
+ name = ?, stt_provider = ?, stt_model = ?, stt_language = ?, llm_provider = ?,
2514
+ llm_model = ?, tts_provider = ?, tts_voice = ?, tts_language = ?,
2515
+ allow_interruptions = ?, min_endpointing_delay = ?, max_turns = ?,
2516
+ wake_word_enabled = ?, updated_at = ?
2517
+ WHERE id = ?""",
2518
+ (
2519
+ payload.name, payload.stt_provider, payload.stt_model, payload.stt_language,
2520
+ payload.llm_provider, payload.llm_model, payload.tts_provider, payload.tts_voice,
2521
+ payload.tts_language, int(payload.allow_interruptions), payload.min_endpointing_delay,
2522
+ payload.max_turns, int(payload.wake_word_enabled), now, stack_id,
2523
+ ),
2524
+ )
2525
+ await db.commit()
2526
+ return await _get_voice_stack(stack_id)
2527
+
2528
+
2529
+ @app.delete("/api/voice-stacks/{stack_id}")
2530
+ async def delete_voice_stack(stack_id: str):
2531
+ async with aiosqlite.connect(DB_PATH) as db:
2532
+ await db.execute("DELETE FROM voice_stacks WHERE id = ?", (stack_id,))
2533
+ await db.commit()
2534
+ return {"ok": True}
2535
+
2536
+
2537
+ @app.get("/api/character-presets", response_model=List[CharacterPreset])
2538
+ async def list_character_presets():
2539
+ async with aiosqlite.connect(DB_PATH) as db:
2540
+ db.row_factory = aiosqlite.Row
2541
+ async with db.execute("SELECT * FROM character_presets ORDER BY name") as c:
2542
+ rows = await c.fetchall()
2543
+ return [
2544
+ CharacterPreset(
2545
+ id=r["id"],
2546
+ name=r["name"],
2547
+ description=r["description"],
2548
+ system_prompt=r["system_prompt"],
2549
+ voice_stack_id=r["voice_stack_id"],
2550
+ motors=_json.loads(r["motors"]) if r["motors"] else [],
2551
+ created_at=r["created_at"],
2552
+ updated_at=r["updated_at"],
2553
+ )
2554
+ for r in rows
2555
+ ]
2556
+
2557
+
2558
+ @app.post("/api/character-presets", response_model=CharacterPreset)
2559
+ async def create_character_preset(payload: CharacterPresetCreate):
2560
+ preset_id = payload.id or secrets.token_urlsafe(12)
2561
+ now = datetime.utcnow().isoformat()
2562
+ async with aiosqlite.connect(DB_PATH) as db:
2563
+ await db.execute(
2564
+ """INSERT INTO character_presets
2565
+ (id, name, description, system_prompt, voice_stack_id, motors, created_at, updated_at)
2566
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
2567
+ (
2568
+ preset_id, payload.name, payload.description, payload.system_prompt,
2569
+ payload.voice_stack_id, _json.dumps(payload.motors or []), now, now,
2570
+ ),
2571
+ )
2572
+ await db.commit()
2573
+ return await _get_character_preset(preset_id)
2574
+
2575
+
2576
+ async def _get_character_preset(preset_id: str) -> CharacterPreset:
2577
+ async with aiosqlite.connect(DB_PATH) as db:
2578
+ db.row_factory = aiosqlite.Row
2579
+ async with db.execute("SELECT * FROM character_presets WHERE id = ?", (preset_id,)) as c:
2580
+ row = await c.fetchone()
2581
+ if not row:
2582
+ raise HTTPException(404, "Character preset not found")
2583
+ return CharacterPreset(
2584
+ id=row["id"],
2585
+ name=row["name"],
2586
+ description=row["description"],
2587
+ system_prompt=row["system_prompt"],
2588
+ voice_stack_id=row["voice_stack_id"],
2589
+ motors=_json.loads(row["motors"]) if row["motors"] else [],
2590
+ created_at=row["created_at"],
2591
+ updated_at=row["updated_at"],
2592
+ )
2593
+
2594
+
2595
+ @app.get("/api/character-presets/{preset_id}", response_model=CharacterPreset)
2596
+ async def get_character_preset(preset_id: str):
2597
+ return await _get_character_preset(preset_id)
2598
+
2599
+
2600
+ @app.put("/api/character-presets/{preset_id}", response_model=CharacterPreset)
2601
+ async def update_character_preset(preset_id: str, payload: CharacterPresetCreate):
2602
+ now = datetime.utcnow().isoformat()
2603
+ async with aiosqlite.connect(DB_PATH) as db:
2604
+ async with db.execute("SELECT id FROM character_presets WHERE id = ?", (preset_id,)) as c:
2605
+ if not await c.fetchone():
2606
+ raise HTTPException(404, "Character preset not found")
2607
+ await db.execute(
2608
+ """UPDATE character_presets SET
2609
+ name = ?, description = ?, system_prompt = ?, voice_stack_id = ?, motors = ?, updated_at = ?
2610
+ WHERE id = ?""",
2611
+ (
2612
+ payload.name, payload.description, payload.system_prompt,
2613
+ payload.voice_stack_id, _json.dumps(payload.motors or []), now, preset_id,
2614
+ ),
2615
+ )
2616
+ await db.commit()
2617
+ return await _get_character_preset(preset_id)
2618
+
2619
+
2620
+ @app.delete("/api/character-presets/{preset_id}")
2621
+ async def delete_character_preset(preset_id: str):
2622
+ async with aiosqlite.connect(DB_PATH) as db:
2623
+ await db.execute("DELETE FROM character_presets WHERE id = ?", (preset_id,))
2624
+ await db.commit()
2625
+ return {"ok": True}
2626
+
2627
+
2628
+ class RobotVoiceConfigUpdate(BaseModel):
2629
+ character_preset_id: Optional[str] = None
2630
+ voice_stack_id: Optional[str] = None
2631
+ override_config: Optional[dict] = None
2632
+
2633
+
2634
+ @app.get("/api/robots/{robot_id}/voice-config", response_model=RobotVoiceConfig)
2635
+ async def get_robot_voice_config(robot_id: str):
2636
+ return await _resolve_robot_voice_config(robot_id)
2637
+
2638
+
2639
+ @app.put("/api/robots/{robot_id}/voice-config", response_model=RobotVoiceConfig)
2640
+ async def update_robot_voice_config(robot_id: str, payload: RobotVoiceConfigUpdate):
2641
+ now = datetime.utcnow().isoformat()
2642
+ async with aiosqlite.connect(DB_PATH) as db:
2643
+ await db.execute(
2644
+ """INSERT INTO robot_voice_configs (robot_id, character_preset_id, voice_stack_id, override_config, updated_at)
2645
+ VALUES (?, ?, ?, ?, ?)
2646
+ ON CONFLICT(robot_id) DO UPDATE SET
2647
+ character_preset_id = excluded.character_preset_id,
2648
+ voice_stack_id = excluded.voice_stack_id,
2649
+ override_config = excluded.override_config,
2650
+ updated_at = excluded.updated_at""",
2651
+ (
2652
+ robot_id,
2653
+ payload.character_preset_id,
2654
+ payload.voice_stack_id,
2655
+ _json.dumps(payload.override_config) if payload.override_config else None,
2656
+ now,
2657
+ ),
2658
+ )
2659
+ await db.commit()
2660
+ return await _resolve_robot_voice_config(robot_id)
2661
+
2662
+
2663
+ @app.get("/api/devices/by-room/{room_name}/voice-config", response_model=RobotVoiceConfig)
2664
+ async def get_device_voice_config_by_room(room_name: str):
2665
+ """ROBOVOICE agent calls this when it joins a room to fetch per-robot config."""
2666
+ async with aiosqlite.connect(DB_PATH) as db:
2667
+ db.row_factory = aiosqlite.Row
2668
+ async with db.execute(
2669
+ "SELECT robot_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
2670
+ (room_name,),
2671
+ ) as c:
2672
+ session = await c.fetchone()
2673
+ if not session:
2674
+ if room_name.startswith("robopark-"):
2675
+ device_id = room_name[len("robopark-"):]
2676
+ if device_id:
2677
+ return await _resolve_robot_voice_config(device_id)
2678
+ raise HTTPException(404, "No session bound to this room")
2679
+ return await _resolve_robot_voice_config(session["robot_id"])
2680
+
2681
+
2164
2682
  # =============================================================================
2165
2683
  # TELEMETRY (close-the-loop: trigger counts, latency, drop rate, error reasons)
2166
2684
  # =============================================================================
@@ -2349,11 +2867,24 @@ async def dashboard():
2349
2867
  const deviceInventory = ref({});
2350
2868
  const showLiveKitConfig = ref(false);
2351
2869
  const livekitForm = ref({ url: '', api_key: '', api_secret: '' });
2870
+ const voiceStacks = ref([]);
2871
+ const characterPresets = ref([]);
2872
+ const showVoiceStackForm = ref(false);
2873
+ const showCharacterForm = ref(false);
2874
+ const editingVoiceStack = ref(null);
2875
+ const editingCharacter = ref(null);
2876
+ const voiceStackForm = ref({
2877
+ id: '', name: '', stt_provider: 'speaches', stt_model: 'Systran/faster-whisper-small', stt_language: 'en',
2878
+ llm_provider: 'ollama', llm_model: 'gemma4:e4b', tts_provider: 'elevenlabs',
2879
+ tts_voice: '21m00Tcm4TlvDq8ikWAM', tts_language: 'en',
2880
+ allow_interruptions: true, min_endpointing_delay: 0.7, max_turns: 20, wake_word_enabled: false,
2881
+ });
2882
+ const characterForm = ref({ id: '', name: '', description: '', system_prompt: '', voice_stack_id: '', motors: [], motorsStr: '' });
2352
2883
  let ws = null;
2353
2884
 
2354
2885
  const fetchData = async () => {
2355
2886
  try {
2356
- const [robotsRes, serversRes, sessionsRes, statsRes, devsRes, settingsRes, lkRes] = await Promise.all([
2887
+ const [robotsRes, serversRes, sessionsRes, statsRes, devsRes, settingsRes, lkRes, stacksRes, charsRes] = await Promise.all([
2357
2888
  fetch('/api/robots').then(r => r.json()),
2358
2889
  fetch('/api/servers').then(r => r.json()),
2359
2890
  fetch('/api/sessions?active_only=true').then(r => r.json()),
@@ -2361,6 +2892,8 @@ async def dashboard():
2361
2892
  fetch('/api/devices').then(r => r.json()),
2362
2893
  fetch('/api/settings').then(r => r.json()),
2363
2894
  fetch('/api/livekit/config').then(r => r.json()),
2895
+ fetch('/api/voice-stacks').then(r => r.json()).catch(() => []),
2896
+ fetch('/api/character-presets').then(r => r.json()).catch(() => []),
2364
2897
  ]);
2365
2898
  robots.value = robotsRes;
2366
2899
  servers.value = serversRes;
@@ -2369,6 +2902,8 @@ async def dashboard():
2369
2902
  devices.value = devsRes;
2370
2903
  settings.value = settingsRes;
2371
2904
  livekit.value = lkRes;
2905
+ voiceStacks.value = stacksRes;
2906
+ characterPresets.value = charsRes;
2372
2907
 
2373
2908
  for (const server of serversRes) {
2374
2909
  try {
@@ -2681,15 +3216,112 @@ async def dashboard():
2681
3216
  if (ws) ws.close();
2682
3217
  });
2683
3218
 
3219
+ const openVoiceStackForm = (stack = null) => {
3220
+ if (stack) {
3221
+ editingVoiceStack.value = stack.id;
3222
+ voiceStackForm.value = { ...stack };
3223
+ } else {
3224
+ editingVoiceStack.value = null;
3225
+ voiceStackForm.value = {
3226
+ id: '', name: '', stt_provider: 'speaches', stt_model: 'Systran/faster-whisper-small', stt_language: 'en',
3227
+ llm_provider: 'ollama', llm_model: 'gemma4:e4b', tts_provider: 'elevenlabs',
3228
+ tts_voice: '21m00Tcm4TlvDq8ikWAM', tts_language: 'en',
3229
+ allow_interruptions: true, min_endpointing_delay: 0.7, max_turns: 20, wake_word_enabled: false,
3230
+ };
3231
+ }
3232
+ showVoiceStackForm.value = true;
3233
+ };
3234
+
3235
+ const saveVoiceStack = async () => {
3236
+ try {
3237
+ const method = editingVoiceStack.value ? 'PUT' : 'POST';
3238
+ const url = editingVoiceStack.value ? `/api/voice-stacks/${editingVoiceStack.value}` : '/api/voice-stacks';
3239
+ await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(voiceStackForm.value) });
3240
+ showVoiceStackForm.value = false;
3241
+ fetchData();
3242
+ } catch (e) {
3243
+ alert('Failed to save voice stack: ' + e.message);
3244
+ }
3245
+ };
3246
+
3247
+ const deleteVoiceStack = async (id) => {
3248
+ if (!confirm('Delete this voice stack? Any presets using it will need reassignment.')) return;
3249
+ try {
3250
+ await fetch(`/api/voice-stacks/${id}`, { method: 'DELETE' });
3251
+ fetchData();
3252
+ } catch (e) {
3253
+ alert('Failed to delete voice stack: ' + e.message);
3254
+ }
3255
+ };
3256
+
3257
+ const openCharacterForm = (char = null) => {
3258
+ if (char) {
3259
+ editingCharacter.value = char.id;
3260
+ const motorsArr = Array.isArray(char.motors) ? char.motors : [];
3261
+ characterForm.value = { ...char, motors: motorsArr, motorsStr: motorsArr.join(', ') };
3262
+ } else {
3263
+ editingCharacter.value = null;
3264
+ characterForm.value = { id: '', name: '', description: '', system_prompt: '', voice_stack_id: '', motors: [], motorsStr: '' };
3265
+ }
3266
+ showCharacterForm.value = true;
3267
+ };
3268
+
3269
+ const saveCharacter = async () => {
3270
+ try {
3271
+ const motors = characterForm.value.motorsStr
3272
+ ? characterForm.value.motorsStr.split(',').map(x => x.trim()).filter(Boolean)
3273
+ : (characterForm.value.motors || []);
3274
+ const payload = { ...characterForm.value, motors };
3275
+ delete payload.motorsStr;
3276
+ const method = editingCharacter.value ? 'PUT' : 'POST';
3277
+ const url = editingCharacter.value ? `/api/character-presets/${editingCharacter.value}` : '/api/character-presets';
3278
+ await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
3279
+ showCharacterForm.value = false;
3280
+ fetchData();
3281
+ } catch (e) {
3282
+ alert('Failed to save character preset: ' + e.message);
3283
+ }
3284
+ };
3285
+
3286
+ const deleteCharacter = async (id) => {
3287
+ if (!confirm('Delete this character preset?')) return;
3288
+ try {
3289
+ await fetch(`/api/character-presets/${id}`, { method: 'DELETE' });
3290
+ fetchData();
3291
+ } catch (e) {
3292
+ alert('Failed to delete character preset: ' + e.message);
3293
+ }
3294
+ };
3295
+
3296
+ const assignRobotCharacter = async (robotId, characterId) => {
3297
+ try {
3298
+ await fetch(`/api/robots/${robotId}/voice-config`, {
3299
+ method: 'PUT', headers: {'Content-Type': 'application/json'},
3300
+ body: JSON.stringify({ character_id: characterId || null }),
3301
+ });
3302
+ fetchData();
3303
+ } catch (e) {
3304
+ alert('Failed to assign character: ' + e.message);
3305
+ }
3306
+ };
3307
+
3308
+ const stackName = (id) => voiceStacks.value.find(s => s.id === id)?.name || '—';
3309
+ const characterName = (id) => characterPresets.value.find(c => c.id === id)?.name || '—';
3310
+
2684
3311
  return {
2685
3312
  robots, servers, sessions, stats, connected, serverMetrics,
2686
3313
  devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken,
2687
3314
  showLiveKitConfig, livekitForm, preview, deviceInventory,
3315
+ voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
3316
+ editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
2688
3317
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
2689
3318
  toggleProductionMode, rotateEnrollmentToken,
2690
3319
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
2691
3320
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
2692
3321
  startPreview, stopPreview, updateDeviceMedia,
3322
+ openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
3323
+ openCharacterForm, saveCharacter, deleteCharacter,
3324
+ assignRobotCharacter, stackName, characterName,
2693
3325
  };
2694
3326
  },
2695
3327
  template: `
@@ -2940,6 +3572,25 @@ async def dashboard():
2940
3572
  {{ robot.total_sessions }} sessions
2941
3573
  </div>
2942
3574
  </div>
3575
+ <div class="text-xs text-gray-400 mb-2">
3576
+ Character: <strong>{{ characterName(robot.character_id) }}</strong> ·
3577
+ Voice Stack: <strong>{{ stackName(robot.voice_stack_id) }}</strong>
3578
+ </div>
3579
+ <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
3580
+ <div>
3581
+ <label class="text-xs text-gray-500">Character</label>
3582
+ <select :value="robot.character_id"
3583
+ @change="assignRobotCharacter(robot.id, $event.target.value)"
3584
+ class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
3585
+ <option value="">—</option>
3586
+ <option v-for="c in characterPresets" :key="c.id" :value="c.id">{{ c.name }}</option>
3587
+ </select>
3588
+ </div>
3589
+ <div>
3590
+ <label class="text-xs text-gray-500">Voice Stack</label>
3591
+ <div class="text-xs text-gray-400 px-2 py-1 bg-gray-800 rounded truncate">{{ stackName(robot.voice_stack_id) }}</div>
3592
+ </div>
3593
+ </div>
2943
3594
  <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
2944
3595
  <div class="grid grid-cols-2 gap-2">
2945
3596
  <div>
@@ -2979,6 +3630,63 @@ async def dashboard():
2979
3630
  <div class="text-xs text-gray-400 px-2 py-1">{{ preview.room }}</div>
2980
3631
  </div>
2981
3632
  </div>
3633
+
3634
+ <!-- Voice Configuration Panel -->
3635
+ <div class="col-span-4 bg-gray-800 rounded-lg p-4">
3636
+ <div class="flex justify-between items-center mb-4">
3637
+ <h2 class="text-xl font-semibold">🎙️ Voice Stacks</h2>
3638
+ <button @click="openVoiceStackForm()" class="px-2 py-1 bg-cyan-700 hover:bg-cyan-600 rounded text-xs">+ New Stack</button>
3639
+ </div>
3640
+ <div class="space-y-3">
3641
+ <div v-for="s in voiceStacks" :key="s.id" class="bg-gray-700 rounded-lg p-3">
3642
+ <div class="flex justify-between items-start">
3643
+ <div>
3644
+ <div class="font-medium">{{ s.name }}</div>
3645
+ <div class="text-xs text-gray-400 mt-1">
3646
+ STT <strong>{{ s.stt_provider }}/{{ s.stt_model }}</strong> · {{ s.stt_language }} ·
3647
+ LLM <strong>{{ s.llm_provider }}/{{ s.llm_model }}</strong> ·
3648
+ TTS <strong>{{ s.tts_provider }}</strong> · {{ s.tts_language }}
3649
+ </div>
3650
+ <div class="text-xs text-gray-500 mt-1">
3651
+ interruptions={{ s.allow_interruptions ? 'on' : 'off' }} · endpointing={{ s.min_endpointing_delay }}s · max_turns={{ s.max_turns }}
3652
+ </div>
3653
+ </div>
3654
+ <div class="flex gap-1">
3655
+ <button @click="openVoiceStackForm(s)" class="text-xs px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded">Edit</button>
3656
+ <button @click="deleteVoiceStack(s.id)" class="text-xs px-2 py-1 bg-red-700 hover:bg-red-600 rounded">×</button>
3657
+ </div>
3658
+ </div>
3659
+ </div>
3660
+ <div v-if="voiceStacks.length === 0" class="text-gray-500 text-center py-4 text-sm">
3661
+ No voice stacks configured.
3662
+ </div>
3663
+ </div>
3664
+
3665
+ <div class="flex justify-between items-center mb-4 mt-6">
3666
+ <h2 class="text-xl font-semibold">🎭 Character Presets</h2>
3667
+ <button @click="openCharacterForm()" class="px-2 py-1 bg-cyan-700 hover:bg-cyan-600 rounded text-xs">+ New Character</button>
3668
+ </div>
3669
+ <div class="space-y-3">
3670
+ <div v-for="c in characterPresets" :key="c.id" class="bg-gray-700 rounded-lg p-3">
3671
+ <div class="flex justify-between items-start">
3672
+ <div>
3673
+ <div class="font-medium">{{ c.name }}</div>
3674
+ <div class="text-xs text-gray-400 mt-1">{{ c.description }}</div>
3675
+ <div class="text-xs text-gray-500 mt-1">
3676
+ stack: <strong>{{ stackName(c.voice_stack_id) }}</strong> · motors: {{ (c.motors || '').replace(/[^a-zA-Z,]/g, '') || '—' }}
3677
+ </div>
3678
+ </div>
3679
+ <div class="flex gap-1">
3680
+ <button @click="openCharacterForm(c)" class="text-xs px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded">Edit</button>
3681
+ <button @click="deleteCharacter(c.id)" class="text-xs px-2 py-1 bg-red-700 hover:bg-red-600 rounded">×</button>
3682
+ </div>
3683
+ </div>
3684
+ </div>
3685
+ <div v-if="characterPresets.length === 0" class="text-gray-500 text-center py-4 text-sm">
3686
+ No character presets configured.
3687
+ </div>
3688
+ </div>
3689
+ </div>
2982
3690
 
2983
3691
  <!-- Servers Panel -->
2984
3692
  <div class="col-span-5 space-y-4">
@@ -3046,10 +3754,135 @@ async def dashboard():
3046
3754
  <div class="text-gray-400 text-xs">
3047
3755
  {{ session.server_id }} • {{ session.room_name }}
3048
3756
  </div>
3757
+ <div v-if="session.voice_config" class="text-xs text-cyan-400 mt-1">
3758
+ {{ session.voice_config.character_id }} · {{ session.voice_config.voice_stack_id }}
3759
+ </div>
3049
3760
  </div>
3050
3761
  </div>
3051
3762
  </div>
3052
3763
  </div>
3764
+
3765
+ <!-- Voice Stack Modal -->
3766
+ <div v-if="showVoiceStackForm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="showVoiceStackForm = false">
3767
+ <div class="bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
3768
+ <h3 class="text-lg font-semibold mb-2">{{ editingVoiceStack ? 'Edit Voice Stack' : 'New Voice Stack' }}</h3>
3769
+ <div class="grid grid-cols-2 gap-3 text-sm">
3770
+ <div>
3771
+ <label class="block text-gray-400 mb-1">ID</label>
3772
+ <input v-model="voiceStackForm.id" :disabled="!!editingVoiceStack" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="e.g. english-fast">
3773
+ </div>
3774
+ <div>
3775
+ <label class="block text-gray-400 mb-1">Name</label>
3776
+ <input v-model="voiceStackForm.name" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="English Fast">
3777
+ </div>
3778
+ <div>
3779
+ <label class="block text-gray-400 mb-1">STT Provider</label>
3780
+ <select v-model="voiceStackForm.stt_provider" class="w-full bg-gray-700 rounded px-3 py-2">
3781
+ <option>speaches</option>
3782
+ <option>deepgram</option>
3783
+ <option>openai</option>
3784
+ </select>
3785
+ </div>
3786
+ <div>
3787
+ <label class="block text-gray-400 mb-1">STT Model</label>
3788
+ <input v-model="voiceStackForm.stt_model" class="w-full bg-gray-700 rounded px-3 py-2">
3789
+ </div>
3790
+ <div>
3791
+ <label class="block text-gray-400 mb-1">STT Language</label>
3792
+ <input v-model="voiceStackForm.stt_language" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="en/he">
3793
+ </div>
3794
+ <div>
3795
+ <label class="block text-gray-400 mb-1">LLM Provider</label>
3796
+ <select v-model="voiceStackForm.llm_provider" class="w-full bg-gray-700 rounded px-3 py-2">
3797
+ <option>ollama</option>
3798
+ <option>openai</option>
3799
+ </select>
3800
+ </div>
3801
+ <div>
3802
+ <label class="block text-gray-400 mb-1">LLM Model</label>
3803
+ <input v-model="voiceStackForm.llm_model" class="w-full bg-gray-700 rounded px-3 py-2">
3804
+ </div>
3805
+ <div>
3806
+ <label class="block text-gray-400 mb-1">TTS Provider</label>
3807
+ <select v-model="voiceStackForm.tts_provider" class="w-full bg-gray-700 rounded px-3 py-2">
3808
+ <option>elevenlabs</option>
3809
+ <option>speaches</option>
3810
+ <option>cartesia</option>
3811
+ </select>
3812
+ </div>
3813
+ <div>
3814
+ <label class="block text-gray-400 mb-1">TTS Voice ID</label>
3815
+ <input v-model="voiceStackForm.tts_voice" class="w-full bg-gray-700 rounded px-3 py-2">
3816
+ </div>
3817
+ <div>
3818
+ <label class="block text-gray-400 mb-1">TTS Language</label>
3819
+ <input v-model="voiceStackForm.tts_language" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="en">
3820
+ </div>
3821
+ <div>
3822
+ <label class="block text-gray-400 mb-1">Min Endpointing Delay (s)</label>
3823
+ <input v-model.number="voiceStackForm.min_endpointing_delay" type="number" step="0.1" class="w-full bg-gray-700 rounded px-3 py-2">
3824
+ </div>
3825
+ <div>
3826
+ <label class="block text-gray-400 mb-1">Max Turns</label>
3827
+ <input v-model.number="voiceStackForm.max_turns" type="number" class="w-full bg-gray-700 rounded px-3 py-2">
3828
+ </div>
3829
+ <div class="flex items-center gap-2">
3830
+ <input id="vs_interruptions" v-model="voiceStackForm.allow_interruptions" type="checkbox" class="rounded">
3831
+ <label for="vs_interruptions" class="text-sm">Allow Interruptions</label>
3832
+ </div>
3833
+ <div class="flex items-center gap-2">
3834
+ <input id="vs_wake" v-model="voiceStackForm.wake_word_enabled" type="checkbox" class="rounded">
3835
+ <label for="vs_wake" class="text-sm">Wake Word Enabled</label>
3836
+ </div>
3837
+ </div>
3838
+ <div class="flex justify-end gap-2 mt-5">
3839
+ <button @click="showVoiceStackForm = false" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded">Cancel</button>
3840
+ <button @click="saveVoiceStack" class="px-3 py-2 bg-cyan-600 hover:bg-cyan-700 rounded">Save</button>
3841
+ </div>
3842
+ </div>
3843
+ </div>
3844
+
3845
+ <!-- Character Modal -->
3846
+ <div v-if="showCharacterForm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="showCharacterForm = false">
3847
+ <div class="bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
3848
+ <h3 class="text-lg font-semibold mb-2">{{ editingCharacter ? 'Edit Character' : 'New Character' }}</h3>
3849
+ <div class="space-y-3 text-sm">
3850
+ <div class="grid grid-cols-2 gap-3">
3851
+ <div>
3852
+ <label class="block text-gray-400 mb-1">ID</label>
3853
+ <input v-model="characterForm.id" :disabled="!!editingCharacter" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="volt">
3854
+ </div>
3855
+ <div>
3856
+ <label class="block text-gray-400 mb-1">Name</label>
3857
+ <input v-model="characterForm.name" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="VOLT">
3858
+ </div>
3859
+ </div>
3860
+ <div>
3861
+ <label class="block text-gray-400 mb-1">Voice Stack</label>
3862
+ <select v-model="characterForm.voice_stack_id" class="w-full bg-gray-700 rounded px-3 py-2">
3863
+ <option value="">—</option>
3864
+ <option v-for="s in voiceStacks" :key="s.id" :value="s.id">{{ s.name }}</option>
3865
+ </select>
3866
+ </div>
3867
+ <div>
3868
+ <label class="block text-gray-400 mb-1">Description</label>
3869
+ <input v-model="characterForm.description" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="Short public description">
3870
+ </div>
3871
+ <div>
3872
+ <label class="block text-gray-400 mb-1">System Prompt</label>
3873
+ <textarea v-model="characterForm.system_prompt" rows="8" class="w-full bg-gray-700 rounded px-3 py-2 font-mono" placeholder="You are a friendly robot..."></textarea>
3874
+ </div>
3875
+ <div>
3876
+ <label class="block text-gray-400 mb-1">Motor Commands (comma-separated)</label>
3877
+ <input v-model="characterForm.motorsStr" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="drive, navigate, stop">
3878
+ </div>
3879
+ </div>
3880
+ <div class="flex justify-end gap-2 mt-5">
3881
+ <button @click="showCharacterForm = false" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded">Cancel</button>
3882
+ <button @click="saveCharacter" class="px-3 py-2 bg-cyan-600 hover:bg-cyan-700 rounded">Save</button>
3883
+ </div>
3884
+ </div>
3885
+ </div>
3053
3886
  </div>
3054
3887
  `
3055
3888
  }).mount('#app');
@@ -3068,7 +3901,7 @@ async def robot_client_page():
3068
3901
  detection, session triggering, and dashboard streaming before deploying to
3069
3902
  real Pis.
3070
3903
  """
3071
- return """
3904
+ return r"""
3072
3905
  <!DOCTYPE html>
3073
3906
  <html>
3074
3907
  <head>