infinicode 2.8.26 → 2.8.28
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/.opencode/plugins/home-logo-animation.tsx +124 -124
- package/.opencode/plugins/mesh-commands.tsx +140 -140
- package/.opencode/plugins/routing-mode-display.tsx +138 -138
- package/.opencode/themes/infinibot-gold.json +222 -222
- package/.opencode/tui.json +8 -8
- package/README.md +623 -623
- package/bin/robopark.js +2 -2
- package/dist/kernel/agents/backends/registry.js +22 -2
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +1512 -1403
- package/dist/kernel/federation/federation.d.ts +3 -0
- package/dist/kernel/federation/federation.js +25 -4
- package/dist/kernel/federation/transport-http.d.ts +1 -0
- package/dist/kernel/federation/transport-http.js +3 -0
- package/dist/kernel/plugins/dashboard-plugin.js +8 -8
- package/dist/kernel/providers/ollama-provider.d.ts +5 -0
- package/dist/kernel/providers/ollama-provider.js +131 -0
- package/dist/robopark/add-robot.js +25 -3
- package/dist/robopark/auto-start.js +38 -38
- package/dist/robopark/probe.js +15 -15
- package/dist/robopark/setup-livekit.js +52 -52
- package/package.json +2 -2
- package/packages/robopark/README.md +63 -63
- package/packages/robopark/pi-client/README.md +17 -17
- package/packages/robopark/scheduler/main.py +827 -2
- package/packages/robopark/scheduler/preview_agent.py +24 -0
- package/packages/robopark/scheduler/requirements-robot.txt +9 -9
- package/packages/robopark/vision/README.md +66 -66
- package/packages/robopark/vision/requirements-demo.txt +16 -16
|
@@ -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>
|