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/README.md +63 -63
- package/bin/robopark.js +17 -17
- package/package.json +38 -38
- package/pi-client/README.md +17 -17
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/main.py +836 -3
- package/scheduler/preview_agent.py +24 -0
- package/scheduler/requirements-robot.txt +9 -9
- package/vision/README.md +66 -66
- package/vision/requirements-demo.txt +16 -16
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
|
-
|
|
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>
|