infinicode 2.8.27 → 2.8.29

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.
@@ -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()
@@ -2092,12 +2192,19 @@ async def device_request_session(device_id: str,
2092
2192
  .to_jwt()
2093
2193
  )
2094
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
+
2095
2201
  return {
2096
2202
  "session_id": session_id,
2097
2203
  "server_id": server["id"],
2098
2204
  "server_url": server["url"],
2099
2205
  "room_name": room_name,
2100
2206
  "token": token,
2207
+ "voice_config": voice_config.model_dump() if voice_config else None,
2101
2208
  }
2102
2209
 
2103
2210
  # =============================================================================
@@ -2117,6 +2224,67 @@ class Character(CharacterSummary):
2117
2224
  system_prompt: Optional[str] = None
2118
2225
  raw: dict = {}
2119
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
+
2120
2288
  def _load_robovoice_settings() -> dict:
2121
2289
  """Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
2122
2290
  try:
@@ -2169,6 +2337,348 @@ async def get_character(character_id: str):
2169
2337
  )
2170
2338
  raise HTTPException(404, f"Character '{character_id}' not found in {ROBOVOICE_SETTINGS_PATH}")
2171
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
+
2172
2682
  # =============================================================================
2173
2683
  # TELEMETRY (close-the-loop: trigger counts, latency, drop rate, error reasons)
2174
2684
  # =============================================================================
@@ -2357,11 +2867,24 @@ async def dashboard():
2357
2867
  const deviceInventory = ref({});
2358
2868
  const showLiveKitConfig = ref(false);
2359
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: '' });
2360
2883
  let ws = null;
2361
2884
 
2362
2885
  const fetchData = async () => {
2363
2886
  try {
2364
- 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([
2365
2888
  fetch('/api/robots').then(r => r.json()),
2366
2889
  fetch('/api/servers').then(r => r.json()),
2367
2890
  fetch('/api/sessions?active_only=true').then(r => r.json()),
@@ -2369,6 +2892,8 @@ async def dashboard():
2369
2892
  fetch('/api/devices').then(r => r.json()),
2370
2893
  fetch('/api/settings').then(r => r.json()),
2371
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(() => []),
2372
2897
  ]);
2373
2898
  robots.value = robotsRes;
2374
2899
  servers.value = serversRes;
@@ -2377,6 +2902,8 @@ async def dashboard():
2377
2902
  devices.value = devsRes;
2378
2903
  settings.value = settingsRes;
2379
2904
  livekit.value = lkRes;
2905
+ voiceStacks.value = stacksRes;
2906
+ characterPresets.value = charsRes;
2380
2907
 
2381
2908
  for (const server of serversRes) {
2382
2909
  try {
@@ -2689,15 +3216,112 @@ async def dashboard():
2689
3216
  if (ws) ws.close();
2690
3217
  });
2691
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
+
2692
3311
  return {
2693
3312
  robots, servers, sessions, stats, connected, serverMetrics,
2694
3313
  devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken,
2695
3314
  showLiveKitConfig, livekitForm, preview, deviceInventory,
3315
+ voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
3316
+ editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
2696
3317
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
2697
3318
  toggleProductionMode, rotateEnrollmentToken,
2698
3319
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
2699
3320
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
2700
3321
  startPreview, stopPreview, updateDeviceMedia,
3322
+ openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
3323
+ openCharacterForm, saveCharacter, deleteCharacter,
3324
+ assignRobotCharacter, stackName, characterName,
2701
3325
  };
2702
3326
  },
2703
3327
  template: `
@@ -2948,6 +3572,25 @@ async def dashboard():
2948
3572
  {{ robot.total_sessions }} sessions
2949
3573
  </div>
2950
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>
2951
3594
  <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
2952
3595
  <div class="grid grid-cols-2 gap-2">
2953
3596
  <div>
@@ -2987,6 +3630,63 @@ async def dashboard():
2987
3630
  <div class="text-xs text-gray-400 px-2 py-1">{{ preview.room }}</div>
2988
3631
  </div>
2989
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>
2990
3690
 
2991
3691
  <!-- Servers Panel -->
2992
3692
  <div class="col-span-5 space-y-4">
@@ -3054,10 +3754,135 @@ async def dashboard():
3054
3754
  <div class="text-gray-400 text-xs">
3055
3755
  {{ session.server_id }} • {{ session.room_name }}
3056
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>
3057
3760
  </div>
3058
3761
  </div>
3059
3762
  </div>
3060
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>
3061
3886
  </div>
3062
3887
  `
3063
3888
  }).mount('#app');
@@ -3076,7 +3901,7 @@ async def robot_client_page():
3076
3901
  detection, session triggering, and dashboard streaming before deploying to
3077
3902
  real Pis.
3078
3903
  """
3079
- return """
3904
+ return r"""
3080
3905
  <!DOCTYPE html>
3081
3906
  <html>
3082
3907
  <head>