mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(webapp): voice relays through the host, and a created profile is served right away
This commit is contained in:
1 parent
2a86dd6462
commit
1e81ee57e3
6 files changed
+144
-11
No files matched your search
@@ -22,8 +22,9 @@ from hermes_cli.web_routers._common import http_failure
|
||||
from hermes_cli.web_deps import late
|
||||
from hermes_cli.web_server_chat import _ws_auth_ok, _ws_request_is_allowed
|
||||
from hermes_cli.web_server_gateway import _read_dashboard_json_response, _split_text_for_speak_stream
|
||||
from hermes_cli.web_server_surface import policy
|
||||
from hermes_cli.web_session_sockets import SPEAK_STREAM_SOCKET, TRANSCRIBE_STREAM_SOCKET
|
||||
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from hermes_cli.web_models import (
|
||||
AudioTranscriptionRequest,
|
||||
STTLeaseRequest,
|
||||
@@ -149,7 +150,7 @@ async def transcribe_audio_upload(
|
||||
|
||||
|
||||
@router.get("/api/audio/voice-config")
|
||||
async def get_client_voice_config(profile: Optional[str] = None):
|
||||
async def get_client_voice_config(request: Request, profile: Optional[str] = None):
|
||||
"""The active profile's STT/TTS config for CLIENT-DIRECT voice.
|
||||
|
||||
Lets the desktop cut the audio relay hop: mic audio goes straight to the
|
||||
@@ -162,11 +163,13 @@ async def get_client_voice_config(profile: Optional[str] = None):
|
||||
Same trust boundary as every profile-scoped route: the caller is an
|
||||
authenticated client that can already drive the agent. Keys in the
|
||||
response are held in client memory only, never persisted client-side.
|
||||
Gate: ``voice.client_direct`` in config.yaml (default true).
|
||||
Gate: ``voice.client_direct`` in config.yaml (default true), and the
|
||||
serving surface (``client_direct_voice``: the Webapp always relays).
|
||||
"""
|
||||
from tools.voice_client_config import resolve_client_voice_config
|
||||
client_direct = policy(request.app.state).client_direct_voice
|
||||
try:
|
||||
result = await _run_config_scoped(profile, resolve_client_voice_config)
|
||||
result = await _run_config_scoped(profile, lambda: resolve_client_voice_config(client_direct))
|
||||
except HTTPException:
|
||||
raise # an unknown ?profile= is the scope's 404, not a reason to fall back to relay
|
||||
except Exception:
|
||||
|
||||
@@ -827,7 +827,10 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
clone_config = clone
|
||||
with _profile_errors("POST /api/profiles failed", not_found=(),
|
||||
bad_request=(ValueError, FileExistsError, FileNotFoundError)):
|
||||
path = profiles_mod.create_profile(
|
||||
# Off the loop: creation asks a live multiplexer to serve the profile now over its control
|
||||
# socket, and on the gateway's own listener (the Webapp) that socket answers on this loop.
|
||||
path = await asyncio.to_thread(
|
||||
profiles_mod.create_profile,
|
||||
name=body.name, clone_from=clone_from, clone_all=body.clone_all,
|
||||
clone_config=clone_config, no_skills=body.no_skills, description=body.description,
|
||||
clone_channels=body.clone_channels, **({"soul": body.soul} if body.soul is not None else {}))
|
||||
|
||||
@@ -47,25 +47,29 @@ class SurfacePolicy:
|
||||
#: ``/api/ui-preferences`` serves: the browser's door to this machine's UI preferences store
|
||||
#: (native Desktop reaches the same file through its preload capability, not over HTTP).
|
||||
ui_preferences: bool
|
||||
#: ``/api/audio/voice-config`` may hand the client provider keys to call STT/TTS itself. A
|
||||
#: browser tab cannot: a provider without CORS (the Nous tool gateway) refuses its fetch, and the
|
||||
#: keys would sit in the page. It relays through the host's /api/audio/* endpoints instead.
|
||||
client_direct_voice: bool
|
||||
|
||||
|
||||
POLICIES: dict[str, SurfacePolicy] = {
|
||||
"serve": SurfacePolicy(
|
||||
serves_spa=False, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
|
||||
own_origin_cookie_writes=False, web_access=False, browser_files=False,
|
||||
desktop_plugins=False, ui_preferences=False),
|
||||
desktop_plugins=False, ui_preferences=False, client_direct_voice=True),
|
||||
"dashboard": SurfacePolicy(
|
||||
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
|
||||
own_origin_cookie_writes=False, web_access=False, browser_files=False,
|
||||
desktop_plugins=False, ui_preferences=False),
|
||||
desktop_plugins=False, ui_preferences=False, client_direct_voice=True),
|
||||
"gateway": SurfacePolicy(
|
||||
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
|
||||
own_origin_cookie_writes=False, web_access=True, browser_files=False,
|
||||
desktop_plugins=False, ui_preferences=False),
|
||||
desktop_plugins=False, ui_preferences=False, client_direct_voice=True),
|
||||
"webapp": SurfacePolicy(
|
||||
serves_spa=True, html_token=False, host_terminal=True, launch_link=True, gateway_ticket=True,
|
||||
own_origin_cookie_writes=True, web_access=True, browser_files=True,
|
||||
desktop_plugins=True, ui_preferences=True),
|
||||
desktop_plugins=True, ui_preferences=True, client_direct_voice=False),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""A profile created through ``POST /api/profiles`` is served by the live multiplexer right away.
|
||||
|
||||
Creation asks the multiplexer to rescan over its control socket. On the gateway's own listener (the
|
||||
Webapp) that socket answers on the loop serving the route, so a create run on the loop waited out the
|
||||
socket's timeout instead, the multiplexer only served the profile at its periodic rescan, and the
|
||||
Webapp refused to switch to it ("This gateway does not serve profile ...") until then.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, _isolate_hermes_home):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
|
||||
|
||||
home = get_hermes_home()
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: home / "profiles")
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", home / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
def test_a_created_profile_is_served_without_waiting_for_the_rescan(client, monkeypatch):
|
||||
from hermes_cli import gateway_multiplex_served
|
||||
|
||||
served = []
|
||||
|
||||
def multiplexer(name, **_kwargs):
|
||||
# The control socket answers on the route's loop: called from that loop, it never does.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
served.append(name)
|
||||
return ["default", name]
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(gateway_multiplex_served, "notify_multiplexer_profiles_changed", multiplexer)
|
||||
|
||||
response = client.post("/api/profiles", json={"name": "testing-profile", "no_skills": True})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert served == ["testing-profile"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""The Webapp relays voice instead of handing its tab provider keys.
|
||||
|
||||
``/api/audio/voice-config`` lets native Desktop call STT/TTS providers itself. A browser tab cannot:
|
||||
a provider without CORS (the Nous tool gateway Hermes Cloud routes speech through) refuses the
|
||||
tab's fetch, so dictation failed with "Failed to fetch", and the response put the keys in the page.
|
||||
On the Webapp surface both directions resolve to relay, with no key in the body; native surfaces
|
||||
keep client-direct.
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import hermes_yaml as yaml
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
_TOKEN = "a" * 43
|
||||
_KEY = "gsk_voice_relay_test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_config(tmp_path, monkeypatch):
|
||||
"""GET /api/audio/voice-config on ``web_server.app`` serving *surface*, for a profile whose STT
|
||||
resolves client-direct (Groq, with its key)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"stt": {"provider": "groq"}}), encoding="utf-8")
|
||||
monkeypatch.setenv("GROQ_API_KEY", _KEY)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for name in ("hermes_cli.config", "tools.transcription_tools", "tools.tts_tool",
|
||||
"tools.voice_client_config"):
|
||||
if name in sys.modules:
|
||||
importlib.reload(sys.modules[name])
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
monkeypatch.setattr(web_server.app.state, "bound_host", "localhost", raising=False)
|
||||
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
|
||||
monkeypatch.setattr(web_server, "_SESSION_TOKEN", _TOKEN)
|
||||
client = TestClient(web_server.app, base_url="http://localhost")
|
||||
|
||||
def get(surface):
|
||||
monkeypatch.setattr(web_server.app.state, "ui_surface", surface, raising=False)
|
||||
response = client.get("/api/audio/voice-config", headers={"X-Hermes-Session-Token": _TOKEN})
|
||||
assert response.status_code == 200, response.text
|
||||
return response
|
||||
|
||||
return get
|
||||
|
||||
|
||||
def test_the_webapp_relays_voice_and_its_tab_gets_no_key(voice_config):
|
||||
response = voice_config("webapp")
|
||||
|
||||
body = response.json()
|
||||
assert body["stt"]["mode"] == "relay"
|
||||
assert body["tts"]["mode"] == "relay"
|
||||
assert _KEY not in response.text
|
||||
|
||||
|
||||
def test_native_desktop_still_calls_the_provider_directly(voice_config):
|
||||
stt = voice_config("dashboard").json()["stt"]
|
||||
|
||||
assert stt["mode"] == "direct"
|
||||
assert stt["api_key"] == _KEY
|
||||
@@ -228,15 +228,19 @@ def _resolve_tts_client_config() -> dict[str, Any]:
|
||||
return _relay(f"provider {provider!r} has no client wire")
|
||||
|
||||
|
||||
def resolve_client_voice_config() -> dict[str, Any]:
|
||||
def resolve_client_voice_config(client_direct: bool = True) -> dict[str, Any]:
|
||||
"""Resolve both directions for the CURRENT profile scope.
|
||||
|
||||
Callers scope the profile via ``hermes_constants.set_hermes_home_override``
|
||||
(the web server's ``_config_profile_scope``) before calling — identical to
|
||||
how ``/api/audio/transcribe`` scopes ``transcribe_recording``.
|
||||
``client_direct=False`` is the serving surface's verdict (a browser tab):
|
||||
both directions relay and no key is resolved.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if not _client_direct_enabled():
|
||||
if not client_direct:
|
||||
out = {"stt": _relay("browser surface"), "tts": _relay("browser surface")}
|
||||
elif not _client_direct_enabled():
|
||||
out = {"stt": _relay("voice.client_direct disabled"), "tts": _relay("voice.client_direct disabled")}
|
||||
else:
|
||||
for key, resolver in (("stt", _resolve_stt_client_config), ("tts", _resolve_tts_client_config)):
|
||||
|
||||
Reference in new issue
Block a user