@oneciel-ai/ciel-runtime 0.2.10 → 0.2.11
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/ciel_runtime.py +3 -3
- package/ciel_runtime_support/config_repository.py +1 -0
- package/ciel_runtime_support/router_server_context.py +2 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/runtime_launch.py +10 -2
- package/ciel_runtime_support/runtime_paths.py +2 -1
- package/ciel_runtime_support/web_endpoints.py +109 -6
- package/ciel_runtime_support/web_ui.py +68 -5
- package/ciel_runtime_support/web_ui_controller.py +6 -0
- package/docs/COLAB_SPEECH.md +4 -0
- package/package.json +1 -1
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
package/ciel_runtime.py
CHANGED
|
@@ -491,7 +491,7 @@ from ciel_runtime_support.runtime_paths import (CHANNEL_COMPACT_REQUEST_PATH, #
|
|
|
491
491
|
OLLAMA_MODEL_CATALOG_PATH, PID_PATH, PLAN_ARTIFACTS_DIR,
|
|
492
492
|
RATE_LIMIT_STATE_PATH, REQUEST_DUMP_PATH, RESPONSE_DUMP_PATH,
|
|
493
493
|
ROUTER_ACTIVITY_PATH, ROUTER_BASE, ROUTER_CLIENTS_DIR,
|
|
494
|
-
ROUTER_EXTERNAL_TOKEN_PATH, ROUTER_HOST, ROUTER_INSTANCE_DIR, ROUTER_PORT, ROUTER_WORKSPACE, SSE_LAST_PATH,
|
|
494
|
+
ROUTER_EXTERNAL_TOKEN_PATH, ROUTER_HOST, ROUTER_INSTANCE_DIR, ROUTER_INSTANCE_ID, ROUTER_PORT, ROUTER_WORKSPACE, SSE_LAST_PATH,
|
|
495
495
|
SSE_TRACE_PATH, TOOL_CALL_LOG_PATH, USAGE_EVENTS_PATH,
|
|
496
496
|
WEB_TOOLS_MCP_CONFIG, ZAI_MCP_CONFIG, agy_user_bin_dir,
|
|
497
497
|
ciel_runtime_user_bin_dir, default_router_port,
|
|
@@ -1691,7 +1691,7 @@ def handle_llm_config_post(handler: BaseHTTPRequestHandler, path: str, body: dic
|
|
|
1691
1691
|
|
|
1692
1692
|
def web_ui_controller() -> WebUiController:
|
|
1693
1693
|
return WebUiController(
|
|
1694
|
-
constants=WebUiConstants(VERSION, ROUTER_ACTIVITY_PATH, CONTEXT_USAGE_PATH, DEFAULT_REQUEST_TIMEOUT_MS),
|
|
1694
|
+
constants=WebUiConstants(VERSION, ROUTER_ACTIVITY_PATH, CONTEXT_USAGE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, ROUTER_WORKSPACE, ROUTER_PORT, ROUTER_INSTANCE_ID),
|
|
1695
1695
|
projection=WebUiProjectionPorts(current_alias, read_json_file, router_rate_limit_usage, positive_int, timeout_profile_idle_ms, context_limit_for_status),
|
|
1696
1696
|
display=WebUiDisplayPorts(render_router_home_page, render_web_chat_page, provider_mode_label, api_key_status_line),
|
|
1697
1697
|
http=WebUiHttpPorts(load_config, get_current_provider, write_text_response),
|
|
@@ -2836,7 +2836,7 @@ def _router_server_context() -> RouterServerContext:
|
|
|
2836
2836
|
config=load_config())),
|
|
2837
2837
|
)
|
|
2838
2838
|
return RouterServerContext(
|
|
2839
|
-
health=RouterHealthPresentationPorts(VERSION, SOURCE_FINGERPRINT, os.getpid, getpass.getuser, HOME, ROUTER_INSTANCE_DIR, ROUTER_WORKSPACE, ROUTER_PORT, current_alias),
|
|
2839
|
+
health=RouterHealthPresentationPorts(VERSION, SOURCE_FINGERPRINT, os.getpid, getpass.getuser, HOME, ROUTER_INSTANCE_DIR, ROUTER_WORKSPACE, ROUTER_PORT, ROUTER_INSTANCE_ID, current_alias),
|
|
2840
2840
|
http_services=http_services,
|
|
2841
2841
|
server_runtime=server_runtime,
|
|
2842
2842
|
)
|
|
@@ -20,6 +20,7 @@ class RouterHealthPresentationPorts:
|
|
|
20
20
|
config_dir: Path
|
|
21
21
|
workspace: str
|
|
22
22
|
router_port: int
|
|
23
|
+
instance_id: str
|
|
23
24
|
current_alias: Callable[[dict[str, Any]], str]
|
|
24
25
|
|
|
25
26
|
|
|
@@ -46,6 +47,7 @@ class RouterServerContext:
|
|
|
46
47
|
"config_dir": str(self.health.config_dir),
|
|
47
48
|
"workspace": self.health.workspace,
|
|
48
49
|
"router_port": self.health.router_port,
|
|
50
|
+
"instance_id": self.health.instance_id,
|
|
49
51
|
"provider": provider,
|
|
50
52
|
"model": self.health.current_alias(cfg),
|
|
51
53
|
"web_chat": "/ca/web/chat",
|
|
@@ -19,7 +19,11 @@ from ciel_runtime_support.runtime_constants import (
|
|
|
19
19
|
ROUTED_COMPAT_PROMPT,
|
|
20
20
|
)
|
|
21
21
|
from ciel_runtime_support.runtime_paths import CONFIG_DIR, LOG_PATH, ROUTER_INSTANCE_DIR
|
|
22
|
-
from ciel_runtime_support.web_endpoints import
|
|
22
|
+
from ciel_runtime_support.web_endpoints import (
|
|
23
|
+
current_web_workspace,
|
|
24
|
+
web_backend_owned_by_workspace,
|
|
25
|
+
web_backend_settings,
|
|
26
|
+
)
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
CLAUDE_CODE_GENERATED_GREEDY_OPTIONS = frozenset(
|
|
@@ -32,7 +36,11 @@ def web_backend_start_requested(config: dict[str, Any]) -> bool:
|
|
|
32
36
|
"1", "true", "yes", "on"
|
|
33
37
|
}:
|
|
34
38
|
return True
|
|
35
|
-
|
|
39
|
+
settings = web_backend_settings(config)
|
|
40
|
+
return settings.enabled and web_backend_owned_by_workspace(
|
|
41
|
+
settings,
|
|
42
|
+
current_web_workspace(),
|
|
43
|
+
)
|
|
36
44
|
|
|
37
45
|
|
|
38
46
|
@dataclass(frozen=True, slots=True)
|
|
@@ -109,6 +109,7 @@ ROUTER_WORKSPACE = workspace_identity(
|
|
|
109
109
|
os.environ.get("CIEL_RUNTIME_LAUNCH_CWD") or Path.cwd()
|
|
110
110
|
)
|
|
111
111
|
_WORKSPACE_DIGEST = hashlib.sha256(ROUTER_WORKSPACE.encode("utf-8", errors="replace")).hexdigest()[:12]
|
|
112
|
+
ROUTER_INSTANCE_ID = f"{ROUTER_PORT}-{_WORKSPACE_DIGEST}"
|
|
112
113
|
_STATE_DIR_OVERRIDE = str(os.environ.get("CIEL_RUNTIME_STATE_DIR") or "").strip()
|
|
113
114
|
_TEST_STATE_ISOLATED = str(os.environ.get("CIEL_RUNTIME_TEST_ISOLATED") or "").strip().lower() in {
|
|
114
115
|
"1",
|
|
@@ -121,7 +122,7 @@ ROUTER_INSTANCE_DIR = (
|
|
|
121
122
|
if _STATE_DIR_OVERRIDE
|
|
122
123
|
else CONFIG_DIR
|
|
123
124
|
if _TEST_STATE_ISOLATED
|
|
124
|
-
else CONFIG_DIR / "router-instances" /
|
|
125
|
+
else CONFIG_DIR / "router-instances" / ROUTER_INSTANCE_ID
|
|
125
126
|
)
|
|
126
127
|
LOG_PATH = ROUTER_INSTANCE_DIR / "router.log"
|
|
127
128
|
LOG_LEVEL_PATH = CONFIG_DIR / "log-level"
|
|
@@ -13,6 +13,8 @@ from dataclasses import dataclass
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
from .workspace_router_selection import workspace_identity
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
_TRUE = {"1", "true", "yes", "on"}
|
|
18
20
|
_WILDCARD_HOSTS = {"0.0.0.0", "::", "[::]"}
|
|
@@ -49,6 +51,7 @@ class WebBackendSettings:
|
|
|
49
51
|
host: str = "127.0.0.1"
|
|
50
52
|
port: int = 0
|
|
51
53
|
tailscale_https: bool = False
|
|
54
|
+
workspace: str = ""
|
|
52
55
|
|
|
53
56
|
@property
|
|
54
57
|
def client_host(self) -> str:
|
|
@@ -74,7 +77,34 @@ def web_backend_settings(config: dict[str, Any] | None) -> WebBackendSettings:
|
|
|
74
77
|
if enabled_value is not None
|
|
75
78
|
else bool(tailscale_https or host != "127.0.0.1" or port)
|
|
76
79
|
)
|
|
77
|
-
|
|
80
|
+
workspace = workspace_identity(values.get("workspace"))
|
|
81
|
+
return WebBackendSettings(enabled, host, port, tailscale_https, workspace)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def current_web_workspace(
|
|
85
|
+
environ: MutableMapping[str, str] | None = None,
|
|
86
|
+
) -> str:
|
|
87
|
+
environment = os.environ if environ is None else environ
|
|
88
|
+
return workspace_identity(environment.get("CIEL_RUNTIME_LAUNCH_CWD") or Path.cwd())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def web_backend_owned_by_workspace(
|
|
92
|
+
settings: WebBackendSettings,
|
|
93
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
94
|
+
) -> bool:
|
|
95
|
+
if not settings.workspace:
|
|
96
|
+
return True
|
|
97
|
+
return settings.workspace == workspace_identity(workspace or current_web_workspace())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def web_backend_owned_by_instance(
|
|
101
|
+
settings: WebBackendSettings,
|
|
102
|
+
router_port: int,
|
|
103
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
104
|
+
) -> bool:
|
|
105
|
+
if not web_backend_owned_by_workspace(settings, workspace):
|
|
106
|
+
return False
|
|
107
|
+
return not settings.port or settings.port == router_port
|
|
78
108
|
|
|
79
109
|
|
|
80
110
|
def load_saved_web_backend(path: os.PathLike[str] | str) -> WebBackendSettings:
|
|
@@ -117,7 +147,11 @@ def web_backend_panel_rows(
|
|
|
117
147
|
|
|
118
148
|
|
|
119
149
|
def update_web_backend_config(
|
|
120
|
-
config: dict[str, Any],
|
|
150
|
+
config: dict[str, Any],
|
|
151
|
+
key: str,
|
|
152
|
+
value: Any,
|
|
153
|
+
effective_port: int,
|
|
154
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
121
155
|
) -> list[str]:
|
|
122
156
|
current = web_backend_settings(config)
|
|
123
157
|
enabled = current.enabled
|
|
@@ -144,6 +178,7 @@ def update_web_backend_config(
|
|
|
144
178
|
"host": host,
|
|
145
179
|
"port": port or effective_port,
|
|
146
180
|
"tailscale_https": tailscale_https,
|
|
181
|
+
"workspace": workspace_identity(workspace or current_web_workspace()),
|
|
147
182
|
}
|
|
148
183
|
external = host in _WILDCARD_HOSTS or not _is_loopback(host)
|
|
149
184
|
config["router_debug_external_access"] = external
|
|
@@ -152,6 +187,7 @@ def update_web_backend_config(
|
|
|
152
187
|
return [
|
|
153
188
|
f"Web backend: {'on' if enabled else 'off'} · {host}:{port or effective_port}.",
|
|
154
189
|
f"Tailscale HTTPS: {'on' if tailscale_https else 'off'}.",
|
|
190
|
+
f"Web instance: {config['web_backend']['workspace']}:{port or effective_port}.",
|
|
155
191
|
f"Runtime is restarting so {scheme} endpoint settings apply now.",
|
|
156
192
|
]
|
|
157
193
|
|
|
@@ -341,6 +377,44 @@ def tailscale_https_url_for_target(
|
|
|
341
377
|
return ""
|
|
342
378
|
|
|
343
379
|
|
|
380
|
+
def tailscale_proxy_target_for_port(
|
|
381
|
+
public_port: int,
|
|
382
|
+
*,
|
|
383
|
+
executable: str | None = None,
|
|
384
|
+
timeout: float = 2.0,
|
|
385
|
+
) -> str:
|
|
386
|
+
command = executable or shutil.which("tailscale")
|
|
387
|
+
if not command:
|
|
388
|
+
return ""
|
|
389
|
+
try:
|
|
390
|
+
completed = subprocess.run(
|
|
391
|
+
[command, "serve", "status", "--json"],
|
|
392
|
+
capture_output=True,
|
|
393
|
+
text=True,
|
|
394
|
+
timeout=timeout,
|
|
395
|
+
check=False,
|
|
396
|
+
)
|
|
397
|
+
if completed.returncode != 0:
|
|
398
|
+
return ""
|
|
399
|
+
payload = json.loads(completed.stdout)
|
|
400
|
+
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
|
401
|
+
return ""
|
|
402
|
+
web = payload.get("Web") if isinstance(payload.get("Web"), dict) else {}
|
|
403
|
+
suffix = f":{public_port}"
|
|
404
|
+
for authority, config in web.items():
|
|
405
|
+
if public_port == 443:
|
|
406
|
+
matches = str(authority).rsplit(":", 1)[-1] in {"443", str(authority)}
|
|
407
|
+
else:
|
|
408
|
+
matches = str(authority).endswith(suffix)
|
|
409
|
+
if not matches or not isinstance(config, dict):
|
|
410
|
+
continue
|
|
411
|
+
handlers = config.get("Handlers")
|
|
412
|
+
root = handlers.get("/") if isinstance(handlers, dict) else None
|
|
413
|
+
if isinstance(root, dict):
|
|
414
|
+
return str(root.get("Proxy") or "").rstrip("/")
|
|
415
|
+
return ""
|
|
416
|
+
|
|
417
|
+
|
|
344
418
|
def build_web_endpoint_report(
|
|
345
419
|
client_host: str,
|
|
346
420
|
bind_host: str,
|
|
@@ -375,13 +449,28 @@ def configure_tailscale_https(router_port: int, https_port: int | None = None) -
|
|
|
375
449
|
if not executable:
|
|
376
450
|
return ["Tailscale HTTPS was requested, but the tailscale CLI was not found."]
|
|
377
451
|
public_port = https_port or router_port
|
|
452
|
+
target = f"http://127.0.0.1:{router_port}"
|
|
453
|
+
existing_target = tailscale_proxy_target_for_port(
|
|
454
|
+
public_port,
|
|
455
|
+
executable=executable,
|
|
456
|
+
)
|
|
457
|
+
if (
|
|
458
|
+
existing_target
|
|
459
|
+
and existing_target != target
|
|
460
|
+
and public_port != router_port
|
|
461
|
+
):
|
|
462
|
+
return [
|
|
463
|
+
"Tailscale HTTPS setup refused: "
|
|
464
|
+
f"public port {public_port} already belongs to {existing_target}; "
|
|
465
|
+
f"this runtime is {target}. Use a unique public port."
|
|
466
|
+
]
|
|
378
467
|
command = [
|
|
379
468
|
executable,
|
|
380
469
|
"serve",
|
|
381
470
|
f"--https={public_port}",
|
|
382
471
|
"--bg",
|
|
383
472
|
"--yes",
|
|
384
|
-
|
|
473
|
+
target,
|
|
385
474
|
]
|
|
386
475
|
try:
|
|
387
476
|
completed = subprocess.run(
|
|
@@ -413,14 +502,24 @@ def configure_requested_web_endpoints(
|
|
|
413
502
|
environment = os.environ if environ is None else environ
|
|
414
503
|
lines: list[str] = []
|
|
415
504
|
saved = web_backend_settings(config)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
505
|
+
explicit = str(environment.get("CIEL_RUNTIME_TAILSCALE_HTTPS") or "").lower() in _TRUE
|
|
506
|
+
owned = web_backend_owned_by_instance(
|
|
507
|
+
saved,
|
|
508
|
+
router_port,
|
|
509
|
+
current_web_workspace(environment),
|
|
419
510
|
)
|
|
511
|
+
requested = explicit or (saved.enabled and saved.tailscale_https and owned)
|
|
420
512
|
if requested:
|
|
421
513
|
configured_port = str(environment.get("CIEL_RUNTIME_TAILSCALE_HTTPS_PORT") or "").strip()
|
|
422
514
|
https_port = _valid_port(configured_port, "CIEL_RUNTIME_TAILSCALE_HTTPS_PORT") if configured_port else None
|
|
423
515
|
lines.extend(configure_tailscale_https(router_port, https_port))
|
|
516
|
+
elif saved.enabled and saved.tailscale_https and not owned:
|
|
517
|
+
owner = saved.workspace or "legacy port"
|
|
518
|
+
lines.append(
|
|
519
|
+
"web_tailscale_skipped: settings belong to "
|
|
520
|
+
f"{owner}:{saved.port or 'auto'}, current instance is "
|
|
521
|
+
f"{current_web_workspace(environment)}:{router_port}"
|
|
522
|
+
)
|
|
424
523
|
lines.extend(build_web_endpoint_report(client_host, bind_host, router_port).status_lines())
|
|
425
524
|
return lines
|
|
426
525
|
|
|
@@ -437,11 +536,15 @@ __all__ = [
|
|
|
437
536
|
"build_web_endpoint_report",
|
|
438
537
|
"configure_requested_web_endpoints",
|
|
439
538
|
"configure_tailscale_https",
|
|
539
|
+
"current_web_workspace",
|
|
440
540
|
"discover_tailscale_node",
|
|
441
541
|
"load_saved_web_backend",
|
|
442
542
|
"tailscale_https_url_for_target",
|
|
543
|
+
"tailscale_proxy_target_for_port",
|
|
443
544
|
"update_web_backend_config",
|
|
444
545
|
"web_backend_panel_rows",
|
|
546
|
+
"web_backend_owned_by_instance",
|
|
547
|
+
"web_backend_owned_by_workspace",
|
|
445
548
|
"web_backend_settings",
|
|
446
549
|
"web_backend_summary",
|
|
447
550
|
]
|
|
@@ -12,11 +12,16 @@ def render_web_chat_page(
|
|
|
12
12
|
mode: str,
|
|
13
13
|
api_status: str,
|
|
14
14
|
timeout_ms: int,
|
|
15
|
+
workspace: str,
|
|
16
|
+
router_port: int,
|
|
17
|
+
instance_id: str,
|
|
15
18
|
) -> str:
|
|
16
19
|
escaped_model = html_lib.escape(model)
|
|
17
20
|
escaped_provider = html_lib.escape(provider)
|
|
18
21
|
escaped_mode = html_lib.escape(mode)
|
|
19
22
|
escaped_api_status = html_lib.escape(api_status)
|
|
23
|
+
escaped_workspace = html_lib.escape(workspace)
|
|
24
|
+
escaped_instance_id = html_lib.escape(instance_id)
|
|
20
25
|
return f"""<!doctype html>
|
|
21
26
|
<html lang="en">
|
|
22
27
|
<head>
|
|
@@ -173,6 +178,8 @@ def render_web_chat_page(
|
|
|
173
178
|
<div><div class="meta-label">API</div><div class="meta-value">{escaped_api_status}</div></div>
|
|
174
179
|
<div><div class="meta-label">Timeout</div><div class="meta-value">{timeout_ms:,} ms</div></div>
|
|
175
180
|
<div><div class="meta-label">Bridge</div><div class="meta-value">active session channel</div></div>
|
|
181
|
+
<div><div class="meta-label">Instance</div><div class="meta-value">{escaped_instance_id}</div></div>
|
|
182
|
+
<div><div class="meta-label">Workspace</div><div class="meta-value">{escaped_workspace}</div></div>
|
|
176
183
|
</div>
|
|
177
184
|
<div class="nav">
|
|
178
185
|
<a href="/">Router Home</a>
|
|
@@ -269,6 +276,20 @@ def render_web_chat_page(
|
|
|
269
276
|
</dialog>
|
|
270
277
|
<script>
|
|
271
278
|
const MODEL = {json.dumps(model)};
|
|
279
|
+
const EXPECTED_INSTANCE_ID = {json.dumps(instance_id)};
|
|
280
|
+
const EXPECTED_WORKSPACE = {json.dumps(workspace)};
|
|
281
|
+
const EXPECTED_ROUTER_PORT = {int(router_port)};
|
|
282
|
+
const ORIGIN_INSTANCE_KEY = 'ciel-runtime-web-chat-origin-instance:' + location.origin;
|
|
283
|
+
const bootstrapParams = new URLSearchParams(location.search);
|
|
284
|
+
const previousOriginInstance = localStorage.getItem(ORIGIN_INSTANCE_KEY) || '';
|
|
285
|
+
const rebindOriginInstance = bootstrapParams.get('rebind') === '1';
|
|
286
|
+
if (!previousOriginInstance || rebindOriginInstance) {{
|
|
287
|
+
localStorage.setItem(ORIGIN_INSTANCE_KEY, EXPECTED_INSTANCE_ID);
|
|
288
|
+
}}
|
|
289
|
+
const boundOriginInstance = rebindOriginInstance ? EXPECTED_INSTANCE_ID : previousOriginInstance;
|
|
290
|
+
let instanceIdentityBlocked = boundOriginInstance && boundOriginInstance !== EXPECTED_INSTANCE_ID
|
|
291
|
+
? `This browser origin is bound to ${{previousOriginInstance}}, but the page came from ${{EXPECTED_INSTANCE_ID}}.`
|
|
292
|
+
: '';
|
|
272
293
|
const transcript = document.getElementById('transcript');
|
|
273
294
|
const composer = document.getElementById('composer');
|
|
274
295
|
const prompt = document.getElementById('prompt');
|
|
@@ -533,6 +554,41 @@ def render_web_chat_page(
|
|
|
533
554
|
}}
|
|
534
555
|
return bubble;
|
|
535
556
|
}}
|
|
557
|
+
function blockRuntimeIdentity(reason) {{
|
|
558
|
+
const detail = String(reason || 'Runtime identity changed.');
|
|
559
|
+
if (instanceIdentityBlocked === detail && sendButton.disabled) return;
|
|
560
|
+
instanceIdentityBlocked = detail;
|
|
561
|
+
if (eventSource) eventSource.close();
|
|
562
|
+
eventSource = null;
|
|
563
|
+
sendButton.disabled = true;
|
|
564
|
+
attachButton.disabled = true;
|
|
565
|
+
micButton.disabled = true;
|
|
566
|
+
stopActiveSpeech();
|
|
567
|
+
setState('instance mismatch', 'error');
|
|
568
|
+
addBubble('system', 'Web Chat stopped to prevent cross-instance delivery. ' + detail + ' Restore the original proxy target, or intentionally open this URL once with ?rebind=1.');
|
|
569
|
+
}}
|
|
570
|
+
async function verifyRuntimeIdentity(options = {{}}) {{
|
|
571
|
+
if (instanceIdentityBlocked) {{
|
|
572
|
+
if (options.announce !== false) blockRuntimeIdentity(instanceIdentityBlocked);
|
|
573
|
+
return false;
|
|
574
|
+
}}
|
|
575
|
+
try {{
|
|
576
|
+
const response = await fetch('/health', {{headers: {{'accept': 'application/json'}}, cache: 'no-store'}});
|
|
577
|
+
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
|
|
578
|
+
const health = await response.json();
|
|
579
|
+
const actualInstance = String(health.instance_id || '');
|
|
580
|
+
const actualWorkspace = String(health.workspace || '');
|
|
581
|
+
const actualPort = Number(health.router_port || 0);
|
|
582
|
+
if (actualInstance !== EXPECTED_INSTANCE_ID || actualWorkspace !== EXPECTED_WORKSPACE || actualPort !== EXPECTED_ROUTER_PORT) {{
|
|
583
|
+
blockRuntimeIdentity(`Expected ${{EXPECTED_INSTANCE_ID}} (${{EXPECTED_WORKSPACE}}:${{EXPECTED_ROUTER_PORT}}), received ${{actualInstance || 'unknown'}} (${{actualWorkspace || 'unknown'}}:${{actualPort || 'unknown'}}).`);
|
|
584
|
+
return false;
|
|
585
|
+
}}
|
|
586
|
+
return true;
|
|
587
|
+
}} catch (err) {{
|
|
588
|
+
if (options.announce !== false) setState('identity check failed', 'error');
|
|
589
|
+
return false;
|
|
590
|
+
}}
|
|
591
|
+
}}
|
|
536
592
|
function structuredWebResponse(message) {{
|
|
537
593
|
const value = message && message.meta && message.meta.web_response;
|
|
538
594
|
if (!value || typeof value !== 'object') return null;
|
|
@@ -965,6 +1021,7 @@ def render_web_chat_page(
|
|
|
965
1021
|
}}
|
|
966
1022
|
}}
|
|
967
1023
|
async function startVoiceInput() {{
|
|
1024
|
+
if (!await verifyRuntimeIdentity()) throw new Error('Runtime identity verification failed');
|
|
968
1025
|
if (!navigator.mediaDevices) throw new Error('This browser does not support microphone recording');
|
|
969
1026
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
970
1027
|
if (!AudioContextClass) throw new Error('Live voice requires Web Audio support');
|
|
@@ -1119,7 +1176,8 @@ def render_web_chat_page(
|
|
|
1119
1176
|
historyLoading = false;
|
|
1120
1177
|
}}
|
|
1121
1178
|
}}
|
|
1122
|
-
function startChannelStream() {{
|
|
1179
|
+
async function startChannelStream() {{
|
|
1180
|
+
if (!await verifyRuntimeIdentity()) return;
|
|
1123
1181
|
if (eventSource) eventSource.close();
|
|
1124
1182
|
const url = `/ca/channel/stream?channel=${{encodeURIComponent(channel)}}&recipient=web&after=${{lastId}}&timeout=3600`;
|
|
1125
1183
|
eventSource = new EventSource(url);
|
|
@@ -1137,6 +1195,7 @@ def render_web_chat_page(
|
|
|
1137
1195
|
}};
|
|
1138
1196
|
}}
|
|
1139
1197
|
async function sendMessage(text, files = [], options = {{}}) {{
|
|
1198
|
+
if (!await verifyRuntimeIdentity()) return;
|
|
1140
1199
|
setState('queued');
|
|
1141
1200
|
sendButton.disabled = true;
|
|
1142
1201
|
attachButton.disabled = true;
|
|
@@ -1292,10 +1351,14 @@ def render_web_chat_page(
|
|
|
1292
1351
|
transcript.addEventListener('scroll', () => {{
|
|
1293
1352
|
if (transcript.scrollTop < 48) loadOlderHistory();
|
|
1294
1353
|
}});
|
|
1295
|
-
addBubble('system', `
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1354
|
+
addBubble('system', `Connecting to runtime ${{EXPECTED_INSTANCE_ID}} for ${{MODEL}} on ${{EXPECTED_WORKSPACE}}.`);
|
|
1355
|
+
verifyRuntimeIdentity().then(ok => {{
|
|
1356
|
+
if (!ok) return;
|
|
1357
|
+
loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
|
|
1358
|
+
loadInitialHistory().finally(startChannelStream);
|
|
1359
|
+
prompt.focus();
|
|
1360
|
+
setInterval(() => verifyRuntimeIdentity({{announce: false}}), 5000);
|
|
1361
|
+
}});
|
|
1299
1362
|
</script>
|
|
1300
1363
|
</body>
|
|
1301
1364
|
</html>"""
|
|
@@ -17,6 +17,9 @@ class WebUiConstants:
|
|
|
17
17
|
activity_path: Path
|
|
18
18
|
context_usage_path: Path
|
|
19
19
|
default_timeout_ms: int
|
|
20
|
+
workspace: str = ""
|
|
21
|
+
router_port: int = 0
|
|
22
|
+
instance_id: str = ""
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
@dataclass(frozen=True, slots=True)
|
|
@@ -136,6 +139,9 @@ class WebUiController:
|
|
|
136
139
|
provider_config,
|
|
137
140
|
),
|
|
138
141
|
timeout_ms=timeout_ms,
|
|
142
|
+
workspace=self.constants.workspace,
|
|
143
|
+
router_port=self.constants.router_port,
|
|
144
|
+
instance_id=self.constants.instance_id,
|
|
139
145
|
)
|
|
140
146
|
|
|
141
147
|
def handle_get(self, handler: Any, path: str) -> bool:
|
package/docs/COLAB_SPEECH.md
CHANGED
|
@@ -32,6 +32,10 @@ The Colab Qwen endpoint remains a batch HTTP API, so the live caption is progres
|
|
|
32
32
|
|
|
33
33
|
Web Chat requests carry an input mode and a structured response contract. The active agent first sends a short acknowledgement and then a final response containing `spoken`, `overview`, and optional `details` fields. The browser renders the fields separately and sends only `spoken` to TTS, avoiding long Markdown, URLs, code, and tables in synthesized speech. Legacy plain `message` replies remain supported.
|
|
34
34
|
|
|
35
|
+
### Multiple local runtime instances
|
|
36
|
+
|
|
37
|
+
Web backend ownership is scoped to the normalized workspace and router port. A saved Web/Tailscale configuration is not inherited by another workspace, and Ciel refuses to take an explicitly selected Tailscale HTTPS port that already proxies a different local router. `/health` advertises a stable `instance_id` derived from the workspace and port. Web Chat binds each browser origin to that ID, verifies it before sends, voice capture, and SSE reconnects, and stops delivery if a proxy begins returning another runtime. Use `?rebind=1` only when intentionally assigning that browser origin to a different instance.
|
|
38
|
+
|
|
35
39
|
## API surface
|
|
36
40
|
|
|
37
41
|
- `GET|POST /ca/speech/config`
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|