openvoiceui 1.0.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.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,333 @@
1
+ """
2
+ GatewayManager — registry and router for all gateway implementations.
3
+
4
+ Responsibilities:
5
+ - Registers built-in gateways (OpenClaw) on startup
6
+ - Discovers and loads plugin gateways from plugins/*/plugin.json
7
+ - Routes stream_to_queue() calls to the correct gateway by ID
8
+ - Provides ask() for inter-gateway delegation (one agent calling another)
9
+ - Exposes list_gateways() for health endpoint and admin UI
10
+
11
+ Usage:
12
+ from services.gateway_manager import gateway_manager
13
+
14
+ # Standard voice request (routes to gateway from active profile):
15
+ gateway_manager.stream_to_queue(
16
+ event_queue, message, session_key, captured_actions,
17
+ gateway_id='openclaw' # or whatever the active profile specifies
18
+ )
19
+
20
+ # Inter-gateway delegation (one agent calling another):
21
+ response = gateway_manager.ask('claude-api', 'Summarise this text: ...', session_key)
22
+
23
+ Profile integration:
24
+ Profiles select a gateway via adapter_config.gateway_id.
25
+ If not set, defaults to 'openclaw'. Example profile snippet:
26
+
27
+ "adapter_config": {
28
+ "gateway_id": "claude-api",
29
+ "sessionKey": "claude-1"
30
+ }
31
+
32
+ Plugin discovery:
33
+ On startup, the manager scans plugins/*/plugin.json for entries where
34
+ "provides": "gateway". See plugins/README.md for the contributor guide.
35
+ """
36
+
37
+ import importlib.util
38
+ import json
39
+ import logging
40
+ import os
41
+ import queue
42
+ import sys
43
+ from pathlib import Path
44
+ from typing import Optional
45
+
46
+ from services.gateways.base import GatewayBase
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Root of the project (two levels up from this file)
51
+ _PROJECT_ROOT = Path(__file__).parent.parent
52
+ _PLUGINS_DIR = _PROJECT_ROOT / 'plugins'
53
+
54
+
55
+ class GatewayManager:
56
+ """Registry and router for all gateway implementations."""
57
+
58
+ def __init__(self):
59
+ self._gateways: dict[str, GatewayBase] = {}
60
+
61
+ # ------------------------------------------------------------------ #
62
+ # Registration #
63
+ # ------------------------------------------------------------------ #
64
+
65
+ def register(self, gateway: GatewayBase) -> None:
66
+ """Register a gateway instance. Overwrites any existing entry with the same ID."""
67
+ if not isinstance(gateway, GatewayBase):
68
+ raise TypeError(f"Expected GatewayBase subclass, got {type(gateway)}")
69
+ gid = gateway.gateway_id
70
+ if not gateway.is_configured():
71
+ logger.warning(
72
+ f"Gateway '{gid}' registered but not configured "
73
+ f"(missing env vars). Requests to it will fail."
74
+ )
75
+ self._gateways[gid] = gateway
76
+ status = "configured" if gateway.is_configured() else "NOT configured"
77
+ logger.info(f"GatewayManager: registered '{gid}' ({status})")
78
+
79
+ # ------------------------------------------------------------------ #
80
+ # Routing #
81
+ # ------------------------------------------------------------------ #
82
+
83
+ def get(self, gateway_id: Optional[str]) -> Optional[GatewayBase]:
84
+ """Return the gateway for the given ID, or None if not found."""
85
+ gid = gateway_id or 'openclaw'
86
+ return self._gateways.get(gid)
87
+
88
+ def stream_to_queue(
89
+ self,
90
+ event_queue: queue.Queue,
91
+ message: str,
92
+ session_key: str,
93
+ captured_actions: Optional[list] = None,
94
+ gateway_id: Optional[str] = None,
95
+ **kwargs,
96
+ ) -> None:
97
+ """
98
+ Route a voice request to the named gateway and stream events into
99
+ event_queue. Blocking — returns when the response is complete.
100
+
101
+ Falls back to 'openclaw' if gateway_id is None or not registered.
102
+ """
103
+ gid = gateway_id or 'openclaw'
104
+ gw = self._gateways.get(gid)
105
+
106
+ if gw is None:
107
+ # Fallback to openclaw if the requested gateway isn't loaded
108
+ logger.warning(
109
+ f"Gateway '{gid}' not registered — falling back to 'openclaw'"
110
+ )
111
+ gw = self._gateways.get('openclaw')
112
+
113
+ if gw is None:
114
+ err = f"No gateway available (requested: '{gid}', openclaw fallback also missing)"
115
+ logger.error(err)
116
+ if captured_actions is None:
117
+ captured_actions = []
118
+ event_queue.put({'type': 'error', 'error': err})
119
+ return
120
+
121
+ if not gw.is_configured():
122
+ err = f"Gateway '{gw.gateway_id}' is not configured (check env vars)"
123
+ logger.error(err)
124
+ if captured_actions is None:
125
+ captured_actions = []
126
+ event_queue.put({'type': 'error', 'error': err})
127
+ return
128
+
129
+ gw.stream_to_queue(event_queue, message, session_key, captured_actions, **kwargs)
130
+
131
+ def ask(self, gateway_id: str, message: str, session_key: str) -> str:
132
+ """
133
+ Inter-gateway delegation: ask a gateway a question and return the
134
+ full response as a string. Synchronous.
135
+
136
+ Used when one agent wants to delegate work to another. Example:
137
+ response = gateway_manager.ask('claude-api', 'Summarise: ...', 'sub-1')
138
+
139
+ Returns empty string on error (errors are logged).
140
+ """
141
+ gw = self._gateways.get(gateway_id)
142
+ if gw is None:
143
+ logger.error(f"ask(): gateway '{gateway_id}' not registered")
144
+ return ''
145
+ if not gw.is_configured():
146
+ logger.error(f"ask(): gateway '{gateway_id}' not configured")
147
+ return ''
148
+
149
+ q: queue.Queue = queue.Queue()
150
+ captured: list = []
151
+ gw.stream_to_queue(q, message, session_key, captured)
152
+
153
+ # Drain queue for text_done or error
154
+ while True:
155
+ try:
156
+ event = q.get(timeout=330)
157
+ except queue.Empty:
158
+ logger.warning(f"ask('{gateway_id}'): timeout waiting for response")
159
+ return ''
160
+ if event.get('type') == 'text_done':
161
+ return event.get('response') or ''
162
+ if event.get('type') == 'error':
163
+ logger.error(f"ask('{gateway_id}'): gateway error: {event.get('error')}")
164
+ return ''
165
+
166
+ # ------------------------------------------------------------------ #
167
+ # Steer (inject message into active run) #
168
+ # ------------------------------------------------------------------ #
169
+
170
+ def send_steer(
171
+ self,
172
+ message: str,
173
+ session_key: str,
174
+ gateway_id: Optional[str] = None,
175
+ ) -> bool:
176
+ """
177
+ Send a steer message to the named gateway (fire-and-forget).
178
+
179
+ Used when the user speaks while the agent is silently working
180
+ (tools / sub-agents). Instead of aborting the active run,
181
+ this injects the user's message at the next tool boundary via
182
+ OpenClaw's messages.queue.mode=steer.
183
+
184
+ The active /api/conversation streaming response continues
185
+ receiving the steered output — no new streaming connection
186
+ is created.
187
+
188
+ Returns True if the message was delivered, False otherwise.
189
+ """
190
+ gid = gateway_id or 'openclaw'
191
+ gw = self._gateways.get(gid)
192
+ if gw is None:
193
+ gw = self._gateways.get('openclaw')
194
+ if gw is None or not gw.is_configured():
195
+ return False
196
+ if not hasattr(gw, 'send_steer'):
197
+ logger.warning(f"Gateway '{gw.gateway_id}' does not support steer")
198
+ return False
199
+ return gw.send_steer(message, session_key)
200
+
201
+ # ------------------------------------------------------------------ #
202
+ # Health / status #
203
+ # ------------------------------------------------------------------ #
204
+
205
+ def list_gateways(self) -> list[dict]:
206
+ """Return status of all registered gateways (for health endpoint / admin UI)."""
207
+ return [
208
+ {
209
+ 'id': gw.gateway_id,
210
+ 'configured': gw.is_configured(),
211
+ 'healthy': gw.is_healthy(),
212
+ 'persistent': gw.persistent,
213
+ }
214
+ for gw in self._gateways.values()
215
+ ]
216
+
217
+ def is_configured(self) -> bool:
218
+ """True if at least one gateway is configured (backwards compat for health check)."""
219
+ return any(gw.is_configured() for gw in self._gateways.values())
220
+
221
+ # ------------------------------------------------------------------ #
222
+ # Startup loading #
223
+ # ------------------------------------------------------------------ #
224
+
225
+ def _load_builtin_gateways(self) -> None:
226
+ """Register the built-in OpenClaw gateway if configured."""
227
+ from services.gateways.openclaw import OpenClawGateway
228
+ self.register(OpenClawGateway())
229
+
230
+ def _load_plugins(self) -> None:
231
+ """
232
+ Scan plugins/*/plugin.json for gateway plugins and register them.
233
+
234
+ Plugin structure:
235
+ plugins/
236
+ my-gateway/
237
+ plugin.json {"id": "...", "provides": "gateway", "gateway_class": "Gateway"}
238
+ gateway.py class Gateway(GatewayBase): ...
239
+
240
+ Plugins are skipped (with a warning) if:
241
+ - plugin.json is missing or malformed
242
+ - provides != "gateway"
243
+ - gateway.py is missing
244
+ - required env vars (requires_env) are not set
245
+ - the gateway class fails to instantiate
246
+ """
247
+ if not _PLUGINS_DIR.exists():
248
+ return
249
+
250
+ loaded = []
251
+ for plugin_dir in sorted(_PLUGINS_DIR.iterdir()):
252
+ if not plugin_dir.is_dir():
253
+ continue
254
+ manifest_path = plugin_dir / 'plugin.json'
255
+ if not manifest_path.exists():
256
+ continue
257
+
258
+ try:
259
+ manifest = json.loads(manifest_path.read_text())
260
+ except Exception as e:
261
+ logger.warning(f"plugins/{plugin_dir.name}: invalid plugin.json — {e}")
262
+ continue
263
+
264
+ if manifest.get('provides') != 'gateway':
265
+ continue # not a gateway plugin, skip silently
266
+
267
+ plugin_id = manifest.get('id', plugin_dir.name)
268
+
269
+ # Check required env vars
270
+ required_env = manifest.get('requires_env', [])
271
+ missing = [v for v in required_env if not os.getenv(v)]
272
+ if missing:
273
+ logger.warning(
274
+ f"plugins/{plugin_dir.name}: skipping '{plugin_id}' — "
275
+ f"missing env vars: {', '.join(missing)}"
276
+ )
277
+ continue
278
+
279
+ # Load gateway.py
280
+ gateway_file = plugin_dir / 'gateway.py'
281
+ if not gateway_file.exists():
282
+ logger.warning(f"plugins/{plugin_dir.name}: no gateway.py found, skipping")
283
+ continue
284
+
285
+ try:
286
+ spec = importlib.util.spec_from_file_location(
287
+ f"plugins.{plugin_dir.name}.gateway",
288
+ gateway_file
289
+ )
290
+ module = importlib.util.module_from_spec(spec)
291
+ sys.modules[spec.name] = module
292
+ spec.loader.exec_module(module)
293
+ except Exception as e:
294
+ logger.warning(f"plugins/{plugin_dir.name}: failed to import gateway.py — {e}")
295
+ continue
296
+
297
+ # Get the gateway class
298
+ class_name = manifest.get('gateway_class', 'Gateway')
299
+ cls = getattr(module, class_name, None)
300
+ if cls is None:
301
+ logger.warning(
302
+ f"plugins/{plugin_dir.name}: class '{class_name}' not found in gateway.py"
303
+ )
304
+ continue
305
+ if not (isinstance(cls, type) and issubclass(cls, GatewayBase)):
306
+ logger.warning(
307
+ f"plugins/{plugin_dir.name}: '{class_name}' must subclass GatewayBase"
308
+ )
309
+ continue
310
+
311
+ # Instantiate and register
312
+ try:
313
+ instance = cls()
314
+ self.register(instance)
315
+ loaded.append(plugin_id)
316
+ except Exception as e:
317
+ logger.warning(f"plugins/{plugin_dir.name}: failed to instantiate {class_name} — {e}")
318
+ continue
319
+
320
+ if loaded:
321
+ logger.info(f"GatewayManager: loaded plugin gateways: {', '.join(loaded)}")
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Singleton — used everywhere via: from services.gateway_manager import gateway_manager
326
+ # ---------------------------------------------------------------------------
327
+
328
+ gateway_manager = GatewayManager()
329
+ gateway_manager._load_builtin_gateways()
330
+ gateway_manager._load_plugins()
331
+
332
+ ids = list(gateway_manager._gateways.keys())
333
+ logger.info(f"GatewayManager ready — {len(ids)} gateway(s): {', '.join(ids)}")
@@ -0,0 +1,12 @@
1
+ """
2
+ Gateway implementations for OpenVoiceUI.
3
+
4
+ Built-in:
5
+ openclaw — OpenClaw persistent WebSocket gateway (default)
6
+
7
+ Plugins (drop into plugins/<id>/gateway.py):
8
+ See plugins/README.md for the contributor guide.
9
+ """
10
+ from services.gateways.base import GatewayBase
11
+
12
+ __all__ = ['GatewayBase']
@@ -0,0 +1,110 @@
1
+ """
2
+ GatewayBase — abstract interface all gateway implementations must satisfy.
3
+
4
+ A gateway is the backend LLM connection: it receives a user message and
5
+ streams response events into a queue that conversation.py consumes.
6
+
7
+ Built-in implementation: services/gateways/openclaw.py
8
+ Plugin implementations: plugins/<id>/gateway.py (see plugins/README.md)
9
+
10
+ Implementing a gateway
11
+ ----------------------
12
+ 1. Subclass GatewayBase
13
+ 2. Set gateway_id (unique string slug, e.g. "claude-api")
14
+ 3. Set persistent = True if you maintain a live connection (WS, gRPC, etc.)
15
+ Set persistent = False if you connect on each request (REST APIs)
16
+ 4. Implement is_configured(), stream_to_queue()
17
+ 5. Optionally override is_healthy() for a richer health check
18
+
19
+ Event protocol
20
+ --------------
21
+ stream_to_queue() must put dicts onto event_queue in this order:
22
+
23
+ {'type': 'handshake', 'ms': int} optional — connection latency
24
+ {'type': 'delta', 'text': str} one or more streaming tokens
25
+ {'type': 'action', 'action': dict} tool calls / lifecycle events
26
+ {'type': 'text_done', final — MUST always be sent
27
+ 'response': str | None,
28
+ 'actions': list}
29
+ {'type': 'error', 'error': str} on failure instead of text_done
30
+ """
31
+
32
+ import queue
33
+ from typing import Optional
34
+
35
+
36
+ class GatewayBase:
37
+ """Abstract base class for all OpenVoiceUI gateway implementations."""
38
+
39
+ # Unique slug used as the routing key in gateway_manager and profiles.
40
+ # Set this on every subclass. Example: "openclaw", "claude-api", "langchain"
41
+ gateway_id: str = "unnamed"
42
+
43
+ # True → maintain a persistent connection (WS, long-lived thread, etc.)
44
+ # gateway_manager will call is_healthy() on startup to warm it up.
45
+ # False → connect per-request (REST APIs, stateless clients)
46
+ # zero idle cost; no background thread required.
47
+ persistent: bool = False
48
+
49
+ # ------------------------------------------------------------------ #
50
+ # Required — subclasses must implement these #
51
+ # ------------------------------------------------------------------ #
52
+
53
+ def is_configured(self) -> bool:
54
+ """
55
+ Return True if all required env vars / config are present.
56
+ Called on startup. If False, gateway is registered but marked inactive
57
+ and a warning is logged. Requests routed to it will return an error.
58
+ """
59
+ raise NotImplementedError(
60
+ f"{self.__class__.__name__} must implement is_configured()"
61
+ )
62
+
63
+ def stream_to_queue(
64
+ self,
65
+ event_queue: queue.Queue,
66
+ message: str,
67
+ session_key: str,
68
+ captured_actions: Optional[list] = None,
69
+ **kwargs,
70
+ ) -> None:
71
+ """
72
+ Send message to the LLM backend and stream response events into
73
+ event_queue. This method is blocking — it returns when the full
74
+ response is done (or on error).
75
+
76
+ Called from a background thread by conversation.py.
77
+
78
+ Args:
79
+ event_queue: thread-safe queue.Queue for yielded events
80
+ message: user message string (already context-enriched)
81
+ session_key: session identifier for conversational memory
82
+ captured_actions: list to append tool/lifecycle events to
83
+ **kwargs: gateway-specific extras (e.g. agent_id for openclaw)
84
+ """
85
+ raise NotImplementedError(
86
+ f"{self.__class__.__name__} must implement stream_to_queue()"
87
+ )
88
+
89
+ # ------------------------------------------------------------------ #
90
+ # Optional — override for richer behaviour #
91
+ # ------------------------------------------------------------------ #
92
+
93
+ def is_healthy(self) -> bool:
94
+ """
95
+ Quick synchronous health check. No I/O — just inspect local state.
96
+ Default: same as is_configured().
97
+ Override to check live connection state, last-error timestamp, etc.
98
+ """
99
+ return self.is_configured()
100
+
101
+ def shutdown(self) -> None:
102
+ """
103
+ Called when the server shuts down. Override to close connections,
104
+ cancel background threads, etc. Default: no-op.
105
+ """
106
+
107
+ def __repr__(self) -> str:
108
+ status = "configured" if self.is_configured() else "not configured"
109
+ kind = "persistent" if self.persistent else "on-demand"
110
+ return f"<{self.__class__.__name__} id={self.gateway_id!r} {kind} {status}>"