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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- 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}>"
|