robopark 2.8.36 → 3.1.0
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 +95 -63
- package/bin/robopark.js +7 -17
- package/conversation/elevenlabs_agent.py +1985 -0
- package/conversation/requirements.txt +3 -0
- package/conversation/supervisor_store.py +189 -0
- package/dist/kernel/config-schema.js +37 -0
- package/dist/kernel/types.js +7 -0
- package/dist/robopark/access.js +99 -0
- package/dist/robopark/add-robot.js +188 -0
- package/dist/robopark/agent-ctl.js +305 -0
- package/dist/robopark/auto-start.js +293 -0
- package/dist/robopark/control-center-html.js +9191 -0
- package/dist/robopark/control-server.js +148 -0
- package/dist/robopark/conversation.js +505 -0
- package/dist/robopark/deployment-commands.js +47 -0
- package/dist/robopark/discovery.js +180 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/enroll.js +68 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/motor-control.js +195 -0
- package/dist/robopark/preview-agent-launcher.js +77 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/python-env.js +162 -0
- package/dist/robopark/robot-runtime.js +489 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/screen-control.js +55 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/serve.js +285 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +300 -0
- package/dist/robopark/setup.js +286 -0
- package/dist/robopark/standalone.js +486 -0
- package/dist/robopark/stop-all.js +141 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark/vision-agent-launcher.js +98 -0
- package/dist/robopark/vision-control.js +81 -0
- package/dist/robopark/voice-join-html.js +57 -0
- package/dist/robopark-cli.js +801 -0
- package/package.json +23 -5
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +61 -2
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +16 -7
- package/pi-client/motor_bridge.py +6 -3
- package/scheduler/main.py +4505 -135
- package/scheduler/media_lock.py +57 -0
- package/scheduler/preview_agent.py +1465 -87
- package/scheduler/production_config.json +139 -0
- package/scheduler/robot_supervisor.py +1705 -0
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/scheduler/scripts/robopark-supervisor.service +20 -0
- package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/scheduler/supervisor.example.json +26 -0
- package/scheduler/vision_motion_trigger.py +101 -0
- package/screen/screen_runtime.py +75 -0
- package/static/robopark/barracuda.png +0 -0
- package/static/robopark/bmw.png +0 -0
- package/static/robopark/jaguar.png +0 -0
- package/static/robopark/magnus.jpg +0 -0
- package/static/robopark/panda.png +0 -0
- package/static/robopark/tesla.png +0 -0
- package/static/robopark/titan.jpg +0 -0
- package/static/robopark/volt.png +0 -0
- package/static/vendor/elevenlabs-client.LICENSE +21 -0
- package/static/vendor/elevenlabs-client.iife.js +24200 -0
- package/vision/app_pi_clean.py +253 -16
- package/vision/audio_server_pi.py +19 -0
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +224 -61
- package/vision/requirements_camera.txt +6 -0
- package/vision/requirements_motor.txt +4 -0
- package/vision/requirements_pi_unified.txt +1 -0
- package/vision/requirements_vision_agent.txt +19 -0
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark — `robopark verify`.
|
|
3
|
+
*
|
|
4
|
+
* Checks the fleet end-to-end and reports what's working vs. missing:
|
|
5
|
+
* - hub / scheduler reachable
|
|
6
|
+
* - LiveKit configured and server registered
|
|
7
|
+
* - production mode enabled
|
|
8
|
+
* - robots/devices enrolled
|
|
9
|
+
* - preview agent running on robots
|
|
10
|
+
* - preview room can be started
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* robopark verify
|
|
14
|
+
* robopark verify --scheduler-url http://livekit-1:8080 --robot-id robobmw
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { homedir, hostname } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
import { discoverContext } from './discovery.js';
|
|
21
|
+
function loadDeviceToken(robotId) {
|
|
22
|
+
const paths = [
|
|
23
|
+
join(homedir(), '.robopark', 'device_token'),
|
|
24
|
+
];
|
|
25
|
+
for (const p of paths) {
|
|
26
|
+
if (existsSync(p))
|
|
27
|
+
return readFile(p);
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function readFile(p) {
|
|
32
|
+
const { readFileSync } = require('node:fs');
|
|
33
|
+
return readFileSync(p, 'utf8').trim();
|
|
34
|
+
}
|
|
35
|
+
async function fetchJson(url, init) {
|
|
36
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new Error(`${res.status} ${res.statusText}`);
|
|
39
|
+
return res.json();
|
|
40
|
+
}
|
|
41
|
+
export async function roboparkVerify(opts) {
|
|
42
|
+
const ctx = await discoverContext();
|
|
43
|
+
const schedulerUrl = (opts.schedulerUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:8080` : 'http://localhost:8080')).replace(/\/$/, '');
|
|
44
|
+
const robotId = opts.robotId ?? hostname().split('.')[0];
|
|
45
|
+
console.log(chalk.bold('\n robopark verify'));
|
|
46
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
47
|
+
console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
|
|
48
|
+
console.log(` robot: ${chalk.cyan(robotId)}`);
|
|
49
|
+
console.log();
|
|
50
|
+
const checks = [];
|
|
51
|
+
// 1. Scheduler reachable
|
|
52
|
+
try {
|
|
53
|
+
const settings = (await fetchJson(`${schedulerUrl}/api/settings`));
|
|
54
|
+
checks.push({ name: 'scheduler reachable', ok: true, message: `production_mode=${settings.production_mode ?? false}` });
|
|
55
|
+
if (!settings.production_mode) {
|
|
56
|
+
checks.push({ name: 'production mode', ok: false, message: 'Production mode is OFF. Toggle it in the dashboard or PUT /api/settings', detail: 'Pi preview/session enrollment is blocked until production mode is ON.' });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
checks.push({ name: 'production mode', ok: true, message: 'ON' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
checks.push({ name: 'scheduler reachable', ok: false, message: `could not reach scheduler: ${e.message}`, detail: 'Is robopark serve running on the hub? Is the scheduler port open?' });
|
|
64
|
+
}
|
|
65
|
+
// 2. LiveKit configured
|
|
66
|
+
try {
|
|
67
|
+
const lk = (await fetchJson(`${schedulerUrl}/api/livekit/config`));
|
|
68
|
+
if (lk.url && lk.has_secret) {
|
|
69
|
+
checks.push({ name: 'livekit configured', ok: true, message: `url=${lk.url}` });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
checks.push({ name: 'livekit configured', ok: false, message: 'LiveKit URL or API secret missing', detail: 'Set LiveKit config in the dashboard or via robopark setup-livekit.' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
checks.push({ name: 'livekit configured', ok: false, message: `error: ${e.message}` });
|
|
77
|
+
}
|
|
78
|
+
// 3. LiveKit server registered
|
|
79
|
+
try {
|
|
80
|
+
const servers = (await fetchJson(`${schedulerUrl}/api/servers`));
|
|
81
|
+
const online = servers.filter(s => s.status === 'online');
|
|
82
|
+
if (online.length > 0) {
|
|
83
|
+
checks.push({ name: 'livekit server registered', ok: true, message: `${online.length} online server(s)` });
|
|
84
|
+
}
|
|
85
|
+
else if (servers.length > 0) {
|
|
86
|
+
checks.push({ name: 'livekit server registered', ok: false, message: `${servers.length} server(s) but none online`, detail: 'Check that the LiveKit container/process is running and reachable from the hub.' });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
checks.push({ name: 'livekit server registered', ok: false, message: 'no servers in scheduler', detail: 'Add a LiveKit server in the dashboard or run robopark setup-livekit.' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
checks.push({ name: 'livekit server registered', ok: false, message: `error: ${e.message}` });
|
|
94
|
+
}
|
|
95
|
+
// 4. Device enrolled
|
|
96
|
+
let deviceToken;
|
|
97
|
+
let deviceId;
|
|
98
|
+
try {
|
|
99
|
+
const devices = (await fetchJson(`${schedulerUrl}/api/devices`));
|
|
100
|
+
const device = devices.find(d => d.name === robotId || d.id === robotId);
|
|
101
|
+
if (device) {
|
|
102
|
+
deviceId = device.id;
|
|
103
|
+
checks.push({ name: 'device enrolled', ok: true, message: `${device.id} (${device.status})` });
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
checks.push({ name: 'device enrolled', ok: false, message: `${robotId} not found in scheduler`, detail: 'Run robopark enroll on the robot with the scheduler enrollment token.' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
checks.push({ name: 'device enrolled', ok: false, message: `error: ${e.message}` });
|
|
111
|
+
}
|
|
112
|
+
// 5. Local device token
|
|
113
|
+
deviceToken = loadDeviceToken(robotId);
|
|
114
|
+
if (deviceToken) {
|
|
115
|
+
checks.push({ name: 'local device token', ok: true, message: `found ~/.robopark/device_token` });
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
checks.push({ name: 'local device token', ok: false, message: 'no ~/.robopark/device_token', detail: 'Run robopark enroll on this machine first.' });
|
|
119
|
+
}
|
|
120
|
+
// 6. Robot row exists
|
|
121
|
+
try {
|
|
122
|
+
const robots = (await fetchJson(`${schedulerUrl}/api/robots`));
|
|
123
|
+
const robot = robots.find(r => r.id === (deviceId || robotId) || r.name === robotId);
|
|
124
|
+
if (robot) {
|
|
125
|
+
checks.push({ name: 'robot registered', ok: true, message: `${robot.id} (${robot.status})` });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
checks.push({ name: 'robot registered', ok: false, message: `${robotId} not in robots table`, detail: 'The device should auto-create a robot row on enrollment. Re-run enroll or check scheduler logs.' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
checks.push({ name: 'robot registered', ok: false, message: `error: ${e.message}` });
|
|
133
|
+
}
|
|
134
|
+
// 7. Preview agent polling
|
|
135
|
+
if (deviceToken) {
|
|
136
|
+
try {
|
|
137
|
+
const agentRes = (await fetchJson(`${schedulerUrl}/api/robots/${robotId}/preview/agent`, {
|
|
138
|
+
headers: { Authorization: `Bearer ${deviceToken}` },
|
|
139
|
+
}));
|
|
140
|
+
if (agentRes.active) {
|
|
141
|
+
checks.push({ name: 'preview agent polling', ok: true, message: 'preview currently active' });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
checks.push({ name: 'preview agent polling', ok: true, message: `no active preview (reason=${agentRes.reason || 'none'})`, detail: 'This is normal when the dashboard is not watching. Start a preview from the dashboard.' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
checks.push({ name: 'preview agent polling', ok: false, message: `error: ${e.message}`, detail: 'Is robopark preview-agent running on the robot?' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
checks.push({ name: 'preview agent polling', ok: false, message: 'skipped (no device token)' });
|
|
153
|
+
}
|
|
154
|
+
// 8. Try starting a preview from the operator side
|
|
155
|
+
try {
|
|
156
|
+
const preview = (await fetchJson(`${schedulerUrl}/api/robots/${robotId}/preview/start`, { method: 'POST' }));
|
|
157
|
+
if (preview.active) {
|
|
158
|
+
checks.push({ name: 'operator preview start', ok: true, message: `room assigned at ${preview.url}` });
|
|
159
|
+
// stop it so we don't leave a stale preview running
|
|
160
|
+
await fetch(`${schedulerUrl}/api/robots/${robotId}/preview/stop`, { method: 'POST' });
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
checks.push({ name: 'operator preview start', ok: false, message: `could not start: ${preview.reason || 'unknown'}`, detail: 'Enable production mode, add a LiveKit server, and ensure the robot is not in a session.' });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
checks.push({ name: 'operator preview start', ok: false, message: `error: ${e.message}` });
|
|
168
|
+
}
|
|
169
|
+
// Print results
|
|
170
|
+
let pass = 0;
|
|
171
|
+
let fail = 0;
|
|
172
|
+
for (const c of checks) {
|
|
173
|
+
const icon = c.ok ? chalk.green('✓') : chalk.red('✗');
|
|
174
|
+
const color = c.ok ? chalk.green : chalk.red;
|
|
175
|
+
console.log(` ${icon} ${color(c.name)} — ${c.message}`);
|
|
176
|
+
if (c.detail) {
|
|
177
|
+
console.log(chalk.dim(` → ${c.detail}`));
|
|
178
|
+
}
|
|
179
|
+
if (c.ok)
|
|
180
|
+
pass++;
|
|
181
|
+
else
|
|
182
|
+
fail++;
|
|
183
|
+
}
|
|
184
|
+
console.log();
|
|
185
|
+
if (fail === 0) {
|
|
186
|
+
console.log(chalk.bold.green(` ${pass}/${checks.length} checks passed — fleet is ready for preview.`));
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
console.log(chalk.bold.yellow(` ${pass}/${checks.length} passed, ${fail} failed — fix the items above.`));
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark — `robopark vision-agent` launcher.
|
|
3
|
+
*
|
|
4
|
+
* Starts app_pi_clean.py (RoboVisionAI_PI's camera/motion detector) with the
|
|
5
|
+
* right Python interpreter, self-armed and pointed at the local
|
|
6
|
+
* preview-agent's motion-webhook listener by default — no manual curl calls.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { findPython, findVisionPath, findVisionAudioPath, prepareRobotPython, VISION_REQUIRED_MODULES } from './python-env.js';
|
|
11
|
+
const DEFAULT_VISION_PORT = 5000;
|
|
12
|
+
const DEFAULT_MOTION_WEBHOOK_PORT = 5057; // must match preview-agent's --vision-webhook-port default
|
|
13
|
+
function isPiArm() {
|
|
14
|
+
return process.platform === 'linux' && (process.arch === 'arm' || process.arch === 'arm64');
|
|
15
|
+
}
|
|
16
|
+
export async function roboparkVisionAgent(opts) {
|
|
17
|
+
const script = await findVisionPath();
|
|
18
|
+
if (!script) {
|
|
19
|
+
console.log(chalk.red(' ✗ could not find app_pi_clean.py'));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const basePython = findPython();
|
|
23
|
+
// Real Pi: opencv comes from `apt install python3-opencv`, not pip (see
|
|
24
|
+
// requirements_pi_unified.txt) — pip-installing it here would fight the
|
|
25
|
+
// ARM-optimized system build. Everywhere else: plain pip install works.
|
|
26
|
+
const reqFile = isPiArm() ? 'requirements_vision_agent.txt' : 'requirements-demo.txt';
|
|
27
|
+
const requiredModules = isPiArm() ? [...VISION_REQUIRED_MODULES, 'fastapi', 'uvicorn', 'sounddevice', 'soundfile', 'groq', 'lgpio'] : VISION_REQUIRED_MODULES;
|
|
28
|
+
const python = prepareRobotPython(basePython, script, requiredModules, reqFile);
|
|
29
|
+
if (!python)
|
|
30
|
+
process.exit(1);
|
|
31
|
+
const port = opts.port ?? String(DEFAULT_VISION_PORT);
|
|
32
|
+
const webhookUrl = opts.motionWebhookUrl ?? `http://127.0.0.1:${DEFAULT_MOTION_WEBHOOK_PORT}/`;
|
|
33
|
+
const motionActive = opts.motionActive ?? true; // armed by default — "vision trigger as production system"
|
|
34
|
+
const args = [script, '--port', port, '--motion-webhook-url', webhookUrl];
|
|
35
|
+
if (motionActive)
|
|
36
|
+
args.push('--motion-active');
|
|
37
|
+
// The original RoboVision audio selector lives in audio_server_pi.py.
|
|
38
|
+
// Start it beside the camera service on real Pi robots so /devices and
|
|
39
|
+
// /set-device remain the authoritative source for Park dropdowns.
|
|
40
|
+
let audioScript = null;
|
|
41
|
+
if (isPiArm()) {
|
|
42
|
+
audioScript = await findVisionAudioPath();
|
|
43
|
+
}
|
|
44
|
+
console.log(chalk.bold('\n robopark vision-agent'));
|
|
45
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
46
|
+
console.log(` port: ${chalk.cyan(port)}`);
|
|
47
|
+
console.log(` webhook: ${chalk.cyan(webhookUrl)}`);
|
|
48
|
+
console.log(` motion: ${motionActive ? chalk.green('armed') : chalk.dim('off')}`);
|
|
49
|
+
if (audioScript)
|
|
50
|
+
console.log(` audio: ${chalk.green('RoboVision audio server :8000')}`);
|
|
51
|
+
console.log(` motors: ${chalk.dim('managed independently by robopark motor :8001')}`);
|
|
52
|
+
console.log();
|
|
53
|
+
if (opts.foreground) {
|
|
54
|
+
const audio = audioScript
|
|
55
|
+
? spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } })
|
|
56
|
+
: null;
|
|
57
|
+
const camera = spawn(python, args, { stdio: 'inherit' });
|
|
58
|
+
let stopping = false;
|
|
59
|
+
const stopChildren = () => {
|
|
60
|
+
if (stopping)
|
|
61
|
+
return;
|
|
62
|
+
stopping = true;
|
|
63
|
+
camera.kill('SIGTERM');
|
|
64
|
+
audio?.kill('SIGTERM');
|
|
65
|
+
};
|
|
66
|
+
process.once('SIGINT', stopChildren);
|
|
67
|
+
process.once('SIGTERM', stopChildren);
|
|
68
|
+
// If either required media service exits, tear down its sibling. The
|
|
69
|
+
// robot runtime then restarts one coherent pair instead of accumulating
|
|
70
|
+
// orphan audio servers and duplicate owners of ports/devices.
|
|
71
|
+
await new Promise((resolve) => {
|
|
72
|
+
let resolved = false;
|
|
73
|
+
const finish = () => {
|
|
74
|
+
if (resolved)
|
|
75
|
+
return;
|
|
76
|
+
resolved = true;
|
|
77
|
+
stopChildren();
|
|
78
|
+
resolve();
|
|
79
|
+
};
|
|
80
|
+
camera.once('close', finish);
|
|
81
|
+
audio?.once('close', finish);
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (audioScript) {
|
|
86
|
+
const audio = spawn(python, [audioScript], {
|
|
87
|
+
stdio: 'ignore', detached: true, env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' },
|
|
88
|
+
});
|
|
89
|
+
audio.unref();
|
|
90
|
+
}
|
|
91
|
+
const proc = spawn(python, args, {
|
|
92
|
+
stdio: 'ignore',
|
|
93
|
+
detached: true,
|
|
94
|
+
env: { ...process.env, ROBOPARK_VISION_AGENT: '1' },
|
|
95
|
+
});
|
|
96
|
+
proc.unref();
|
|
97
|
+
console.log(chalk.green(' ✓ vision agent started in background'));
|
|
98
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { unregisterAutoStart } from './auto-start.js';
|
|
4
|
+
import { resolveContext } from './profile.js';
|
|
5
|
+
import { roboparkSetup } from './setup.js';
|
|
6
|
+
function runtimeUnit(name) {
|
|
7
|
+
return `robopark-robot-runtime-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
|
|
8
|
+
}
|
|
9
|
+
export async function roboparkVisionUp(config, name, opts) {
|
|
10
|
+
if (opts.lan && opts.tailscale)
|
|
11
|
+
throw new Error('choose exactly one of --lan or --tailscale');
|
|
12
|
+
const context = await resolveContext({ hubUrl: opts.hubUrl, token: opts.token });
|
|
13
|
+
if (!context.hubUrl) {
|
|
14
|
+
throw new Error('no hub resolved; run `robopark hub-use --hub-url http://HUB_IP:47913 --token TOKEN` once');
|
|
15
|
+
}
|
|
16
|
+
if (!context.token) {
|
|
17
|
+
throw new Error('no mesh token resolved; run `robopark hub-use --hub-url http://HUB_IP:47913 --token TOKEN` once');
|
|
18
|
+
}
|
|
19
|
+
console.log(chalk.bold('\n robopark vision up'));
|
|
20
|
+
console.log(chalk.dim(' ' + '-'.repeat(52)));
|
|
21
|
+
console.log(` robot: ${chalk.cyan(name)}`);
|
|
22
|
+
console.log(` camera: ${chalk.cyan(opts.videoDevice ?? 'auto')}`);
|
|
23
|
+
console.log(` relay: ${chalk.green('always on, independent of voice calls')}`);
|
|
24
|
+
console.log();
|
|
25
|
+
await roboparkSetup(config, {
|
|
26
|
+
role: 'robot',
|
|
27
|
+
name,
|
|
28
|
+
site: context.site,
|
|
29
|
+
hub: context.hubUrl,
|
|
30
|
+
token: context.token,
|
|
31
|
+
port: opts.port ?? '47913',
|
|
32
|
+
character: opts.character,
|
|
33
|
+
videoDevice: opts.videoDevice ?? 'auto',
|
|
34
|
+
// A vision-only registration must never reserve the production microphone.
|
|
35
|
+
audioDevice: 'none',
|
|
36
|
+
lan: opts.tailscale ? false : true,
|
|
37
|
+
tailscale: Boolean(opts.tailscale),
|
|
38
|
+
start: true,
|
|
39
|
+
autoStart: opts.autoStart !== false,
|
|
40
|
+
yes: true,
|
|
41
|
+
});
|
|
42
|
+
console.log(chalk.dim(` dashboard relay: /fed/media/robots/${encodeURIComponent(name)}/video_feed`));
|
|
43
|
+
}
|
|
44
|
+
export function roboparkVisionDown(name) {
|
|
45
|
+
const result = unregisterAutoStart('robot-runtime', name);
|
|
46
|
+
if (!result.ok)
|
|
47
|
+
throw new Error(result.message);
|
|
48
|
+
console.log(chalk.green(` ${result.message}`));
|
|
49
|
+
}
|
|
50
|
+
export function roboparkVisionRestart(name) {
|
|
51
|
+
if (process.platform !== 'linux') {
|
|
52
|
+
throw new Error('vision restart currently requires systemd; rerun `robopark vision up NAME` on this platform');
|
|
53
|
+
}
|
|
54
|
+
const unit = runtimeUnit(name);
|
|
55
|
+
const result = spawnSync('systemctl', ['restart', unit], { encoding: 'utf8' });
|
|
56
|
+
if (result.status !== 0)
|
|
57
|
+
throw new Error(String(result.stderr || result.stdout || `could not restart ${unit}`).trim());
|
|
58
|
+
console.log(chalk.green(` restarted ${unit}`));
|
|
59
|
+
}
|
|
60
|
+
export async function roboparkVisionStatus(name) {
|
|
61
|
+
const unit = runtimeUnit(name);
|
|
62
|
+
const service = process.platform === 'linux'
|
|
63
|
+
? spawnSync('systemctl', ['is-active', unit], { encoding: 'utf8' }).stdout.trim()
|
|
64
|
+
: 'unknown';
|
|
65
|
+
let camera = 'unreachable';
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch('http://127.0.0.1:5000/api/camera/status', {
|
|
68
|
+
signal: AbortSignal.timeout(3_000),
|
|
69
|
+
});
|
|
70
|
+
camera = response.ok ? 'ready' : `HTTP ${response.status}`;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Printed as unreachable below.
|
|
74
|
+
}
|
|
75
|
+
console.log(chalk.bold(`\n ${name} vision`));
|
|
76
|
+
console.log(` service: ${service === 'active' ? chalk.green(service) : chalk.yellow(service)}`);
|
|
77
|
+
console.log(` camera: ${camera === 'ready' ? chalk.green(camera) : chalk.yellow(camera)}`);
|
|
78
|
+
console.log(` local: http://127.0.0.1:5000/video_feed`);
|
|
79
|
+
if (service !== 'active' || camera !== 'ready')
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Public, character-specific ElevenLabs conversation surface. */
|
|
2
|
+
export const ROBOPARK_VOICE_JOIN_HTML = `<!doctype html>
|
|
3
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<meta name="theme-color" content="#080704"><meta name="robopark-voice-join-build" content="voice-join-supervisor-20260723-6">
|
|
5
|
+
<title>RoboPark Character Conversation</title>
|
|
6
|
+
<style>
|
|
7
|
+
.deployment{display:grid;gap:8px;margin:14px 0}.deployment-row{display:grid;grid-template-columns:minmax(150px,1fr) 2fr auto;gap:9px;align-items:center;padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.deployment-row b{font:.62rem var(--mono);color:var(--pale)}.deployment-row span{font:.54rem var(--mono);color:var(--muted)}.deployment-row .ready{color:var(--ok)}.deployment-actions{display:flex;flex-wrap:wrap;gap:5px}.deployment-actions button{border:1px solid var(--line);border-radius:8px;background:rgba(244,207,114,.09);color:var(--gold);padding:.38rem .5rem;font:.5rem var(--mono);cursor:pointer}
|
|
8
|
+
.audio-controls{position:relative;width:min(390px,92%);margin-top:12px;border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.3);text-align:left}.audio-controls summary{padding:.65rem .8rem;cursor:pointer;color:var(--gold);font:600 .58rem var(--mono);letter-spacing:.07em;text-transform:uppercase}.audio-controls summary::marker{color:var(--gold)}.audio-panel{display:grid;gap:.75rem;padding:0 .85rem .85rem}.audio-row{display:grid;grid-template-columns:105px 1fr 45px;gap:.55rem;align-items:center}.audio-row label,.audio-value{font:600 .54rem var(--mono);color:var(--muted)}.audio-value{text-align:right;color:var(--pale)}.audio-row input[type=range]{width:100%;accent-color:var(--gold)}.audio-note{margin:0;color:#7e755f;font:.5rem/1.45 var(--mono)}
|
|
9
|
+
:root{--bg:#080704;--panel:rgba(20,17,11,.88);--line:rgba(247,211,116,.22);--gold:#f4cf72;--pale:#fff0bd;--ink:#f7f1df;--muted:#a99e84;--ok:#64dfa0;--warn:#f0bd55;--bad:#ff776c;--mono:"JetBrains Mono","Cascadia Code",monospace;--display:Georgia,"Times New Roman",serif}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:radial-gradient(circle at 50% 8%,#332510 0,#100d08 35%,#050403 78%);color:var(--ink);font-family:Verdana,Geneva,sans-serif}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:linear-gradient(115deg,transparent 0 48%,rgba(244,207,114,.025) 49% 50%,transparent 51%),repeating-linear-gradient(0deg,transparent 0 34px,rgba(255,255,255,.012) 35px)}button,input{font:inherit}.shell{width:min(1120px,100%);min-height:100vh;margin:auto;padding:clamp(14px,3vw,34px);display:grid;grid-template-rows:auto 1fr auto;gap:18px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{font:700 .65rem var(--mono);letter-spacing:.18em;text-transform:uppercase;color:var(--gold)}.ops-toggle{border:1px solid var(--line);border-radius:999px;background:rgba(0,0,0,.35);color:var(--muted);padding:.55rem .8rem;cursor:pointer}.ops-toggle.on{color:var(--pale);border-color:var(--gold)}.stage{position:relative;min-height:620px;display:grid;grid-template-columns:minmax(0,1.3fr) minmax(280px,.7fr);overflow:hidden;border:1px solid var(--line);border-radius:28px;background:linear-gradient(145deg,rgba(34,27,14,.82),rgba(8,7,5,.92));box-shadow:0 28px 90px rgba(0,0,0,.45)}.hero{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:34px;text-align:center;overflow:hidden}.hero:before{content:"";position:absolute;width:500px;height:500px;border:1px solid rgba(244,207,114,.1);border-radius:50%;box-shadow:0 0 0 55px rgba(244,207,114,.018),0 0 0 120px rgba(244,207,114,.012)}.avatar-wrap{position:relative;width:min(330px,72vw);aspect-ratio:1;display:grid;place-items:center;border:1px solid rgba(244,207,114,.3);border-radius:50%;background:radial-gradient(circle,rgba(244,207,114,.16),rgba(8,7,5,.62) 58%,#050403 74%);box-shadow:0 0 90px rgba(214,164,55,.16)}.avatar-wrap.live{animation:breathe 2.2s ease-in-out infinite}.avatar-wrap.speaking{box-shadow:0 0 110px rgba(244,207,114,.42);border-color:var(--pale)}@keyframes breathe{50%{transform:scale(1.018)}}.avatar{position:relative;z-index:2;width:78%;height:78%;object-fit:contain;filter:drop-shadow(0 20px 28px rgba(0,0,0,.58))}.fallback{position:absolute;font:700 4rem var(--display);color:var(--gold)}h1{position:relative;margin:22px 0 5px;font:700 clamp(2rem,5vw,3.5rem) var(--display);color:var(--pale)}.subtitle{position:relative;color:var(--muted);font-size:.8rem}.state{position:relative;margin-top:14px;font:600 .68rem var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--gold)}.join{position:relative;margin-top:22px;min-width:210px;border:1px solid var(--gold);border-radius:999px;padding:.9rem 1.25rem;background:linear-gradient(135deg,#f5d77f,#b87b1d);color:#171005;font-weight:800;cursor:pointer;box-shadow:0 12px 30px rgba(184,123,29,.24)}.join:disabled{opacity:.48;cursor:not-allowed}.end{position:relative;margin-top:10px;border:0;background:transparent;color:var(--muted);cursor:pointer}.side{position:relative;border-left:1px solid var(--line);padding:25px;display:flex;flex-direction:column;gap:14px;background:rgba(0,0,0,.2)}.vision{position:relative;aspect-ratio:16/10;border:1px solid var(--line);border-radius:17px;overflow:hidden;background:#050403}.vision img{width:100%;height:100%;object-fit:cover}.vision .label{position:absolute;left:9px;top:9px;padding:.32rem .45rem;border-radius:7px;background:rgba(0,0,0,.7);font:600 .5rem var(--mono);color:var(--gold)}.transcript{flex:1;min-height:220px;max-height:360px;overflow:auto;padding:.65rem;border:1px solid var(--line);border-radius:15px;background:rgba(0,0,0,.22)}.turn{margin:.45rem 0;padding:.55rem .65rem;border-radius:11px;font-size:.75rem;line-height:1.45}.turn.user{margin-left:13%;background:rgba(244,207,114,.12);border:1px solid rgba(244,207,114,.18)}.turn.assistant{margin-right:13%;background:rgba(255,255,255,.045)}.compose{display:flex;gap:8px}.compose input{min-width:0;flex:1;border:1px solid var(--line);border-radius:11px;background:#090805;color:var(--ink);padding:.7rem}.compose button{border:1px solid var(--line);border-radius:11px;background:rgba(244,207,114,.12);color:var(--gold);padding:.7rem;cursor:pointer}.ops{display:none;position:absolute;inset:12px;z-index:8;padding:16px;border:1px solid rgba(244,207,114,.35);border-radius:18px;background:rgba(5,4,3,.94);backdrop-filter:blur(16px);overflow:auto}.ops.open{display:block}.ops-head{display:flex;align-items:center;justify-content:space-between}.ops h2{margin:0;font:700 1.2rem var(--display);color:var(--pale)}.close{border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--muted);padding:.4rem .55rem;cursor:pointer}.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin:14px 0}.fact{padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.fact span{display:block;color:var(--muted);font:500 .49rem var(--mono);text-transform:uppercase;letter-spacing:.11em}.fact b{display:block;margin-top:.3rem;color:var(--ink);font:600 .64rem var(--mono);word-break:break-all}.pipeline{display:grid;grid-template-columns:repeat(5,1fr);gap:7px;margin:15px 0}.step{padding:.65rem .3rem;border:1px solid var(--line);border-radius:11px;text-align:center;color:var(--muted);font:600 .52rem var(--mono);text-transform:uppercase}.step.done{color:var(--ok);border-color:rgba(100,223,160,.4)}.step.live{color:#171005;border-color:var(--gold);background:var(--gold);box-shadow:0 0 24px rgba(244,207,114,.25)}.step.bad{color:var(--bad);border-color:rgba(255,119,108,.5)}.events{display:grid;gap:7px}.event{display:grid;grid-template-columns:95px 70px 1fr;gap:8px;padding:.6rem;border-bottom:1px solid var(--line);font:500 .55rem var(--mono)}.event .ok{color:var(--ok)}.event .error,.event .failed{color:var(--bad)}.foot{text-align:center;color:#716956;font:500 .52rem var(--mono);letter-spacing:.08em}.error-box{display:none;margin-top:12px;color:var(--bad);font:.65rem var(--mono)}@media(max-width:780px){.shell{padding:10px}.stage{grid-template-columns:1fr;min-height:0}.hero{min-height:600px;padding:24px 15px}.side{border-left:0;border-top:1px solid var(--line)}.pipeline{grid-template-columns:1fr 1fr}.top{padding:4px}.event{grid-template-columns:75px 55px 1fr}}
|
|
10
|
+
</style></head><body><main class="shell"><header class="top"><div class="brand">RoboPark / managed character channel</div><div><button class="ops-toggle on" id="always-on" aria-pressed="true">Always on: ON</button> <button class="ops-toggle" id="ops-toggle">Operations</button></div></header><section class="stage"><div class="hero"><div class="avatar-wrap" id="avatar-wrap"><div class="fallback" id="fallback">RP</div><img class="avatar" id="avatar" alt=""></div><h1 id="character-name">Loading character...</h1><div class="subtitle" id="subtitle">Secure ElevenLabs conversation managed by RoboPark</div><div class="state" id="state">validating link</div><button class="join" id="join" disabled>Join conversation</button><button class="end" id="end" hidden>End conversation</button><div class="error-box" id="error"></div></div><aside class="side"><div class="vision" id="vision" hidden><img id="robot-video" alt="Live robot camera"><span class="label">LIVE ROBOT VISION</span></div><div class="transcript" id="transcript"><div class="turn assistant">The transcript will appear here when the conversation begins.</div></div><form class="compose" id="compose"><input id="message" placeholder="Type a message" disabled><button id="send" disabled>Send</button></form></aside><section class="ops" id="ops"><div class="ops-head"><h2>Live management overlay</h2><button class="close" id="ops-close">Close</button></div><div class="facts"><div class="fact"><span>Session</span><b id="f-session">not started</b></div><div class="fact"><span>Character</span><b id="f-character">--</b></div><div class="fact"><span>Agent</span><b id="f-agent">--</b></div><div class="fact"><span>Branch</span><b id="f-branch">default</b></div><div class="fact"><span>Robot</span><b id="f-robot">operator only</b></div><div class="fact"><span>Share link</span><b id="f-share">--</b></div></div><div class="pipeline" id="pipeline"><div class="step live" data-stage="configured">configured</div><div class="step" data-stage="permission">permission</div><div class="step" data-stage="signed_session">signed</div><div class="step" data-stage="connected">connected</div><div class="step" data-stage="conversation">conversation</div></div><div class="events" id="events"><div class="event"><span>waiting</span><span>info</span><span>Session telemetry will stream here.</span></div></div></section></section><footer class="foot">No API credentials are stored in this link. Each visit creates an isolated signed session.</footer></main><script src="/vendor/elevenlabs-client.iife.js"></script><script>
|
|
11
|
+
(function(){
|
|
12
|
+
var deployment=document.createElement('div');deployment.id='deployment';deployment.className='deployment';deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>Loading registered voice, vision, and motor nodes...</span><span></span></div>';document.getElementById('pipeline').before(deployment);
|
|
13
|
+
var audioControls=document.createElement('details');audioControls.id='audio-controls';audioControls.className='audio-controls';audioControls.innerHTML='<summary>Audio levels</summary><div class="audio-panel"><div class="audio-row"><label for="speaker-volume">Speaker</label><input id="speaker-volume" type="range" min="0" max="100" step="1"><output class="audio-value" id="speaker-value"></output></div><div class="audio-row"><label for="mic-sensitivity">Mic sensitivity</label><input id="mic-sensitivity" type="range" min="25" max="200" step="5"><output class="audio-value" id="mic-value"></output></div><p class="audio-note">Changes apply immediately and stay on this browser. High microphone gain may increase background noise.</p></div>';document.getElementById('error').after(audioControls);
|
|
14
|
+
var q=new URLSearchParams(location.search),character=q.get('character')||'',agent=q.get('agent_id')||'',branch=q.get('branch_id')||'',mode=q.get('mode')==='production'?'production':'test',robot=mode==='production'?(q.get('robot')||''):'',stack=q.get('stack')||'',share=q.get('share_id')||'',session=null,eventToken=null,conversation=null,conversationId=null,keepalive=null,poller=null,retryTimer=null,ending=false,desired=false,alwaysOn=true,connecting=false,generation=0,retryAttempt=0,lastProviderError=null,history=[],seen={},micPipelines=[],speakerVolume=storedNumber('robopark.voice.speaker',100,0,100),micSensitivity=storedNumber('robopark.voice.micSensitivity',100,25,200);
|
|
15
|
+
function storedNumber(key,fallback,min,max){try{var raw=localStorage.getItem(key);if(raw===null)return fallback;var value=Number(raw);return Number.isFinite(value)?Math.max(min,Math.min(max,value)):fallback}catch(_){return fallback}}
|
|
16
|
+
function persistNumber(key,value){try{localStorage.setItem(key,String(value))}catch(_){}}
|
|
17
|
+
function applySpeakerVolume(){if(conversation&&typeof conversation.setVolume==='function')try{conversation.setVolume({volume:speakerVolume/100})}catch(_){}}
|
|
18
|
+
function setMicSensitivity(value){micSensitivity=Math.max(25,Math.min(200,Number(value)||100));micPipelines.slice().forEach(function(pipe){try{pipe.gain.gain.setTargetAtTime(micSensitivity/100,pipe.context.currentTime,.015)}catch(_){}})}
|
|
19
|
+
function processMicrophone(stream){if(!stream||!stream.getAudioTracks||!stream.getAudioTracks().length)return stream;var AudioCtor=window.AudioContext||window.webkitAudioContext;if(!AudioCtor)return stream;try{var context=new AudioCtor(),source=context.createMediaStreamSource(stream),gain=context.createGain(),destination=context.createMediaStreamDestination(),pipe={context:context,source:source,gain:gain,destination:destination};gain.gain.value=micSensitivity/100;source.connect(gain);gain.connect(destination);micPipelines.push(pipe);if(context.state==='suspended')context.resume().catch(function(){});var outputTrack=destination.stream.getAudioTracks()[0],nativeStop=outputTrack.stop.bind(outputTrack),closed=false;outputTrack.stop=function(){if(closed)return;closed=true;nativeStop();stream.getTracks().forEach(function(track){try{track.stop()}catch(_){}});try{source.disconnect();gain.disconnect()}catch(_){};micPipelines=micPipelines.filter(function(item){return item!==pipe});context.close().catch(function(){})};stream.getAudioTracks().forEach(function(track){track.addEventListener('ended',function(){try{outputTrack.stop()}catch(_){}})});return new MediaStream(stream.getVideoTracks().concat([outputTrack]))}catch(_){return stream}}
|
|
20
|
+
function createSilentTestMicrophone(){var AudioCtor=window.AudioContext||window.webkitAudioContext;if(!AudioCtor)throw new Error('This browser has no usable microphone or Web Audio support.');var context=new AudioCtor(),oscillator=context.createOscillator(),gain=context.createGain(),destination=context.createMediaStreamDestination();gain.gain.value=0;oscillator.connect(gain);gain.connect(destination);oscillator.start();var track=destination.stream.getAudioTracks()[0],nativeStop=track.stop.bind(track),closed=false;track.stop=function(){if(closed)return;closed=true;try{oscillator.stop()}catch(_){}nativeStop();context.close().catch(function(){})};var subtitle=document.getElementById('subtitle');if(subtitle)subtitle.textContent='No local microphone attached · agent audio and typed test enabled';return new MediaStream([track])}
|
|
21
|
+
function installMicrophoneRecovery(){
|
|
22
|
+
var media=navigator.mediaDevices;if(!media||!media.getUserMedia||media.__roboparkRecovered)return;var nativeGetUserMedia=media.getUserMedia.bind(media);media.__roboparkRecovered=true;
|
|
23
|
+
media.getUserMedia=function(constraints){return nativeGetUserMedia(constraints).catch(function(error){var audio=constraints&&constraints.audio,recoverable=error&&['NotFoundError','DevicesNotFoundError','OverconstrainedError'].includes(error.name);if(!audio||!recoverable)throw error;var fallback=audio===true?{echoCancellation:true,noiseSuppression:true,autoGainControl:true}:Object.assign({},audio);delete fallback.deviceId;delete fallback.groupId;delete fallback.sampleRate;delete fallback.voiceIsolation;return media.enumerateDevices().then(function(devices){var inputs=devices.filter(function(device){return device.kind==='audioinput'&&device.deviceId}),input=inputs.find(function(device){return device.deviceId!=='default'&&device.deviceId!=='communications'})||inputs[0];if(!input){if(mode==='test')return createSilentTestMicrophone();throw new Error('No browser microphone is currently attached. Connect or enable a microphone, then retry.')}fallback.deviceId={exact:input.deviceId};return nativeGetUserMedia({audio:fallback})}).catch(function(secondError){if(secondError&&String(secondError.message||'').indexOf('No browser microphone')===0)throw secondError;if(mode==='test'&&secondError&&['NotFoundError','DevicesNotFoundError','OverconstrainedError'].includes(secondError.name))return createSilentTestMicrophone();delete fallback.deviceId;return nativeGetUserMedia({audio:fallback})})}).then(function(stream){return constraints&&constraints.audio?processMicrophone(stream):stream})};
|
|
24
|
+
}
|
|
25
|
+
installMicrophoneRecovery();
|
|
26
|
+
function ensureElevenLabsClient(){if(window.ElevenLabsClient&&window.ElevenLabsClient.Conversation)return Promise.resolve(window.ElevenLabsClient);return new Promise(function(resolve,reject){var script=document.createElement('script');script.src='/vendor/elevenlabs-client.iife.js';script.onload=function(){if(window.ElevenLabsClient&&window.ElevenLabsClient.Conversation)resolve(window.ElevenLabsClient);else reject(new Error('ElevenLabs browser SDK loaded without a Conversation client.'))};script.onerror=function(){reject(new Error('ElevenLabs browser SDK is unavailable. Check the RoboPark control service build.'))};document.head.appendChild(script)})}
|
|
27
|
+
function el(id){return document.getElementById(id)}function text(id,value){var node=el(id);if(node)node.textContent=value==null?'--':String(value)}function fail(message){text('state','unable to connect');el('error').style.display='block';text('error',message);el('join').disabled=false;stage('configured','bad')}
|
|
28
|
+
function stage(name,status){var order=['configured','permission','signed_session','connected','conversation'],index=order.indexOf(name);document.querySelectorAll('.step').forEach(function(node,i){node.className='step '+(status==='bad'&&i===index?'bad':i<index?'done':i===index?'live':'')})}
|
|
29
|
+
function headers(){return {'content-type':'application/json','X-RoboPark-Session-Token':eventToken||''}}
|
|
30
|
+
function api(path,options){options=options||{};if(path==='/api/sessions/voice-call'&&options.body){var payload=JSON.parse(options.body);payload.call_mode=mode;payload.robot_id=mode==='production'?(robot||null):null;options.body=JSON.stringify(payload)}return fetch('/robopark'+path,Object.assign({cache:'no-store'},options)).then(function(response){if(!response.ok)return response.text().then(function(body){throw new Error(body||('request '+response.status))});return response.json()})}
|
|
31
|
+
function append(role,message){message=String(message||'').trim();if(!message)return;var key=role+'|'+message;if(seen[key])return;seen[key]=1;var node=document.createElement('div');node.className='turn '+role;node.textContent=message;el('transcript').appendChild(node);el('transcript').scrollTop=el('transcript').scrollHeight}
|
|
32
|
+
function management(path,body){if(!session||!eventToken)return Promise.resolve();return api('/api/sessions/'+encodeURIComponent(session)+path,{method:'POST',headers:headers(),body:JSON.stringify(body||{})}).catch(function(){})}
|
|
33
|
+
function event(stageName,status,message,details){var canonical={signed_session:'scheduler_session',provider_connected:'voice_worker',provider_error:'voice_worker',mode:String(message||'')==='speaking'?'playback_started':'stt_listening',interruption:'stt_listening',mcp_tool:'llm_response'}[stageName]||stageName;return management('/pipeline-events',{stage:canonical,status:status||'ok',message:message||'',details:details||{},source:'public_join'})}
|
|
34
|
+
function saveTurn(role,message){message=String(message||'').trim();if(!message)return Promise.resolve();return management('/transcript',{role:role,text:message,is_final:true,sequence:Object.keys(seen).length,source:'public_join'})}
|
|
35
|
+
function renderDetail(detail){var events=(detail&&detail.pipeline_events)||[],latest=events.length?events[events.length-1]:null;if(latest&&latest.status==='failed')stage('conversation','bad');el('events').innerHTML=events.slice(-9).reverse().map(function(item){return '<div class="event"><span>'+String(item.stage||'event').replace(/[<>]/g,'')+'</span><span class="'+String(item.status||'')+'">'+String(item.status||'')+'</span><span>'+String(item.message||'').replace(/[<>]/g,'')+'</span></div>'}).join('')||'<div class="event"><span>connected</span><span class="ok">ok</span><span>Waiting for conversation events.</span></div>'}
|
|
36
|
+
function pollDetail(){applySpeakerVolume();if(!session)return;api('/api/sessions/'+encodeURIComponent(session)+'/detail',{headers:headers()}).then(renderDetail).catch(function(){})}
|
|
37
|
+
function plainDetails(value){try{return JSON.parse(JSON.stringify(value||{}))}catch(_){return {raw:String(value||'')}}}
|
|
38
|
+
function closeManaged(reason,details){var oldSession=session,oldToken=eventToken;if(!oldSession||!oldToken)return Promise.resolve();return api('/api/sessions/'+encodeURIComponent(oldSession)+'/pipeline-events',{method:'POST',headers:{'content-type':'application/json','X-RoboPark-Session-Token':oldToken},body:JSON.stringify({stage:'voice_worker',status:reason==='visitor'?'ok':'warning',message:reason==='visitor'?'Operator ended voice conversation':'Provider session closed; supervisor will replace it',details:plainDetails(details),source:'public_join'})}).catch(function(){}).then(function(){return api('/api/sessions/'+encodeURIComponent(oldSession)+'/end?reason='+encodeURIComponent(reason),{method:'POST',headers:{'content-type':'application/json','X-RoboPark-Session-Token':oldToken}}).catch(function(){})})}
|
|
39
|
+
function stopTimers(){if(retryTimer){clearTimeout(retryTimer);retryTimer=null}if(keepalive){clearInterval(keepalive);keepalive=null}if(poller){clearInterval(poller);poller=null}}
|
|
40
|
+
function scheduleReconnect(){if(!desired||!alwaysOn||ending)return;connecting=false;conversation=null;conversationId=null;var attempt=++retryAttempt,delay=Math.min(30000,Math.round(750*Math.pow(2,Math.min(attempt-1,6))+Math.random()*500));text('state','reconnecting in '+Math.ceil(delay/1000)+'s');stage('connected');el('avatar-wrap').classList.remove('speaking');if(retryTimer)clearTimeout(retryTimer);retryTimer=setTimeout(function(){retryTimer=null;connectAttempt('reconnect')},delay)}
|
|
41
|
+
function recover(myGeneration,details){if(myGeneration!==generation||ending||!desired)return;var captured=plainDetails(details);captured.last_provider_error=lastProviderError;event('provider_error','failed','ElevenLabs disconnected; recovery scheduled',captured);closeManaged('provider_disconnected',captured);session=null;eventToken=null;conversation=null;conversationId=null;scheduleReconnect()}
|
|
42
|
+
function end(reason){if(ending)return;ending=true;desired=false;generation++;stopTimers();var active=conversation,details={manual:true,reason:reason||'visitor'};if(active)try{active.endSession()}catch(_){};closeManaged(reason||'visitor',details);session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;retryAttempt=0;stage('conversation','done');text('state','manually disconnected - supervisor stopped');el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=false;el('join').disabled=false;el('join').textContent='Start conversation';el('end').hidden=true;el('message').disabled=true;el('send').disabled=true;ending=false}
|
|
43
|
+
function callPayload(){return {character_preset_id:character,voice_stack_id:stack||null,client_name:'Public character link',engine:'elevenlabs',agent_id:agent,branch_id:branch||null,robot_id:robot||null,join_link_id:share||null}}
|
|
44
|
+
function connectAttempt(kind){if(!desired||ending||connecting||conversation)return;connecting=true;var myGeneration=++generation;text('state',kind==='reconnect'?'obtaining fresh voice session':'connecting');stage('signed_session');api('/api/sessions/voice-call',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(callPayload())}).then(function(call){if(myGeneration!==generation||!desired)throw new Error('connection superseded');session=call.session_id;eventToken=call.event_token;text('f-session',session);text('f-agent',call.agent_id);text('f-branch',call.branch_id||'default');text('f-robot',call.robot_id||'operator only');if(call.robot_video_ref&&robot){el('vision').hidden=false;el('robot-video').src='/fed/media/robots/'+encodeURIComponent(call.robot_video_ref)+'/video_feed'}stage('connected');event('signed_session','ok','Fresh signed ElevenLabs session created',{join_link_id:share,robot_id:robot,reconnect_attempt:retryAttempt});return ensureElevenLabsClient().then(function(SDK){return SDK.Conversation.startSession({signedUrl:call.signed_url,connectionType:'websocket',useWakeLock:true,onConnect:function(info){if(myGeneration!==generation)return;conversationId=info.conversationId;lastProviderError=null;retryAttempt=0;text('state','listening');el('error').style.display='none';el('avatar-wrap').classList.add('live');stage('conversation');management('/engine-connected',{conversation_id:conversationId,connection_type:'websocket',metadata:{join_link_id:share,public_join:true,supervised:true}});event('provider_connected','ok','ElevenLabs conversation connected',{conversation_id:conversationId,supervised:true})},onDisconnect:function(info){recover(myGeneration,info||{reason:'provider_disconnected'})},onError:function(message,context){if(myGeneration!==generation)return;lastProviderError={message:String(message||'Provider error'),context:plainDetails(context)};event('provider_error','failed',lastProviderError.message,lastProviderError);text('state','provider error - recovering')},onStatusChange:function(info){if(myGeneration===generation)event('provider_status',info&&info.status==='disconnected'?'warning':'ok',info&&info.status||'unknown',{})},onMessage:function(info){if(myGeneration!==generation)return;var message=String(info&&info.message||'').trim(),role=info&&info.role==='user'?'user':'assistant';append(role,message);if(message){history.push({role:role,text:message});history=history.slice(-20)}saveTurn(role,message)},onModeChange:function(info){if(myGeneration!==generation)return;var speaking=info.mode==='speaking';el('avatar-wrap').classList.toggle('speaking',speaking);text('state',speaking?'speaking':'listening');event('mode','ok',info.mode,{})},onInterruption:function(info){event('interruption','ok','Agent interrupted',info||{})},onMCPToolCall:function(info){event('mcp_tool','running','Tool call',info||{})}})})}).then(function(active){connecting=false;if(myGeneration!==generation||!desired){try{active.endSession()}catch(_){}return}conversation=active;if(kind==='reconnect'&&history.length&&conversation.sendContextualUpdate)conversation.sendContextualUpdate('RoboPark continuity context from the session that just reconnected:\\n'+history.map(function(turn){return turn.role+': '+turn.text}).join('\\n'));el('join').hidden=true;el('end').hidden=false;el('message').disabled=false;el('send').disabled=false;if(!keepalive)keepalive=setInterval(function(){management('/keepalive',{engine:'elevenlabs',conversation_id:conversationId,robot_id:robot,supervised:true})},10000);if(!poller)poller=setInterval(pollDetail,2000);pollDetail()}).catch(function(error){connecting=false;if(myGeneration!==generation||!desired)return;var details={message:error&&error.message||String(error),last_provider_error:lastProviderError};event('provider_error','failed','Voice connection attempt failed',details);closeManaged('connection_failed',details);session=null;eventToken=null;conversation=null;conversationId=null;if(alwaysOn)scheduleReconnect();else fail(details.message)})}
|
|
45
|
+
function start(){if(desired||conversation||connecting)return;desired=true;retryAttempt=0;el('join').disabled=true;text('state','requesting microphone permission');stage('permission');navigator.mediaDevices.getUserMedia({audio:true}).then(function(stream){stream.getTracks().forEach(function(track){track.stop()});connectAttempt('initial')}).catch(function(error){desired=false;fail(error&&error.message||String(error))})}
|
|
46
|
+
el('always-on').onclick=function(){alwaysOn=!alwaysOn;this.classList.toggle('on',alwaysOn);this.setAttribute('aria-pressed',String(alwaysOn));this.textContent='Always on: '+(alwaysOn?'ON':'OFF');if(!alwaysOn&&retryTimer){clearTimeout(retryTimer);retryTimer=null;text('state','disconnected - automatic recovery paused')}else if(alwaysOn&&desired&&!conversation&&!connecting){scheduleReconnect()}};
|
|
47
|
+
function pollDetail(){applySpeakerVolume();if(!session)return;api('/api/sessions/'+encodeURIComponent(session)+'/detail').then(renderDetail).catch(function(){})}
|
|
48
|
+
function deploymentRequest(path,options){var token=q.get('token')||'',suffix=token?(path.indexOf('?')>=0?'&':'?')+'token='+encodeURIComponent(token):'';return fetch('/robopark'+path+suffix,Object.assign({cache:'no-store'},options||{})).then(function(response){if(!response.ok)throw new Error('request '+response.status);return response.json()})}
|
|
49
|
+
function renderDeployment(data){var token=q.get('token')||'',rows=[];[['voice_vision_boxes','Windows voice + vision',['vision','conversation']],['motor_nodes','Pi motor server',['motor']]].forEach(function(group){var devices=data[group[0]]||[];if(!devices.length){rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span>not registered</span><span></span></div>');return}devices.forEach(function(device){var seen=Date.parse(device.last_heartbeat||''),live=isFinite(seen)&&Date.now()-seen<90000,buttons=token?group[2].map(function(service){return '<button data-device="'+device.id+'" data-service="'+service+'" data-action="start">Start '+service+'</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="restart">Restart</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="stop">Stop</button>'}).join(''):'management token required for controls';rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span class="'+(live?'ready':'')+'">'+String(device.name||device.id)+' / '+(live?'online':'stale')+'</span><div class="deployment-actions">'+buttons+'</div></div>')})});deployment.innerHTML=rows.join('');deployment.querySelectorAll('button[data-action]').forEach(function(button){button.onclick=function(){var action=button.getAttribute('data-action'),service=button.getAttribute('data-service'),device=button.getAttribute('data-device');if((action==='stop'||action==='restart')&&!confirm(action+' '+service+'?'))return;button.disabled=true;deploymentRequest('/api/devices/'+encodeURIComponent(device)+'/services/'+encodeURIComponent(service)+'/'+action,{method:'POST',headers:{'content-type':'application/json'}}).then(function(){button.textContent='queued'}).catch(function(error){button.textContent='failed';fail(error.message)}).then(function(){setTimeout(function(){button.disabled=false},1800)})}})}
|
|
50
|
+
function loadDeployment(){deploymentRequest('/api/characters/'+encodeURIComponent(character)+'/deployment').then(renderDeployment).catch(function(){deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>deployment status unavailable</span><span></span></div>'})}
|
|
51
|
+
el('speaker-volume').value=String(speakerVolume);text('speaker-value',speakerVolume+'%');el('mic-sensitivity').value=String(micSensitivity);text('mic-value',micSensitivity+'%');el('speaker-volume').oninput=function(){speakerVolume=Math.max(0,Math.min(100,Number(this.value)||0));text('speaker-value',speakerVolume+'%');persistNumber('robopark.voice.speaker',speakerVolume);applySpeakerVolume()};el('mic-sensitivity').oninput=function(){setMicSensitivity(this.value);text('mic-value',micSensitivity+'%');persistNumber('robopark.voice.micSensitivity',micSensitivity)};
|
|
52
|
+
el('ops-toggle').onclick=function(){el('ops').classList.toggle('open');this.classList.toggle('on',el('ops').classList.contains('open'))};el('ops-close').onclick=function(){el('ops').classList.remove('open');el('ops-toggle').classList.remove('on')};el('join').onclick=start;el('end').onclick=function(){end('visitor')};el('compose').onsubmit=function(e){e.preventDefault();var message=el('message').value.trim();if(message&&conversation){conversation.sendUserMessage(message);el('message').value=''}};
|
|
53
|
+
text('f-mode',mode);text('f-character',character);text('f-agent',agent);text('f-branch',branch||'default');text('f-robot',mode==='production'?(robot||'missing robot'):'not requested');text('f-share',share||'untracked');
|
|
54
|
+
if(character)loadDeployment();
|
|
55
|
+
if(!character||!agent){fail('This join link is incomplete. Ask the operator for a new character link.');return}if(mode==='production'&&!robot){fail('This production link has no registered robot. Ask the operator for a corrected link.');return}api('/api/character-presets/'+encodeURIComponent(character)).then(function(preset){text('character-name',preset.name||preset.id);text('subtitle',(mode==='test'?'Browser-only test call · ':'Production on-site call · ')+(preset.description||'Secure ElevenLabs conversation managed by RoboPark'));text('f-character',preset.name||preset.id);text('fallback',String(preset.name||preset.id).slice(0,2).toUpperCase());if(preset.img){el('avatar').src=preset.img;el('avatar').onload=function(){el('fallback').style.display='none'}};text('state','ready to connect');el('join').disabled=false}).catch(function(){fail('Character configuration is unavailable.')});
|
|
56
|
+
})();
|
|
57
|
+
</script></body></html>`;
|