@oneciel-ai/ciel-runtime 0.2.3 → 0.2.4
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 +4 -2
- package/ciel_runtime_support/config_repository.py +30 -0
- package/ciel_runtime_support/router_http.py +6 -0
- package/ciel_runtime_support/router_server_context.py +2 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/speech_http_controller.py +335 -0
- package/ciel_runtime_support/web_ui.py +248 -1
- package/docs/COLAB_SPEECH.md +32 -0
- package/package.json +4 -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/scripts/colab/bootstrap_moss_tts.py +106 -0
- package/scripts/colab/bootstrap_qwen_asr.py +106 -0
- package/scripts/configure_speech_workers.py +37 -0
- package/scripts/deploy_colab_speech.ps1 +47 -0
package/ciel_runtime.py
CHANGED
|
@@ -500,6 +500,7 @@ from ciel_runtime_support.runtime_paths import (CHANNEL_COMPACT_REQUEST_PATH, #
|
|
|
500
500
|
from ciel_runtime_support.runtime_restart import forced_upgrade_environment
|
|
501
501
|
from ciel_runtime_support.runtime_restart import running_from_npm_package as detect_running_from_npm_package
|
|
502
502
|
from ciel_runtime_support.secure_json_repository import SecureJsonEffects, SecureJsonRepository
|
|
503
|
+
from ciel_runtime_support.speech_http_controller import SpeechHttpController, SpeechHttpPorts
|
|
503
504
|
from ciel_runtime_support.session_import import ImportSessionHttpController, ImportSessionHttpPorts, ImportSessionLimits, ImportSessionRepository, ImportSessionService, import_record_line, import_tool_text, normalize_import_source
|
|
504
505
|
from ciel_runtime_support.slash_command_assets import ADVISOR_NATIVE_DISABLED_SLASH_COMMAND # noqa: F401 - compatibility export
|
|
505
506
|
from ciel_runtime_support.slash_command_assets import LEGACY_ADVISOR_CALL_MARKER # noqa: F401 - compatibility export
|
|
@@ -1700,6 +1701,7 @@ def render_router_home_html(cfg: dict[str, Any], provider: str, pcfg: dict[str,
|
|
|
1700
1701
|
def render_web_chat_html(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any]) -> str: return web_ui_controller().render_web_chat(cfg, provider, pcfg)
|
|
1701
1702
|
def handle_web_get(handler: BaseHTTPRequestHandler, path: str) -> bool: return web_ui_controller().handle_get(handler, path)
|
|
1702
1703
|
|
|
1704
|
+
def speech_http_controller() -> SpeechHttpController: return SpeechHttpController(SpeechHttpPorts(load_config, save_config, write_json, router_log))
|
|
1703
1705
|
def parse_json_body(raw: bytes) -> dict[str, Any]:
|
|
1704
1706
|
try:
|
|
1705
1707
|
value = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
@@ -2817,8 +2819,8 @@ def _router_server_context() -> RouterServerContext:
|
|
|
2817
2819
|
http_services = RouterHttpServices(
|
|
2818
2820
|
core=RouterHttpCore(load_config, reject_external_router_request, get_current_provider, parse_json_body, is_client_disconnect_error, router_log),
|
|
2819
2821
|
get=RouterHttpGetEndpoints(handle_codex_mcp_split_proxy_get, handle_events_get, handle_llm_config_get, handle_channel_mcp_get, handle_web_get,
|
|
2820
|
-
handle_chat_get, handle_plan_get, route_runtime_get),
|
|
2821
|
-
post=RouterHttpPostEndpoints(handle_codex_mcp_split_proxy_request, handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
|
|
2822
|
+
lambda handler, path: speech_http_controller().get(handler, path), handle_chat_get, handle_plan_get, route_runtime_get),
|
|
2823
|
+
post=RouterHttpPostEndpoints(handle_codex_mcp_split_proxy_request, lambda handler, path, raw, content_type: speech_http_controller().post(handler, path, raw, content_type), handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
|
|
2822
2824
|
handle_plan_post, route_runtime_post),
|
|
2823
2825
|
presentation=RouterHttpPresentation(render_router_home_html, router_health_payload, write_text_response, write_json, list_model_objects_for_request,
|
|
2824
2826
|
resolve_requested_model, model_object),
|
|
@@ -26,6 +26,36 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
|
|
|
26
26
|
"port": 0,
|
|
27
27
|
"tailscale_https": False,
|
|
28
28
|
},
|
|
29
|
+
"speech": {
|
|
30
|
+
"asr": {
|
|
31
|
+
"enabled": False,
|
|
32
|
+
"base_url": "http://ciel-asr:8000",
|
|
33
|
+
"endpoint": "/v1/audio/transcriptions",
|
|
34
|
+
"model": "Qwen/Qwen3-ASR-0.6B",
|
|
35
|
+
"language": "auto",
|
|
36
|
+
"api_key": "",
|
|
37
|
+
"timeout_seconds": 300,
|
|
38
|
+
},
|
|
39
|
+
"tts": {
|
|
40
|
+
"enabled": False,
|
|
41
|
+
"base_url": "http://ciel-tts:8091",
|
|
42
|
+
"endpoint": "/v1/audio/speech",
|
|
43
|
+
"voices_endpoint": "/v1/audio/voices",
|
|
44
|
+
"model": "OpenMOSS-Team/MOSS-TTS-Nano",
|
|
45
|
+
"voice": "default",
|
|
46
|
+
"language": "ko",
|
|
47
|
+
"response_format": "wav",
|
|
48
|
+
"speed": 1.0,
|
|
49
|
+
"auto_speak": False,
|
|
50
|
+
"api_key": "",
|
|
51
|
+
"timeout_seconds": 300,
|
|
52
|
+
},
|
|
53
|
+
"tailscale": {
|
|
54
|
+
"enabled": True,
|
|
55
|
+
"asr_hostname": "ciel-asr",
|
|
56
|
+
"tts_hostname": "ciel-tts",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
29
59
|
"claude_code": {
|
|
30
60
|
"compat_prompt_for_non_anthropic": True,
|
|
31
61
|
"channels": [],
|
|
@@ -90,6 +90,7 @@ class RouterHttpGetEndpoints:
|
|
|
90
90
|
llm_config: Callable[[Any, str], bool]
|
|
91
91
|
channel_mcp: Callable[[Any, str], bool]
|
|
92
92
|
web: Callable[[Any, str], bool]
|
|
93
|
+
speech: Callable[[Any, str], bool]
|
|
93
94
|
chat: Callable[[Any, str], bool]
|
|
94
95
|
plan: Callable[[Any, str], bool]
|
|
95
96
|
runtime: Callable[..., bool]
|
|
@@ -98,6 +99,7 @@ class RouterHttpGetEndpoints:
|
|
|
98
99
|
@dataclass(frozen=True, slots=True)
|
|
99
100
|
class RouterHttpPostEndpoints:
|
|
100
101
|
codex_mcp_split: Callable[[Any, str, bytes, str], bool]
|
|
102
|
+
speech: Callable[[Any, str, bytes, str], bool]
|
|
101
103
|
llm_config: Callable[[Any, str, dict[str, Any]], bool]
|
|
102
104
|
channel_mcp: Callable[[Any, str, dict[str, Any]], bool]
|
|
103
105
|
chat: Callable[[Any, str, dict[str, Any]], bool]
|
|
@@ -636,6 +638,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
|
|
|
636
638
|
return
|
|
637
639
|
if endpoints.web(self, path):
|
|
638
640
|
return
|
|
641
|
+
if endpoints.speech(self, path):
|
|
642
|
+
return
|
|
639
643
|
if endpoints.chat(self, path) or endpoints.plan(self, path):
|
|
640
644
|
return
|
|
641
645
|
provider, pcfg = services.core.get_current_provider(cfg)
|
|
@@ -676,6 +680,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
|
|
|
676
680
|
endpoints = services.post
|
|
677
681
|
if endpoints.codex_mcp_split(self, path, raw, "POST"):
|
|
678
682
|
return
|
|
683
|
+
if endpoints.speech(self, path, raw, str(self.headers.get("content-type") or "application/json")):
|
|
684
|
+
return
|
|
679
685
|
body = services.core.parse_json_body(raw)
|
|
680
686
|
if endpoints.llm_config(self, path, body):
|
|
681
687
|
return
|
|
@@ -49,6 +49,8 @@ class RouterServerContext:
|
|
|
49
49
|
"provider": provider,
|
|
50
50
|
"model": self.health.current_alias(cfg),
|
|
51
51
|
"web_chat": "/ca/web/chat",
|
|
52
|
+
"web_chat_api": "/ca/web/chat/api",
|
|
53
|
+
"speech": "/ca/speech/health",
|
|
52
54
|
"chat": "/ca/chat/health",
|
|
53
55
|
"plan": "/ca/plan/artifacts",
|
|
54
56
|
"events": "/ca/events",
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Speech configuration and OpenAI-compatible ASR/TTS proxy endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from http.server import BaseHTTPRequestHandler
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SpeechConfig = dict[str, Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class SpeechHttpPorts:
|
|
21
|
+
load_config: Callable[[], dict[str, Any]]
|
|
22
|
+
save_config: Callable[[dict[str, Any]], None]
|
|
23
|
+
write_json: Callable[..., None]
|
|
24
|
+
log: Callable[[str, str], None]
|
|
25
|
+
urlopen: Callable[..., Any] = urllib.request.urlopen
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class SpeechHttpController:
|
|
30
|
+
ports: SpeechHttpPorts
|
|
31
|
+
|
|
32
|
+
def get(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
|
|
33
|
+
if path == "/ca/speech/config":
|
|
34
|
+
self.ports.write_json(handler, self.public_config())
|
|
35
|
+
return True
|
|
36
|
+
if path == "/ca/speech/health":
|
|
37
|
+
self.ports.write_json(handler, self.health_payload())
|
|
38
|
+
return True
|
|
39
|
+
if path == "/ca/web/chat/api":
|
|
40
|
+
self.ports.write_json(handler, self.discovery_payload())
|
|
41
|
+
return True
|
|
42
|
+
if path == "/v1/audio/voices":
|
|
43
|
+
return self._proxy_get(handler, "tts", "voices_endpoint")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def post(
|
|
47
|
+
self,
|
|
48
|
+
handler: BaseHTTPRequestHandler,
|
|
49
|
+
path: str,
|
|
50
|
+
raw: bytes,
|
|
51
|
+
content_type: str,
|
|
52
|
+
) -> bool:
|
|
53
|
+
if path == "/ca/speech/config":
|
|
54
|
+
return self._save_public_config(handler, raw)
|
|
55
|
+
if path in {"/v1/audio/transcriptions", "/v1/audio/translations"}:
|
|
56
|
+
return self._proxy_asr(handler, raw, content_type)
|
|
57
|
+
if path in {"/v1/audio/speech", "/v1/audio/speech/batch"}:
|
|
58
|
+
return self._proxy_tts(handler, raw, content_type, batch=path.endswith("/batch"))
|
|
59
|
+
if path == "/v1/audio/voices":
|
|
60
|
+
return self._proxy_raw(handler, "tts", "voices_endpoint", raw, content_type)
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def public_config(self) -> dict[str, Any]:
|
|
64
|
+
speech = self._speech_config()
|
|
65
|
+
public: dict[str, Any] = {"ok": True}
|
|
66
|
+
for name in ("asr", "tts"):
|
|
67
|
+
source = speech.get(name) if isinstance(speech.get(name), dict) else {}
|
|
68
|
+
item = {key: value for key, value in source.items() if key != "api_key"}
|
|
69
|
+
item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
|
|
70
|
+
public[name] = item
|
|
71
|
+
tailscale = speech.get("tailscale")
|
|
72
|
+
public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
|
|
73
|
+
public["endpoints"] = self.discovery_payload()["endpoints"]
|
|
74
|
+
return public
|
|
75
|
+
|
|
76
|
+
def discovery_payload(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"ok": True,
|
|
79
|
+
"web_chat": "/ca/web/chat",
|
|
80
|
+
"endpoints": {
|
|
81
|
+
"chat_health": "GET /ca/channel/health",
|
|
82
|
+
"chat_messages": "GET|POST /ca/channel/messages",
|
|
83
|
+
"chat_wait": "GET /ca/channel/wait",
|
|
84
|
+
"chat_stream": "GET /ca/channel/stream",
|
|
85
|
+
"chat_files": "POST /ca/channel/files",
|
|
86
|
+
"speech_config": "GET|POST /ca/speech/config",
|
|
87
|
+
"speech_health": "GET /ca/speech/health",
|
|
88
|
+
"asr": "POST /v1/audio/transcriptions",
|
|
89
|
+
"asr_translate": "POST /v1/audio/translations",
|
|
90
|
+
"tts": "POST /v1/audio/speech",
|
|
91
|
+
"tts_batch": "POST /v1/audio/speech/batch",
|
|
92
|
+
"tts_voices": "GET|POST /v1/audio/voices",
|
|
93
|
+
"models": "GET /v1/models",
|
|
94
|
+
"responses": "POST /v1/responses",
|
|
95
|
+
"messages": "POST /v1/messages",
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def health_payload(self) -> dict[str, Any]:
|
|
100
|
+
services = {name: self._probe(name) for name in ("asr", "tts")}
|
|
101
|
+
return {"ok": all(not item["enabled"] or item["reachable"] for item in services.values()), "services": services}
|
|
102
|
+
|
|
103
|
+
def _speech_config(self) -> SpeechConfig:
|
|
104
|
+
speech = self.ports.load_config().get("speech")
|
|
105
|
+
return speech if isinstance(speech, dict) else {}
|
|
106
|
+
|
|
107
|
+
def _service_config(self, name: str) -> SpeechConfig:
|
|
108
|
+
service = self._speech_config().get(name)
|
|
109
|
+
return service if isinstance(service, dict) else {}
|
|
110
|
+
|
|
111
|
+
def _save_public_config(self, handler: BaseHTTPRequestHandler, raw: bytes) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
update = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
114
|
+
if not isinstance(update, dict):
|
|
115
|
+
raise ValueError("configuration body must be a JSON object")
|
|
116
|
+
config = self.ports.load_config()
|
|
117
|
+
speech = config.setdefault("speech", {})
|
|
118
|
+
if not isinstance(speech, dict):
|
|
119
|
+
speech = {}
|
|
120
|
+
config["speech"] = speech
|
|
121
|
+
for name in ("asr", "tts"):
|
|
122
|
+
incoming = update.get(name)
|
|
123
|
+
if not isinstance(incoming, dict):
|
|
124
|
+
continue
|
|
125
|
+
current = speech.setdefault(name, {})
|
|
126
|
+
if not isinstance(current, dict):
|
|
127
|
+
current = {}
|
|
128
|
+
speech[name] = current
|
|
129
|
+
for key, value in incoming.items():
|
|
130
|
+
if key in {"api_key_set", "clear_api_key"}:
|
|
131
|
+
continue
|
|
132
|
+
if key == "api_key" and not str(value or "").strip():
|
|
133
|
+
continue
|
|
134
|
+
current[key] = self._validated_value(name, key, value)
|
|
135
|
+
if incoming.get("clear_api_key") is True:
|
|
136
|
+
current["api_key"] = ""
|
|
137
|
+
tailscale = update.get("tailscale")
|
|
138
|
+
if isinstance(tailscale, dict):
|
|
139
|
+
current_tailscale = speech.setdefault("tailscale", {})
|
|
140
|
+
if not isinstance(current_tailscale, dict):
|
|
141
|
+
current_tailscale = {}
|
|
142
|
+
speech["tailscale"] = current_tailscale
|
|
143
|
+
for key in ("enabled", "asr_hostname", "tts_hostname"):
|
|
144
|
+
if key in tailscale:
|
|
145
|
+
current_tailscale[key] = tailscale[key]
|
|
146
|
+
self.ports.save_config(config)
|
|
147
|
+
self.ports.write_json(handler, self.public_config())
|
|
148
|
+
except (UnicodeError, ValueError, TypeError) as exc:
|
|
149
|
+
self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _validated_value(service: str, key: str, value: Any) -> Any:
|
|
154
|
+
allowed = {
|
|
155
|
+
"asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
|
|
156
|
+
"tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
|
|
157
|
+
}
|
|
158
|
+
if key not in allowed[service]:
|
|
159
|
+
raise ValueError(f"unsupported {service} setting: {key}")
|
|
160
|
+
if key in {"enabled", "auto_speak"}:
|
|
161
|
+
return bool(value)
|
|
162
|
+
if key == "timeout_seconds":
|
|
163
|
+
return max(1, min(3600, int(value)))
|
|
164
|
+
if key == "speed":
|
|
165
|
+
return max(0.25, min(4.0, float(value)))
|
|
166
|
+
text = str(value or "").strip()
|
|
167
|
+
if key == "base_url":
|
|
168
|
+
parsed = urllib.parse.urlparse(text)
|
|
169
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
170
|
+
raise ValueError(f"invalid {service} base_url")
|
|
171
|
+
return text.rstrip("/")
|
|
172
|
+
if key in {"endpoint", "voices_endpoint"} and not text.startswith("/"):
|
|
173
|
+
raise ValueError(f"{service} {key} must begin with /")
|
|
174
|
+
return text
|
|
175
|
+
|
|
176
|
+
def _probe(self, name: str) -> dict[str, Any]:
|
|
177
|
+
config = self._service_config(name)
|
|
178
|
+
enabled = bool(config.get("enabled"))
|
|
179
|
+
result: dict[str, Any] = {
|
|
180
|
+
"enabled": enabled,
|
|
181
|
+
"configured": bool(str(config.get("base_url") or "").strip()),
|
|
182
|
+
"reachable": False,
|
|
183
|
+
}
|
|
184
|
+
if not enabled:
|
|
185
|
+
return result
|
|
186
|
+
try:
|
|
187
|
+
request = urllib.request.Request(self._url(config, "/health"), headers=self._headers(config, "application/json"))
|
|
188
|
+
with self.ports.urlopen(request, timeout=min(10.0, self._timeout(config))) as response:
|
|
189
|
+
result["status"] = int(getattr(response, "status", 200))
|
|
190
|
+
result["reachable"] = result["status"] < 500
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
result["error"] = f"{type(exc).__name__}: {exc}"
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def _proxy_get(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str) -> bool:
|
|
196
|
+
config = self._service_config(service)
|
|
197
|
+
if not self._require_enabled(handler, service, config):
|
|
198
|
+
return True
|
|
199
|
+
request = urllib.request.Request(self._url(config, str(config.get(endpoint_key) or "")), headers=self._headers(config, "application/json"))
|
|
200
|
+
return self._open_and_write(handler, service, config, request)
|
|
201
|
+
|
|
202
|
+
def _proxy_asr(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str) -> bool:
|
|
203
|
+
config = self._service_config("asr")
|
|
204
|
+
if not self._require_enabled(handler, "asr", config):
|
|
205
|
+
return True
|
|
206
|
+
if "application/json" in content_type.lower():
|
|
207
|
+
try:
|
|
208
|
+
body = json.loads(raw.decode("utf-8"))
|
|
209
|
+
if not isinstance(body, dict):
|
|
210
|
+
raise ValueError("request must be a JSON object")
|
|
211
|
+
audio = base64.b64decode(str(body.get("audio_base64") or ""), validate=True)
|
|
212
|
+
if not audio:
|
|
213
|
+
raise ValueError("audio_base64 is required")
|
|
214
|
+
fields = {
|
|
215
|
+
"model": str(body.get("model") or config.get("model") or ""),
|
|
216
|
+
"language": str(body.get("language") or config.get("language") or ""),
|
|
217
|
+
"response_format": str(body.get("response_format") or "json"),
|
|
218
|
+
}
|
|
219
|
+
if fields["language"].lower() == "auto":
|
|
220
|
+
fields.pop("language")
|
|
221
|
+
raw, content_type = self._multipart(fields, "file", str(body.get("filename") or "recording.wav"), str(body.get("content_type") or "audio/wav"), audio)
|
|
222
|
+
except (ValueError, TypeError, UnicodeError) as exc:
|
|
223
|
+
self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
|
|
224
|
+
return True
|
|
225
|
+
return self._proxy_raw(handler, "asr", "endpoint", raw, content_type)
|
|
226
|
+
|
|
227
|
+
def _proxy_tts(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str, *, batch: bool) -> bool:
|
|
228
|
+
config = self._service_config("tts")
|
|
229
|
+
if not self._require_enabled(handler, "tts", config):
|
|
230
|
+
return True
|
|
231
|
+
if "application/json" in content_type.lower():
|
|
232
|
+
try:
|
|
233
|
+
body = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
234
|
+
if not isinstance(body, dict):
|
|
235
|
+
raise ValueError("request must be a JSON object")
|
|
236
|
+
if not batch:
|
|
237
|
+
body.setdefault("model", str(config.get("model") or ""))
|
|
238
|
+
body.setdefault("voice", str(config.get("voice") or "default"))
|
|
239
|
+
body.setdefault("language", str(config.get("language") or "Auto"))
|
|
240
|
+
body.setdefault("response_format", str(config.get("response_format") or "wav"))
|
|
241
|
+
body.setdefault("speed", float(config.get("speed") or 1.0))
|
|
242
|
+
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
243
|
+
except (ValueError, TypeError, UnicodeError) as exc:
|
|
244
|
+
self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
|
|
245
|
+
return True
|
|
246
|
+
endpoint = str(config.get("endpoint") or "/v1/audio/speech") + ("/batch" if batch else "")
|
|
247
|
+
return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type)
|
|
248
|
+
|
|
249
|
+
def _proxy_raw(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str, raw: bytes, content_type: str) -> bool:
|
|
250
|
+
config = self._service_config(service)
|
|
251
|
+
if not self._require_enabled(handler, service, config):
|
|
252
|
+
return True
|
|
253
|
+
return self._proxy_bytes(handler, service, config, str(config.get(endpoint_key) or ""), raw, content_type)
|
|
254
|
+
|
|
255
|
+
def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str) -> bool:
|
|
256
|
+
request = urllib.request.Request(
|
|
257
|
+
self._url(config, endpoint),
|
|
258
|
+
data=raw,
|
|
259
|
+
headers=self._headers(config, content_type or "application/octet-stream"),
|
|
260
|
+
method="POST",
|
|
261
|
+
)
|
|
262
|
+
return self._open_and_write(handler, service, config, request)
|
|
263
|
+
|
|
264
|
+
def _open_and_write(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
|
|
265
|
+
try:
|
|
266
|
+
with self.ports.urlopen(request, timeout=self._timeout(config)) as response:
|
|
267
|
+
self._write_bytes(handler, response.read(), int(getattr(response, "status", 200)), str(response.headers.get("content-type") or "application/octet-stream"))
|
|
268
|
+
except urllib.error.HTTPError as exc:
|
|
269
|
+
self._write_bytes(handler, exc.read(), int(exc.code), str(exc.headers.get("content-type") or "application/json"))
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
self.ports.log("ERROR", f"speech_proxy_failed service={service} error={type(exc).__name__}: {exc}")
|
|
272
|
+
self.ports.write_json(handler, {"error": {"type": "upstream_error", "message": f"{service.upper()} upstream unavailable: {exc}"}}, 502)
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _require_enabled(handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig) -> bool:
|
|
277
|
+
if bool(config.get("enabled")) and str(config.get("base_url") or "").strip():
|
|
278
|
+
return True
|
|
279
|
+
body = json.dumps({"error": {"type": "service_disabled", "message": f"{service.upper()} is not configured or enabled"}}).encode()
|
|
280
|
+
handler.send_response(503)
|
|
281
|
+
handler.send_header("content-type", "application/json")
|
|
282
|
+
handler.send_header("content-length", str(len(body)))
|
|
283
|
+
handler.end_headers()
|
|
284
|
+
handler.wfile.write(body)
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _write_bytes(handler: BaseHTTPRequestHandler, data: bytes, status: int, content_type: str) -> None:
|
|
289
|
+
handler.send_response(status)
|
|
290
|
+
handler.send_header("content-type", content_type)
|
|
291
|
+
handler.send_header("content-length", str(len(data)))
|
|
292
|
+
handler.send_header("cache-control", "no-store")
|
|
293
|
+
handler.end_headers()
|
|
294
|
+
handler.wfile.write(data)
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _headers(config: SpeechConfig, content_type: str) -> dict[str, str]:
|
|
298
|
+
headers = {"accept": "*/*", "content-type": content_type}
|
|
299
|
+
api_key = str(config.get("api_key") or "").strip()
|
|
300
|
+
if api_key:
|
|
301
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
302
|
+
return headers
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _url(config: SpeechConfig, endpoint: str) -> str:
|
|
306
|
+
return str(config.get("base_url") or "").rstrip("/") + "/" + endpoint.lstrip("/")
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _timeout(config: SpeechConfig) -> float:
|
|
310
|
+
try:
|
|
311
|
+
return max(1.0, min(3600.0, float(config.get("timeout_seconds") or 300)))
|
|
312
|
+
except (TypeError, ValueError):
|
|
313
|
+
return 300.0
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _multipart(fields: dict[str, str], file_field: str, filename: str, mime: str, data: bytes) -> tuple[bytes, str]:
|
|
317
|
+
boundary = "ciel-" + secrets.token_hex(16)
|
|
318
|
+
chunks: list[bytes] = []
|
|
319
|
+
for name, value in fields.items():
|
|
320
|
+
chunks.extend([
|
|
321
|
+
f"--{boundary}\r\n".encode(),
|
|
322
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
|
323
|
+
str(value).encode("utf-8"), b"\r\n",
|
|
324
|
+
])
|
|
325
|
+
safe_filename = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
326
|
+
chunks.extend([
|
|
327
|
+
f"--{boundary}\r\n".encode(),
|
|
328
|
+
f'Content-Disposition: form-data; name="{file_field}"; filename="{safe_filename}"\r\n'.encode(),
|
|
329
|
+
f"Content-Type: {mime}\r\n\r\n".encode(), data, b"\r\n",
|
|
330
|
+
f"--{boundary}--\r\n".encode(),
|
|
331
|
+
])
|
|
332
|
+
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = ["SpeechHttpController", "SpeechHttpPorts"]
|
|
@@ -112,6 +112,22 @@ def render_web_chat_page(
|
|
|
112
112
|
}}
|
|
113
113
|
.attach-button:hover {{ border-color: var(--accent); }}
|
|
114
114
|
.attach-button:disabled {{ opacity: .55; cursor: not-allowed; }}
|
|
115
|
+
.recording {{ border-color: #ef4444 !important; color: #fecaca !important; }}
|
|
116
|
+
.message-actions {{ display: flex; align-items: flex-start; padding: 4px; }}
|
|
117
|
+
.message-actions button {{ border: 1px solid var(--line); border-radius: 999px; background: #0b111b; color: var(--muted); cursor: pointer; padding: 4px 8px; }}
|
|
118
|
+
dialog {{ width: min(720px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 0; }}
|
|
119
|
+
dialog::backdrop {{ background: rgba(0,0,0,.72); }}
|
|
120
|
+
.settings-head {{ position: sticky; top: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }}
|
|
121
|
+
.settings-body {{ padding: 16px; display: grid; gap: 16px; }}
|
|
122
|
+
.settings-section {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: grid; gap: 10px; }}
|
|
123
|
+
.settings-section h3 {{ margin: 0; font-size: 14px; }}
|
|
124
|
+
.settings-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }}
|
|
125
|
+
.settings-grid label {{ display: grid; gap: 5px; color: var(--muted); font-size: 12px; }}
|
|
126
|
+
.settings-grid label.wide {{ grid-column: 1 / -1; }}
|
|
127
|
+
.settings-grid input, .settings-grid select {{ width: 100%; border: 1px solid var(--line); border-radius: 6px; background: #080d14; color: var(--text); padding: 8px; }}
|
|
128
|
+
.check {{ display: flex !important; grid-auto-flow: column; justify-content: start; align-items: center; gap: 7px !important; }}
|
|
129
|
+
.check input {{ width: auto; }}
|
|
130
|
+
.settings-actions {{ display: flex; justify-content: flex-end; gap: 8px; }}
|
|
115
131
|
#fileInput {{ display: none; }}
|
|
116
132
|
.attachment-tray {{ display: flex; gap: 7px; flex-wrap: wrap; min-height: 0; }}
|
|
117
133
|
.attachment-chip {{
|
|
@@ -135,6 +151,7 @@ def render_web_chat_page(
|
|
|
135
151
|
.bubble {{ max-width: 94%; }}
|
|
136
152
|
header {{ align-items: flex-start; flex-direction: column; }}
|
|
137
153
|
.pill {{ white-space: normal; }}
|
|
154
|
+
.settings-grid {{ grid-template-columns: 1fr; }}
|
|
138
155
|
}}
|
|
139
156
|
</style>
|
|
140
157
|
</head>
|
|
@@ -154,6 +171,8 @@ def render_web_chat_page(
|
|
|
154
171
|
<a href="/">Router Home</a>
|
|
155
172
|
<a href="/ca/events">Events</a>
|
|
156
173
|
<a href="/health">Health JSON</a>
|
|
174
|
+
<a href="/ca/web/chat/api">Chat API JSON</a>
|
|
175
|
+
<button class="ghost" id="speechSettingsButton" type="button">Speech Settings</button>
|
|
157
176
|
<button class="ghost" id="shareButton" type="button">Copy Chat Link</button>
|
|
158
177
|
<button class="ghost" id="clearButton" type="button">Clear Chat</button>
|
|
159
178
|
</div>
|
|
@@ -173,6 +192,7 @@ def render_web_chat_page(
|
|
|
173
192
|
<button class="primary" id="sendButton" type="submit">Send</button>
|
|
174
193
|
</div>
|
|
175
194
|
<div class="composer-actions">
|
|
195
|
+
<button class="attach-button" id="micButton" type="button">Start voice input</button>
|
|
176
196
|
<button class="attach-button" id="attachButton" type="button">Attach files</button>
|
|
177
197
|
<input id="fileInput" type="file" multiple>
|
|
178
198
|
<div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
|
|
@@ -181,6 +201,45 @@ def render_web_chat_page(
|
|
|
181
201
|
</form>
|
|
182
202
|
</main>
|
|
183
203
|
</div>
|
|
204
|
+
<dialog id="speechSettingsDialog">
|
|
205
|
+
<form id="speechSettingsForm">
|
|
206
|
+
<div class="settings-head"><strong>Speech Settings</strong><button class="ghost" id="speechSettingsClose" type="button">Close</button></div>
|
|
207
|
+
<div class="settings-body">
|
|
208
|
+
<section class="settings-section">
|
|
209
|
+
<h3>STT / Qwen ASR</h3>
|
|
210
|
+
<div class="settings-grid">
|
|
211
|
+
<label class="check"><input id="asrEnabled" type="checkbox"> Enable STT</label>
|
|
212
|
+
<label>Language<input id="asrLanguage" placeholder="auto"></label>
|
|
213
|
+
<label class="wide">Tailscale base URL<input id="asrBaseUrl" placeholder="http://ciel-asr:8000"></label>
|
|
214
|
+
<label class="wide">Model<input id="asrModel" placeholder="Qwen/Qwen3-ASR-0.6B"></label>
|
|
215
|
+
<label class="wide">Remote bearer token<input id="asrApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
216
|
+
</div>
|
|
217
|
+
</section>
|
|
218
|
+
<section class="settings-section">
|
|
219
|
+
<h3>TTS / MOSS-TTS-Nano</h3>
|
|
220
|
+
<div class="settings-grid">
|
|
221
|
+
<label class="check"><input id="ttsEnabled" type="checkbox"> Enable TTS</label>
|
|
222
|
+
<label class="check"><input id="ttsAutoSpeak" type="checkbox"> Speak replies automatically</label>
|
|
223
|
+
<label class="wide">Tailscale base URL<input id="ttsBaseUrl" placeholder="http://ciel-tts:8091"></label>
|
|
224
|
+
<label>Voice<input id="ttsVoice" placeholder="default"></label>
|
|
225
|
+
<label>Language<input id="ttsLanguage" placeholder="ko"></label>
|
|
226
|
+
<label class="wide">Model<input id="ttsModel" placeholder="OpenMOSS-Team/MOSS-TTS-Nano"></label>
|
|
227
|
+
<label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
<section class="settings-section">
|
|
231
|
+
<h3>Tailscale tunnel</h3>
|
|
232
|
+
<div class="settings-grid">
|
|
233
|
+
<label class="check"><input id="tailscaleEnabled" type="checkbox"> Use tailnet-only addresses</label>
|
|
234
|
+
<label>ASR hostname<input id="tailscaleAsrHostname" placeholder="ciel-asr"></label>
|
|
235
|
+
<label>TTS hostname<input id="tailscaleTtsHostname" placeholder="ciel-tts"></label>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="hint">The browser calls Ciel locally. Only the Ciel router connects to these Tailscale services.</div>
|
|
238
|
+
</section>
|
|
239
|
+
<div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="primary" type="submit">Save</button></div>
|
|
240
|
+
</div>
|
|
241
|
+
</form>
|
|
242
|
+
</dialog>
|
|
184
243
|
<script>
|
|
185
244
|
const MODEL = {json.dumps(model)};
|
|
186
245
|
const transcript = document.getElementById('transcript');
|
|
@@ -188,10 +247,16 @@ def render_web_chat_page(
|
|
|
188
247
|
const prompt = document.getElementById('prompt');
|
|
189
248
|
const sendButton = document.getElementById('sendButton');
|
|
190
249
|
const attachButton = document.getElementById('attachButton');
|
|
250
|
+
const micButton = document.getElementById('micButton');
|
|
191
251
|
const fileInput = document.getElementById('fileInput');
|
|
192
252
|
const attachmentTray = document.getElementById('attachmentTray');
|
|
193
253
|
const shareButton = document.getElementById('shareButton');
|
|
194
254
|
const clearButton = document.getElementById('clearButton');
|
|
255
|
+
const speechSettingsButton = document.getElementById('speechSettingsButton');
|
|
256
|
+
const speechSettingsDialog = document.getElementById('speechSettingsDialog');
|
|
257
|
+
const speechSettingsForm = document.getElementById('speechSettingsForm');
|
|
258
|
+
const speechSettingsClose = document.getElementById('speechSettingsClose');
|
|
259
|
+
const speechHealthButton = document.getElementById('speechHealthButton');
|
|
195
260
|
const statePill = document.getElementById('statePill');
|
|
196
261
|
const SESSION_KEY = 'ciel-runtime-web-chat-session';
|
|
197
262
|
const LAST_ID_KEY = 'ciel-runtime-web-chat-last-id';
|
|
@@ -218,6 +283,10 @@ def render_web_chat_page(
|
|
|
218
283
|
let lastId = Number(localStorage.getItem(scopedLastIdKey) || '0') || 0;
|
|
219
284
|
let eventSource = null;
|
|
220
285
|
let selectedFiles = [];
|
|
286
|
+
let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
|
|
287
|
+
let mediaRecorder = null;
|
|
288
|
+
let mediaStream = null;
|
|
289
|
+
let recordingChunks = [];
|
|
221
290
|
function setState(text, cls = '') {{
|
|
222
291
|
statePill.textContent = text;
|
|
223
292
|
statePill.className = 'pill ' + cls;
|
|
@@ -401,6 +470,16 @@ def render_web_chat_page(
|
|
|
401
470
|
bubble.innerHTML = renderMarkdown(text);
|
|
402
471
|
}}
|
|
403
472
|
row.appendChild(bubble);
|
|
473
|
+
if (role === 'assistant') {{
|
|
474
|
+
const actions = document.createElement('div');
|
|
475
|
+
actions.className = 'message-actions';
|
|
476
|
+
const speak = document.createElement('button');
|
|
477
|
+
speak.type = 'button';
|
|
478
|
+
speak.textContent = 'Speak';
|
|
479
|
+
speak.addEventListener('click', () => speakText(text));
|
|
480
|
+
actions.appendChild(speak);
|
|
481
|
+
row.appendChild(actions);
|
|
482
|
+
}}
|
|
404
483
|
if (mode === 'prepend') {{
|
|
405
484
|
transcript.insertBefore(row, transcript.firstChild);
|
|
406
485
|
}} else {{
|
|
@@ -424,7 +503,10 @@ def render_web_chat_page(
|
|
|
424
503
|
const text = message.message || '';
|
|
425
504
|
if (!text.trim()) return;
|
|
426
505
|
addBubble(roleForMessage(message), text, mode, message.id);
|
|
427
|
-
if (mode !== 'prepend' && message.sender_id !== 'web-user')
|
|
506
|
+
if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
|
|
507
|
+
setState('reply received', 'ok');
|
|
508
|
+
if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
|
|
509
|
+
}}
|
|
428
510
|
}}
|
|
429
511
|
function formatBytes(bytes) {{
|
|
430
512
|
const value = Number(bytes || 0);
|
|
@@ -471,6 +553,134 @@ def render_web_chat_page(
|
|
|
471
553
|
reader.readAsDataURL(file);
|
|
472
554
|
}});
|
|
473
555
|
}}
|
|
556
|
+
function setSpeechForm(config) {{
|
|
557
|
+
const asr = config.asr || {{}};
|
|
558
|
+
const tts = config.tts || {{}};
|
|
559
|
+
const tailscale = config.tailscale || {{}};
|
|
560
|
+
document.getElementById('asrEnabled').checked = Boolean(asr.enabled);
|
|
561
|
+
document.getElementById('asrBaseUrl').value = asr.base_url || '';
|
|
562
|
+
document.getElementById('asrModel').value = asr.model || '';
|
|
563
|
+
document.getElementById('asrLanguage').value = asr.language || 'auto';
|
|
564
|
+
document.getElementById('asrApiKey').value = '';
|
|
565
|
+
document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
566
|
+
document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
|
|
567
|
+
document.getElementById('ttsAutoSpeak').checked = Boolean(tts.auto_speak);
|
|
568
|
+
document.getElementById('ttsBaseUrl').value = tts.base_url || '';
|
|
569
|
+
document.getElementById('ttsModel').value = tts.model || '';
|
|
570
|
+
document.getElementById('ttsVoice').value = tts.voice || 'default';
|
|
571
|
+
document.getElementById('ttsLanguage').value = tts.language || 'ko';
|
|
572
|
+
document.getElementById('ttsApiKey').value = '';
|
|
573
|
+
document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
574
|
+
document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
|
|
575
|
+
document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
|
|
576
|
+
document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
|
|
577
|
+
micButton.disabled = !asr.enabled;
|
|
578
|
+
micButton.title = asr.enabled ? 'Record speech and transcribe it' : 'Enable STT in Speech Settings first';
|
|
579
|
+
}}
|
|
580
|
+
async function loadSpeechConfig() {{
|
|
581
|
+
const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
|
|
582
|
+
const data = await response.json();
|
|
583
|
+
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
|
|
584
|
+
speechConfig = data;
|
|
585
|
+
setSpeechForm(data);
|
|
586
|
+
return data;
|
|
587
|
+
}}
|
|
588
|
+
async function saveSpeechConfig() {{
|
|
589
|
+
const payload = {{
|
|
590
|
+
asr: {{
|
|
591
|
+
enabled: document.getElementById('asrEnabled').checked,
|
|
592
|
+
base_url: document.getElementById('asrBaseUrl').value,
|
|
593
|
+
model: document.getElementById('asrModel').value,
|
|
594
|
+
language: document.getElementById('asrLanguage').value,
|
|
595
|
+
api_key: document.getElementById('asrApiKey').value,
|
|
596
|
+
}},
|
|
597
|
+
tts: {{
|
|
598
|
+
enabled: document.getElementById('ttsEnabled').checked,
|
|
599
|
+
auto_speak: document.getElementById('ttsAutoSpeak').checked,
|
|
600
|
+
base_url: document.getElementById('ttsBaseUrl').value,
|
|
601
|
+
model: document.getElementById('ttsModel').value,
|
|
602
|
+
voice: document.getElementById('ttsVoice').value,
|
|
603
|
+
language: document.getElementById('ttsLanguage').value,
|
|
604
|
+
api_key: document.getElementById('ttsApiKey').value,
|
|
605
|
+
}},
|
|
606
|
+
tailscale: {{
|
|
607
|
+
enabled: document.getElementById('tailscaleEnabled').checked,
|
|
608
|
+
asr_hostname: document.getElementById('tailscaleAsrHostname').value,
|
|
609
|
+
tts_hostname: document.getElementById('tailscaleTtsHostname').value,
|
|
610
|
+
}},
|
|
611
|
+
}};
|
|
612
|
+
const response = await fetch('/ca/speech/config', {{method: 'POST', headers: {{'content-type': 'application/json', 'accept': 'application/json'}}, body: JSON.stringify(payload)}});
|
|
613
|
+
const data = await response.json();
|
|
614
|
+
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
|
|
615
|
+
speechConfig = data;
|
|
616
|
+
setSpeechForm(data);
|
|
617
|
+
return data;
|
|
618
|
+
}}
|
|
619
|
+
async function speakText(text) {{
|
|
620
|
+
if (!speechConfig.tts || !speechConfig.tts.enabled) {{
|
|
621
|
+
setState('TTS disabled', 'error');
|
|
622
|
+
return;
|
|
623
|
+
}}
|
|
624
|
+
try {{
|
|
625
|
+
setState('generating speech');
|
|
626
|
+
const response = await fetch('/v1/audio/speech', {{
|
|
627
|
+
method: 'POST',
|
|
628
|
+
headers: {{'content-type': 'application/json'}},
|
|
629
|
+
body: JSON.stringify({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}})
|
|
630
|
+
}});
|
|
631
|
+
if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
|
|
632
|
+
const blob = await response.blob();
|
|
633
|
+
const url = URL.createObjectURL(blob);
|
|
634
|
+
const audio = new Audio(url);
|
|
635
|
+
audio.addEventListener('ended', () => URL.revokeObjectURL(url), {{once: true}});
|
|
636
|
+
audio.addEventListener('error', () => URL.revokeObjectURL(url), {{once: true}});
|
|
637
|
+
await audio.play();
|
|
638
|
+
setState('speaking', 'ok');
|
|
639
|
+
}} catch (err) {{
|
|
640
|
+
setState('TTS error', 'error');
|
|
641
|
+
addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
|
|
642
|
+
}}
|
|
643
|
+
}}
|
|
644
|
+
async function transcribeRecording(blob) {{
|
|
645
|
+
setState('transcribing');
|
|
646
|
+
const audio_base64 = await fileToBase64(blob);
|
|
647
|
+
const response = await fetch('/v1/audio/transcriptions', {{
|
|
648
|
+
method: 'POST',
|
|
649
|
+
headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
|
|
650
|
+
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.webm', content_type: blob.type || 'audio/webm', model: speechConfig.asr.model, language: speechConfig.asr.language}})
|
|
651
|
+
}});
|
|
652
|
+
const text = await response.text();
|
|
653
|
+
let data = {{}};
|
|
654
|
+
try {{ data = text ? JSON.parse(text) : {{}}; }} catch {{}}
|
|
655
|
+
if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
|
|
656
|
+
const transcriptText = String(data.text || data.transcript || '').trim();
|
|
657
|
+
if (!transcriptText) throw new Error('ASR returned no transcript');
|
|
658
|
+
prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
|
|
659
|
+
prompt.focus();
|
|
660
|
+
setState('transcribed', 'ok');
|
|
661
|
+
}}
|
|
662
|
+
async function startVoiceInput() {{
|
|
663
|
+
if (!navigator.mediaDevices || !window.MediaRecorder) throw new Error('This browser does not support microphone recording');
|
|
664
|
+
mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
|
|
665
|
+
recordingChunks = [];
|
|
666
|
+
mediaRecorder = new MediaRecorder(mediaStream);
|
|
667
|
+
mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
|
|
668
|
+
mediaRecorder.addEventListener('stop', async () => {{
|
|
669
|
+
const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
|
|
670
|
+
if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
|
|
671
|
+
mediaStream = null;
|
|
672
|
+
micButton.textContent = 'Start voice input';
|
|
673
|
+
micButton.classList.remove('recording');
|
|
674
|
+
try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
|
|
675
|
+
}}, {{once: true}});
|
|
676
|
+
mediaRecorder.start();
|
|
677
|
+
micButton.textContent = 'Stop and transcribe';
|
|
678
|
+
micButton.classList.add('recording');
|
|
679
|
+
setState('recording', 'error');
|
|
680
|
+
}}
|
|
681
|
+
function stopVoiceInput() {{
|
|
682
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
|
|
683
|
+
}}
|
|
474
684
|
async function uploadAttachment(file) {{
|
|
475
685
|
const content = await fileToBase64(file);
|
|
476
686
|
const response = await fetch('/ca/channel/files', {{
|
|
@@ -663,6 +873,42 @@ def render_web_chat_page(
|
|
|
663
873
|
}}
|
|
664
874
|
}});
|
|
665
875
|
attachButton.addEventListener('click', () => fileInput.click());
|
|
876
|
+
micButton.addEventListener('click', async () => {{
|
|
877
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
|
|
878
|
+
stopVoiceInput();
|
|
879
|
+
return;
|
|
880
|
+
}}
|
|
881
|
+
try {{ await startVoiceInput(); }} catch (err) {{
|
|
882
|
+
setState('microphone error', 'error');
|
|
883
|
+
addBubble('system', 'Microphone failed: ' + String(err && err.message ? err.message : err));
|
|
884
|
+
}}
|
|
885
|
+
}});
|
|
886
|
+
speechSettingsButton.addEventListener('click', async () => {{
|
|
887
|
+
try {{ await loadSpeechConfig(); }} catch (err) {{ addBubble('system', 'Could not load speech settings: ' + String(err && err.message ? err.message : err)); }}
|
|
888
|
+
speechSettingsDialog.showModal();
|
|
889
|
+
}});
|
|
890
|
+
speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
|
|
891
|
+
speechSettingsForm.addEventListener('submit', async event => {{
|
|
892
|
+
event.preventDefault();
|
|
893
|
+
try {{
|
|
894
|
+
await saveSpeechConfig();
|
|
895
|
+
speechSettingsDialog.close();
|
|
896
|
+
setState('speech settings saved', 'ok');
|
|
897
|
+
}} catch (err) {{
|
|
898
|
+
setState('settings error', 'error');
|
|
899
|
+
addBubble('system', 'Speech settings failed: ' + String(err && err.message ? err.message : err));
|
|
900
|
+
}}
|
|
901
|
+
}});
|
|
902
|
+
speechHealthButton.addEventListener('click', async () => {{
|
|
903
|
+
try {{
|
|
904
|
+
await saveSpeechConfig();
|
|
905
|
+
const response = await fetch('/ca/speech/health', {{headers: {{'accept': 'application/json'}}}});
|
|
906
|
+
const data = await response.json();
|
|
907
|
+
const asr = data.services && data.services.asr;
|
|
908
|
+
const tts = data.services && data.services.tts;
|
|
909
|
+
addBubble('system', `Speech health — ASR: ${{asr && asr.reachable ? 'reachable' : asr && asr.enabled ? 'unreachable' : 'disabled'}}, TTS: ${{tts && tts.reachable ? 'reachable' : tts && tts.enabled ? 'unreachable' : 'disabled'}}.`);
|
|
910
|
+
}} catch (err) {{ addBubble('system', 'Speech health check failed: ' + String(err && err.message ? err.message : err)); }}
|
|
911
|
+
}});
|
|
666
912
|
fileInput.addEventListener('change', () => {{
|
|
667
913
|
addSelectedFiles(fileInput.files);
|
|
668
914
|
fileInput.value = '';
|
|
@@ -706,6 +952,7 @@ def render_web_chat_page(
|
|
|
706
952
|
if (transcript.scrollTop < 48) loadOlderHistory();
|
|
707
953
|
}});
|
|
708
954
|
addBubble('system', `Connected to active session bridge for ${{MODEL}}. Messages are queued on channel ${{channel}} and replies stream back from /ca/channel/stream.`);
|
|
955
|
+
loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
|
|
709
956
|
loadInitialHistory().finally(startChannelStream);
|
|
710
957
|
prompt.focus();
|
|
711
958
|
</script>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Colab speech workers
|
|
2
|
+
|
|
3
|
+
Ciel Runtime can proxy its web chat and OpenAI-compatible audio API to two Colab workers over a tailnet-only Tailscale tunnel.
|
|
4
|
+
|
|
5
|
+
## One-time prerequisites
|
|
6
|
+
|
|
7
|
+
1. Authenticate the Colab CLI inside WSL. The installed CLI currently uses Google Application Default Credentials, so run `~/google-cloud-sdk/bin/gcloud auth application-default login` in `Ubuntu-26.04`.
|
|
8
|
+
2. Create a reusable or ephemeral Tailscale auth key. In each Colab account/notebook Secret store, add `TAILSCALE_AUTHKEY` and grant notebook access.
|
|
9
|
+
3. Optionally add the same `CIEL_SPEECH_API_KEY` secret to both workers. Enter that value once in Web Chat > Speech Settings; Ciel stores it server-side and never returns it to the browser.
|
|
10
|
+
|
|
11
|
+
## Deploy
|
|
12
|
+
|
|
13
|
+
From PowerShell at the repository root:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
.\scripts\deploy_colab_speech.ps1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The script creates `ciel-asr` and `ciel-tts` T4 sessions, installs Qwen3-ASR-0.6B and MOSS-TTS-Nano, starts Tailscale in userspace networking mode, publishes each localhost model server with Tailscale Serve, and saves both returned `base_url` values into Web Chat > Speech Settings automatically.
|
|
20
|
+
|
|
21
|
+
Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The workers are reachable only by devices in the same tailnet unless an administrator separately enables Tailscale Funnel.
|
|
22
|
+
|
|
23
|
+
## API surface
|
|
24
|
+
|
|
25
|
+
- `GET|POST /ca/speech/config`
|
|
26
|
+
- `GET /ca/speech/health`
|
|
27
|
+
- `POST /v1/audio/transcriptions`
|
|
28
|
+
- `POST /v1/audio/translations`
|
|
29
|
+
- `POST /v1/audio/speech`
|
|
30
|
+
- `POST /v1/audio/speech/batch`
|
|
31
|
+
- `GET|POST /v1/audio/voices`
|
|
32
|
+
- `GET /ca/web/chat/api` lists chat, model, message, response, file, and speech endpoints.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneciel-ai/ciel-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "One Ciel LLC",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"ciel-runtime-stop.cmd",
|
|
37
37
|
"ciel-runtime-stop.ps1",
|
|
38
38
|
"npm-bin/",
|
|
39
|
+
"scripts/colab/",
|
|
40
|
+
"scripts/configure_speech_workers.py",
|
|
41
|
+
"scripts/deploy_colab_speech.ps1",
|
|
39
42
|
"install.sh",
|
|
40
43
|
"install.ps1",
|
|
41
44
|
"README.md",
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Bootstrap MOSS-TTS-Nano with vLLM-Omni on Colab and Tailscale Serve.
|
|
2
|
+
|
|
3
|
+
Required Colab Secret: TAILSCALE_AUTHKEY
|
|
4
|
+
Optional Colab Secret: CIEL_SPEECH_API_KEY
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HOSTNAME = os.environ.get("CIEL_TTS_HOSTNAME", "ciel-tts")
|
|
20
|
+
PORT = 8091
|
|
21
|
+
SOCKET = "/tmp/ciel-tts-tailscaled.sock"
|
|
22
|
+
STATE = "/tmp/ciel-tts-tailscaled.state"
|
|
23
|
+
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def secret(name: str, *, required: bool = False) -> str:
|
|
27
|
+
value = str(os.environ.get(name) or "").strip()
|
|
28
|
+
if not value:
|
|
29
|
+
try:
|
|
30
|
+
from google.colab import userdata # type: ignore
|
|
31
|
+
|
|
32
|
+
value = str(userdata.get(name) or "").strip()
|
|
33
|
+
except Exception:
|
|
34
|
+
value = ""
|
|
35
|
+
if required and not value:
|
|
36
|
+
raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
41
|
+
print("+", " ".join(args))
|
|
42
|
+
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_tailscale() -> None:
|
|
46
|
+
if shutil.which("tailscale"):
|
|
47
|
+
return
|
|
48
|
+
run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
52
|
+
install_tailscale()
|
|
53
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
|
|
55
|
+
subprocess.Popen(
|
|
56
|
+
["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
|
|
57
|
+
stdout=tail_log,
|
|
58
|
+
stderr=subprocess.STDOUT,
|
|
59
|
+
start_new_session=True,
|
|
60
|
+
)
|
|
61
|
+
for _ in range(60):
|
|
62
|
+
if Path(SOCKET).exists():
|
|
63
|
+
break
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
|
|
66
|
+
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
67
|
+
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
68
|
+
serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
|
|
69
|
+
if serve.returncode == 0:
|
|
70
|
+
return dns_name, f"https://{dns_name}"
|
|
71
|
+
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
72
|
+
return dns_name, f"http://{dns_name}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def wait_for_server(api_key: str) -> None:
|
|
76
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
77
|
+
for _ in range(240):
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
80
|
+
if response.status < 500:
|
|
81
|
+
return
|
|
82
|
+
except Exception:
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> None:
|
|
88
|
+
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
89
|
+
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
90
|
+
run(sys.executable, "-m", "pip", "install", "-U", "vllm-omni==0.24.0")
|
|
91
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
command = [
|
|
93
|
+
"vllm", "serve", "OpenMOSS-Team/MOSS-TTS-Nano", "--omni", "--host", "127.0.0.1", "--port", str(PORT),
|
|
94
|
+
"--gpu-memory-utilization", "0.72", "--trust-remote-code", "--enforce-eager",
|
|
95
|
+
]
|
|
96
|
+
if api_key:
|
|
97
|
+
command.extend(["--api-key", api_key])
|
|
98
|
+
server_log = (LOG_DIR / "moss-tts.log").open("ab")
|
|
99
|
+
subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
|
|
100
|
+
wait_for_server(api_key)
|
|
101
|
+
dns_name, base_url = start_tailscale(auth_key)
|
|
102
|
+
print(json.dumps({"ok": True, "role": "tts", "hostname": dns_name, "base_url": base_url, "model": "OpenMOSS-Team/MOSS-TTS-Nano", "api_key_set": bool(api_key)}, indent=2))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Bootstrap Qwen3-ASR on a Colab T4 and publish it only to the tailnet.
|
|
2
|
+
|
|
3
|
+
Required Colab Secret: TAILSCALE_AUTHKEY
|
|
4
|
+
Optional Colab Secret: CIEL_SPEECH_API_KEY
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HOSTNAME = os.environ.get("CIEL_ASR_HOSTNAME", "ciel-asr")
|
|
20
|
+
PORT = 8000
|
|
21
|
+
SOCKET = "/tmp/ciel-asr-tailscaled.sock"
|
|
22
|
+
STATE = "/tmp/ciel-asr-tailscaled.state"
|
|
23
|
+
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def secret(name: str, *, required: bool = False) -> str:
|
|
27
|
+
value = str(os.environ.get(name) or "").strip()
|
|
28
|
+
if not value:
|
|
29
|
+
try:
|
|
30
|
+
from google.colab import userdata # type: ignore
|
|
31
|
+
|
|
32
|
+
value = str(userdata.get(name) or "").strip()
|
|
33
|
+
except Exception:
|
|
34
|
+
value = ""
|
|
35
|
+
if required and not value:
|
|
36
|
+
raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
41
|
+
print("+", " ".join(args))
|
|
42
|
+
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_tailscale() -> None:
|
|
46
|
+
if shutil.which("tailscale"):
|
|
47
|
+
return
|
|
48
|
+
run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
52
|
+
install_tailscale()
|
|
53
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tail_log = (LOG_DIR / "tailscale-asr.log").open("ab")
|
|
55
|
+
subprocess.Popen(
|
|
56
|
+
["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
|
|
57
|
+
stdout=tail_log,
|
|
58
|
+
stderr=subprocess.STDOUT,
|
|
59
|
+
start_new_session=True,
|
|
60
|
+
)
|
|
61
|
+
for _ in range(60):
|
|
62
|
+
if Path(SOCKET).exists():
|
|
63
|
+
break
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
|
|
66
|
+
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
67
|
+
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
68
|
+
serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
|
|
69
|
+
if serve.returncode == 0:
|
|
70
|
+
return dns_name, f"https://{dns_name}"
|
|
71
|
+
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
72
|
+
return dns_name, f"http://{dns_name}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def wait_for_server(api_key: str) -> None:
|
|
76
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
77
|
+
for _ in range(180):
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
80
|
+
if response.status < 500:
|
|
81
|
+
return
|
|
82
|
+
except Exception:
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
raise RuntimeError("Qwen ASR did not become healthy; inspect /content/ciel-speech-logs/qwen-asr.log")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> None:
|
|
88
|
+
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
89
|
+
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
90
|
+
run(sys.executable, "-m", "pip", "install", "-U", "qwen-asr[vllm]", "vllm[audio]")
|
|
91
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
command = [
|
|
93
|
+
"qwen-asr-serve", "Qwen/Qwen3-ASR-0.6B", "--host", "127.0.0.1", "--port", str(PORT),
|
|
94
|
+
"--gpu-memory-utilization", "0.78", "--max-model-len", "8192",
|
|
95
|
+
]
|
|
96
|
+
if api_key:
|
|
97
|
+
command.extend(["--api-key", api_key])
|
|
98
|
+
server_log = (LOG_DIR / "qwen-asr.log").open("ab")
|
|
99
|
+
subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
|
|
100
|
+
wait_for_server(api_key)
|
|
101
|
+
dns_name, base_url = start_tailscale(auth_key)
|
|
102
|
+
print(json.dumps({"ok": True, "role": "asr", "hostname": dns_name, "base_url": base_url, "model": "Qwen/Qwen3-ASR-0.6B", "api_key_set": bool(api_key)}, indent=2))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Persist deployed speech worker URLs in the active Ciel Runtime config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure(asr_base_url: str, tts_base_url: str) -> dict[str, Any]:
|
|
14
|
+
import ciel_runtime
|
|
15
|
+
|
|
16
|
+
config = ciel_runtime.load_config()
|
|
17
|
+
speech = config.setdefault("speech", {})
|
|
18
|
+
asr = speech.setdefault("asr", {})
|
|
19
|
+
tts = speech.setdefault("tts", {})
|
|
20
|
+
asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
|
|
21
|
+
tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
|
|
22
|
+
ciel_runtime.save_config(config)
|
|
23
|
+
return {"asr": asr["base_url"], "tts": tts["base_url"]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> int:
|
|
27
|
+
parser = argparse.ArgumentParser()
|
|
28
|
+
parser.add_argument("--asr-base-url", required=True)
|
|
29
|
+
parser.add_argument("--tts-base-url", required=True)
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
result = configure(args.asr_base_url, args.tts_base_url)
|
|
32
|
+
print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Distribution = "Ubuntu-26.04",
|
|
3
|
+
[string]$AsrSession = "ciel-asr",
|
|
4
|
+
[string]$TtsSession = "ciel-tts"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = "Stop"
|
|
8
|
+
$repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
9
|
+
$wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
|
|
10
|
+
if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
|
|
11
|
+
|
|
12
|
+
Write-Host "Checking Colab CLI authentication..."
|
|
13
|
+
& wsl -d $Distribution -- bash -lc "colab status >/dev/null"
|
|
14
|
+
if ($LASTEXITCODE -ne 0) {
|
|
15
|
+
throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Write-Host "Creating ASR T4 session: $AsrSession"
|
|
19
|
+
& wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$AsrSession'"
|
|
20
|
+
if ($LASTEXITCODE -ne 0) { throw "Could not create ASR Colab session." }
|
|
21
|
+
|
|
22
|
+
Write-Host "Creating TTS T4 session: $TtsSession"
|
|
23
|
+
& wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$TtsSession'"
|
|
24
|
+
if ($LASTEXITCODE -ne 0) { throw "Could not create TTS Colab session." }
|
|
25
|
+
|
|
26
|
+
Write-Host "Installing Qwen3-ASR and its Tailscale service..."
|
|
27
|
+
$asrOutput = (& wsl -d $Distribution -- bash -lc "colab exec --session '$AsrSession' --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
|
|
28
|
+
if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
|
|
29
|
+
|
|
30
|
+
Write-Host "Installing MOSS-TTS-Nano and its Tailscale service..."
|
|
31
|
+
$ttsOutput = (& wsl -d $Distribution -- bash -lc "colab exec --session '$TtsSession' --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
|
|
32
|
+
if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
|
|
33
|
+
|
|
34
|
+
function Read-BootstrapResult([string]$Text, [string]$Role) {
|
|
35
|
+
$matches = [regex]::Matches($Text, '(?s)\{\s*"ok"\s*:\s*true.*?\}')
|
|
36
|
+
if ($matches.Count -eq 0) { throw "Could not find the $Role bootstrap result in Colab output." }
|
|
37
|
+
$result = $matches[$matches.Count - 1].Value | ConvertFrom-Json
|
|
38
|
+
if ($result.role -ne $Role -or -not $result.base_url) { throw "Invalid $Role bootstrap result." }
|
|
39
|
+
return $result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
$asr = Read-BootstrapResult $asrOutput "asr"
|
|
43
|
+
$tts = Read-BootstrapResult $ttsOutput "tts"
|
|
44
|
+
& python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url
|
|
45
|
+
if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
|
|
46
|
+
|
|
47
|
+
Write-Host "Both services are running and connected to Web Chat > Speech Settings."
|