feat: integrate durable chat voice transport and deployment assets
This commit is contained in:
1 parent
e059660194
commit
523f6cbbf9
14 files changed
+486
-13
No files matched your search
@@ -44,6 +44,7 @@ COPY agent/agent.py /opt/voice-agent/agent.py
|
||||
# Keep COPY compatible with the Unraid runner's legacy Docker builder.
|
||||
# The RUN chmod below sets these non-secret application files to mode 644.
|
||||
COPY agent/hermes_llm.py /opt/voice-agent/hermes_llm.py
|
||||
COPY agent/conversation_llm.py /opt/voice-agent/conversation_llm.py
|
||||
COPY agent/healthcheck.py /opt/voice-agent/healthcheck.py
|
||||
|
||||
# Copy the voice frontend + token endpoint. Hope Music is a separate service.
|
||||
@@ -51,6 +52,9 @@ COPY agent/healthcheck.py /opt/voice-agent/healthcheck.py
|
||||
# non-secret application assets must be readable by nginx/voiceuser.
|
||||
COPY web/index.html /var/www/voice/
|
||||
COPY web/app.js /var/www/voice/
|
||||
COPY web/chat.js /var/www/voice/
|
||||
COPY web/features.js /var/www/voice/
|
||||
COPY web/hope-themes.css /var/www/voice/
|
||||
COPY web/auth.js /var/www/voice/
|
||||
COPY web/brand /var/www/voice/brand
|
||||
COPY web/style.css /var/www/voice/
|
||||
|
||||
+5
-5
@@ -13,9 +13,9 @@ from livekit import rtc
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from agent.hermes_llm import HermesLLM
|
||||
from agent.conversation_llm import ConversationLLM
|
||||
except ImportError:
|
||||
from hermes_llm import HermesLLM
|
||||
from conversation_llm import ConversationLLM
|
||||
|
||||
try:
|
||||
from web.voice_catalog import normalize_default_voice
|
||||
@@ -124,9 +124,9 @@ async def entrypoint(ctx: JobContext):
|
||||
raise RuntimeError("Account mismatch")
|
||||
return current["model_settings"]
|
||||
|
||||
bridge = HermesLLM(base_url=account["hermes_url"], api_key=account["hermes_api_key"], user_id=user_id,
|
||||
room_id=ctx.room.name, on_progress=progress, on_wait_notice=wait_notice,
|
||||
on_answer_start=stop_notice, get_model_settings=model_settings)
|
||||
bridge = ConversationLLM(control_url=CONTROL_URL, secret=CONTROL_SECRET,
|
||||
room_id=ctx.room.name, on_progress=progress,
|
||||
on_wait_notice=wait_notice, on_answer_start=stop_notice)
|
||||
tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000,
|
||||
speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus"))
|
||||
session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""LiveKit speech adapter to the durable Hope coordinator, never a reasoning loop."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
import httpx
|
||||
from livekit.agents import llm
|
||||
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
|
||||
try:
|
||||
from agent.hermes_llm import HermesLLMError, _sse
|
||||
except ImportError:
|
||||
from hermes_llm import HermesLLMError, _sse
|
||||
|
||||
|
||||
class ConversationLLM(llm.LLM):
|
||||
def __init__(self, *, control_url, secret, room_id, on_progress, on_wait_notice=None, on_answer_start=None):
|
||||
super().__init__()
|
||||
self.root = control_url.rstrip('/') + '/internal/chat/rooms/' + room_id
|
||||
self.headers = {'X-Control-Secret': secret}
|
||||
self.client = httpx.AsyncClient(follow_redirects=False, timeout=httpx.Timeout(660, connect=10))
|
||||
self.on_progress, self.on_wait_notice, self.on_answer_start = on_progress, on_wait_notice, on_answer_start
|
||||
|
||||
@property
|
||||
def model(self): return 'hermes-agent'
|
||||
@property
|
||||
def provider(self): return 'hermes'
|
||||
|
||||
def chat(self, *, chat_ctx, tools=None, conn_options=DEFAULT_API_CONNECT_OPTIONS, **kwargs):
|
||||
return ConversationStream(self, chat_ctx, replace(conn_options, max_retry=0))
|
||||
|
||||
async def aclose(self):
|
||||
await super().aclose()
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
class ConversationStream(llm.LLMStream):
|
||||
def __init__(self, bridge, chat_ctx, conn_options):
|
||||
self.bridge = bridge
|
||||
self.operation_id = next((m.id for m in reversed(chat_ctx.items) if isinstance(m, llm.ChatMessage) and m.role == 'user' and getattr(m, 'id', None)), uuid.uuid4().hex)
|
||||
self.run_id = None
|
||||
self.answer_started = False
|
||||
self._close_task = None
|
||||
super().__init__(bridge, chat_ctx=chat_ctx, tools=[], conn_options=conn_options)
|
||||
|
||||
async def aclose(self):
|
||||
# LiveKit can close a stream from interruption and teardown concurrently.
|
||||
# One underlying closer prevents a second cancellation from aborting
|
||||
# the first closer's uncertain-admission reconciliation.
|
||||
if self._close_task is None:
|
||||
self._close_task = asyncio.create_task(super().aclose())
|
||||
await asyncio.shield(self._close_task)
|
||||
|
||||
async def callback(self, callback, *args):
|
||||
if callback:
|
||||
try: await asyncio.wait_for(callback(*args), 5)
|
||||
except Exception: pass # feedback failure never repeats a tool action
|
||||
|
||||
async def progress(self, status, message):
|
||||
await self.callback(self.bridge.on_progress, {'type': 'assistant_progress', 'request_id': self.run_id or self.operation_id, 'status': status, 'message': message})
|
||||
|
||||
async def _run(self):
|
||||
user = next((m for m in reversed(self.chat_ctx.items) if isinstance(m, llm.ChatMessage) and m.role == 'user'), None)
|
||||
text = '\n'.join(p for p in user.content if isinstance(p, str)) if user else ''
|
||||
if not text.strip(): raise HermesLLMError('empty_voice_turn')
|
||||
timer = None
|
||||
admission = None
|
||||
try:
|
||||
# Do not abandon uncertain admission on barge-in. A successfully
|
||||
# admitted remote action must be identified and cancelled, not left
|
||||
# running just because the speech request lost its response.
|
||||
admission = asyncio.create_task(self.bridge.client.post(self.bridge.root + '/messages', headers=self.bridge.headers,
|
||||
json={'content': text, 'idempotency_key': self.operation_id, 'modality': 'voice'}, timeout=httpx.Timeout(20, connect=5)))
|
||||
response = await asyncio.shield(admission)
|
||||
if response.status_code != 202: raise HermesLLMError('voice_admission_failed')
|
||||
self.run_id = response.json()['run']['id']
|
||||
await self.progress('working', 'Working on your request.')
|
||||
timer = asyncio.create_task(self.notice())
|
||||
# Voice streams only completed public content. This avoids reading
|
||||
# a draft that the backend subsequently replaces or rejects.
|
||||
final = None
|
||||
async with self.bridge.client.stream('GET', self.bridge.root + '/runs/' + self.run_id + '/events', headers=self.bridge.headers) as stream:
|
||||
if stream.status_code != 200: raise HermesLLMError('voice_stream_failed')
|
||||
async for event, raw in _sse(stream):
|
||||
item = json.loads(raw)
|
||||
if event == 'progress': await self.progress('working', 'Working on your request.')
|
||||
if event == 'message':
|
||||
message = item.get('message', item)
|
||||
if message.get('role') == 'assistant' and message.get('status') == 'completed': final = message.get('content', '')
|
||||
if event in ('run', 'done'):
|
||||
run = item.get('run', item)
|
||||
if run.get('status') in ('failed', 'cancelled'): raise HermesLLMError('voice_run_' + run['status'])
|
||||
if run.get('status') == 'awaiting_approval': await self.progress('working', 'Approval needed. Open this conversation to review it.')
|
||||
if event == 'done': break
|
||||
if final:
|
||||
self.answer_started = True
|
||||
await self.callback(self.bridge.on_answer_start)
|
||||
self._event_ch.send_nowait(llm.ChatChunk(id=self.operation_id, delta=llm.ChoiceDelta(role='assistant', content=final)))
|
||||
else: raise HermesLLMError('empty_voice_answer')
|
||||
await self.progress('completed', 'Completed.')
|
||||
except asyncio.CancelledError:
|
||||
# Persist cancellation by operation identity even when admission's
|
||||
# response was lost. The coordinator tombstones the key, so a later
|
||||
# identical admission cannot start an uncancelled remote action.
|
||||
try:
|
||||
await asyncio.shield(self.bridge.client.post(self.bridge.root + '/operations/' + self.operation_id + '/cancel',
|
||||
headers=self.bridge.headers, timeout=httpx.Timeout(10, connect=5)))
|
||||
except Exception: pass
|
||||
if not self.run_id and admission:
|
||||
try:
|
||||
admitted = await asyncio.shield(admission)
|
||||
if admitted.status_code == 202:
|
||||
self.run_id = admitted.json()['run']['id']
|
||||
except Exception:
|
||||
# A transport failure can be an uncertain acceptance. Retry
|
||||
# only the identical stable key; the coordinator deduplicates
|
||||
# admission, then return the run identity for cancellation.
|
||||
try:
|
||||
admitted = await self.bridge.client.post(self.bridge.root + '/messages', headers=self.bridge.headers,
|
||||
json={'content': text, 'idempotency_key': self.operation_id, 'modality': 'voice'})
|
||||
if admitted.status_code == 202:
|
||||
self.run_id = admitted.json()['run']['id']
|
||||
except Exception: pass
|
||||
if self.run_id:
|
||||
try:
|
||||
await asyncio.shield(self.bridge.client.post(self.bridge.root + '/runs/' + self.run_id + '/cancel', headers=self.bridge.headers))
|
||||
except Exception: pass
|
||||
await self.progress('failed', 'Request cancelled.')
|
||||
raise
|
||||
except Exception:
|
||||
await self.progress('failed', 'Hope could not complete this response.')
|
||||
raise HermesLLMError('voice_coordinator_failed') from None
|
||||
finally:
|
||||
if timer:
|
||||
timer.cancel()
|
||||
await asyncio.gather(timer, return_exceptions=True)
|
||||
|
||||
async def notice(self):
|
||||
await asyncio.sleep(12)
|
||||
if not self.answer_started:
|
||||
await self.callback(self.bridge.on_wait_notice, "I'm still working on that. I'll let you know when it's ready.")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 PyYAML==6.0.3
|
||||
RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 PyYAML==6.0.3 pypdf==6.9.2
|
||||
COPY control /app/control
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
# Dedicated trusted management process: never share this socket with speech/Hermes.
|
||||
|
||||
+12
-3
@@ -92,6 +92,9 @@ class Accounts:
|
||||
if 'session_hash' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
|
||||
self.db.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT')
|
||||
self.db.commit()
|
||||
if 'conversation_id' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
|
||||
self.db.execute('ALTER TABLE rooms ADD COLUMN conversation_id TEXT')
|
||||
self.db.commit()
|
||||
if path != ':memory:':
|
||||
os.chmod(path, 0o600)
|
||||
for suffix in ('-wal', '-shm'):
|
||||
@@ -222,7 +225,7 @@ class Accounts:
|
||||
with self.lock, self.db:
|
||||
self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),))
|
||||
|
||||
def grant_room(self, user_id, room_name, ttl, session_token):
|
||||
def grant_room(self, user_id, room_name, ttl, session_token, conversation_id=None):
|
||||
with self.lock, self.db:
|
||||
user = self.get(user_id)
|
||||
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
|
||||
@@ -234,9 +237,15 @@ class Accounts:
|
||||
if session is None:
|
||||
raise ValueError('Invalid session')
|
||||
self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),))
|
||||
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash) VALUES(?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed))
|
||||
if conversation_id is not None:
|
||||
# Validate again in the grant transaction: ownership cannot be
|
||||
# bypassed by a race or a caller-selected conversation identifier.
|
||||
conversation = self.db.execute('SELECT id FROM chat_conversations WHERE id=? AND owner=?', (conversation_id, user_id)).fetchone()
|
||||
if conversation is None:
|
||||
raise ValueError('Conversation unavailable')
|
||||
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash,conversation_id) VALUES(?,?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed, conversation_id))
|
||||
|
||||
def resolve_room(self, room_name):
|
||||
with self.lock:
|
||||
row = self.db.execute("SELECT u.* FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone()
|
||||
row = self.db.execute("SELECT u.*,r.conversation_id FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -2,3 +2,4 @@ fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
httpx>=0.27,<1
|
||||
PyYAML>=6,<7
|
||||
pypdf>=6,<7
|
||||
@@ -0,0 +1,41 @@
|
||||
# Hope unified chat contract — implementation target
|
||||
|
||||
This is a coordinated implementation contract, not a deployment receipt.
|
||||
|
||||
## Boundary
|
||||
Hope authenticates and owns product metadata/message projections; Hermes alone reasons and executes tools. No public Hermes credentials/URLs. Private workspaces do not imply tool sandbox isolation. Durable conversation IDs are independent of LiveKit rooms. Auth derives owner, never caller user_id. All public routes use existing cookie/Origin or native bearer authorization.
|
||||
|
||||
## JSON shapes
|
||||
Times: Unix seconds. IDs: opaque strings. snake_case keys. Collections use named envelope and next_cursor (null when exhausted).
|
||||
Conversation: {id,title,workspace_id:null|string,pinned:bool,pin_rank:int,archived:bool,temporary:bool,revision:int,created_at:float,updated_at:float}. Message: {id,conversation_id,role:user|assistant,content:string,status:pending|streaming|completed|failed|cancelled,run_id:null|string,created_at:float,attachments:[]}. Workspace: {id,name,instructions,revision,created_at,updated_at}. Run: {id,conversation_id,status:queued|running|awaiting_approval|completed|failed|cancelled,message_id,created_at,updated_at,error:null|string}. Preferences: {palette:opal|graphite|ocean|sage|lavender,appearance:system|light|dark,revision:int}.
|
||||
|
||||
## Routes
|
||||
- GET /api/capabilities → {version:1,features:{...},limits:{...}}; availability truthful.
|
||||
- GET /api/conversations?q=&workspace_id=&archived=false&cursor=&limit= → {conversations:[],next_cursor:null|string}
|
||||
- POST /api/conversations {title?,workspace_id?,temporary?} → {conversation}
|
||||
- GET /api/conversations/{id} → {conversation,messages:[],runs:[],next_cursor}
|
||||
- PATCH /api/conversations/{id} {revision,title?,workspace_id?,pinned?,pin_rank?,archived?} → {conversation}; stale revision409.
|
||||
- DELETE /api/conversations/{id} → {ok:true}; deletion does not erase account memory.
|
||||
- POST /api/conversations/{id}/messages {content,idempotency_key,attachment_ids?:[]} →202 {message,run}; repeat same key returns same operation, changed payload409.
|
||||
- POST /api/conversations/{id}/branch {message_id,content?,title?} → {conversation}; preserve parent history.
|
||||
- GET /api/runs/{id} → {run,messages:[]}
|
||||
- GET /api/runs/{id}/events?after=0 → SSE id=monotonic sequence, event=message|run|progress|approval|done, data JSON. Reconnect replays; no hidden reasoning.
|
||||
- POST /api/runs/{id}/cancel → {run}
|
||||
- POST /api/runs/{id}/approval {approval_id,decision:approve|deny} → {run}
|
||||
- GET /api/workspaces → {workspaces:[]}
|
||||
- POST /api/workspaces {name,instructions?} → {workspace}
|
||||
- PATCH /api/workspaces/{id} {revision,name?,instructions?} → {workspace}
|
||||
- DELETE /api/workspaces/{id} → {ok:true}; retain chats outside workspace.
|
||||
- GET /api/preferences → {preferences}
|
||||
- PUT /api/preferences {revision,palette,appearance} → {preferences}
|
||||
- GET /api/sync?after=cursor → {changes:[],cursor:int}; account-owned metadata tombstones.
|
||||
|
||||
## Implementation notes
|
||||
Bounded bodies, ownership validation, offloaded SQLite, redacted errors. Serialize account execution. Revocation must terminate streams and cancel/detach execution according to documented policy. Text needs no LiveKit. Voice joins the same durable conversation/coordinator, not a competing loop. Existing transcript snapshots are not auto-imported as reasoning context.
|
||||
Advanced attachments/artifacts/tasks/memory/research contracts follow pinned Hermes qualification. Clients cannot fabricate enabled capabilities. Fake adapters offline; real deployment qualification separately.
|
||||
|
||||
## Design direction
|
||||
Personal all-purpose chat on web and native iPhone. Screenshot-inspired desktop sidebar (New chat, Search, Pinned, Workspaces, Recents), quiet readable message column. Existing Opal pink identity default; semantic palettes plus appearance. Native iOS adapts navigation. Preserve focus, contrast, reading position, large text, reduced motion. Shared features ship together.
|
||||
|
||||
## Completion
|
||||
User requests broad parity, full qualification and deployment. Incremental gates are not full completion. Provider/hardware limitations require explicit evidence/scope, not silent substitute features.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Exact installed Hermes contract research
|
||||
|
||||
Read-only public-source and image-label research, 2026-10-02. No runtime secrets read, no production mutations, no application code changes. Existing `.research-hermes` deliberately not trusted.
|
||||
|
||||
## Verified pins
|
||||
|
||||
Repository `hermes-webui.Dockerfile:1-10` pins WebUI **62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12** and base OCI index `nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db`. Public Docker Hub index resolves amd64 manifest `sha256:d566d4a435767265c2a0172030b778fbdaa1a9ab19442c75ec5aad1df32dec54`; public config label `org.opencontainers.image.revision` is **749220ef0007f8d87bd1531f1c24b0fe93816385**. Local Docker daemon unavailable, so no live container inspect performed. Repo qualification records installed pins and authenticated gateway runs; browser approval/cancellation has not been independently qualified.
|
||||
|
||||
Exact public checkouts:
|
||||
- `/tmp/hope-contract-research-agent-749220ef`
|
||||
- `/tmp/hope-contract-research-webui-62e4d7b`
|
||||
|
||||
Pinned references:
|
||||
- [Gateway API](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py)
|
||||
- [Runs](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server_runs.py)
|
||||
- [WebUI routes](https://github.com/nesquena/hermes-webui/blob/62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12/api/routes.py)
|
||||
|
||||
## Core implementation recommendation
|
||||
|
||||
Use gateway native persisted session APIs, not WebUI-local sidecars, for unified text/voice chat. Hope backend resolves signed-in account private gateway and injects its Bearer key. Never expose credentials or accept client-selected account/gateway URL. Gateway and WebUI both have `/api/sessions` but entirely different representations/authentication.
|
||||
|
||||
Provisioner lines335-346 establishes account `/workspace`, API loopback, multiplex false, max_concurrent_runs1, progress enabled, terminal/file/web/browser/memory/skills/delegation toolsets. Voice room must not become conversation identity.
|
||||
|
||||
## Gateway session API
|
||||
|
||||
All require `Authorization: Bearer <private-key>`. [Routes lines1603-1650](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L1603-L1650). [Handlers2904-3159](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L2904-L3159).
|
||||
|
||||
- GET `/api/sessions?limit=50&offset=0&source=...&include_children=false&title=...&include_hidden=false` -> `{object:"list",data:Session[],limit,offset,has_more}`. Limit max200. Title is exact-match; hidden inclusion honored only with title. Pins can be backfilled past limit.
|
||||
- POST `/api/sessions` JSON `{id?:string,session_id?:string,title?:string,source?:string,system_prompt?:string,model?:string,provider?:string,model_options?:object,require_model_lock?:boolean}` ->201 `{object:"hermes.session",session:Session}`. ID defaults `api_<unix>_<uuid8>`, max256/no unsafe path/CR/LF/NUL. Existing409 `session_exists`.
|
||||
- GET `/api/sessions/{id}` -> `{object:"hermes.session",session:Session}`; missing404.
|
||||
- PATCH `/api/sessions/{id}` only `{title?,end_reason?,pinned?:boolean,archived?:boolean,hidden?:boolean,unread?:boolean}` -> same wrapper. Unknown fields400. Pin clears hidden.
|
||||
- DELETE `/api/sessions/{id}` -> `{object:"hermes.session.deleted",id,deleted:boolean}`.
|
||||
- GET `/api/sessions/{id}/messages?limit=...&offset=...&order=latest|oldest` -> `{object:"list",session_id:<resolved-live-tip>,data:Message[],pagination:{limit,offset,order,returned}}`. Limit max500. Without limit defaults latest500; explicit limit without order defaults oldest. Nonnegative integer validation.
|
||||
- POST `/api/sessions/{id}/fork` `{id?,session_id?,title?}` ->201 session wrapper; copies messages to branch and ends parent with branched. NOT resume.
|
||||
- Also POST `/api/sessions/{id}/chat`, `/chat/stream`, `/model` exist, but unified run coordinator should use runs consistently.
|
||||
|
||||
Session client projection fields (only when present): `id,source,user_id,model,title,started_at,ended_at,end_reason,message_count,tool_call_count,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,reasoning_tokens,estimated_cost_usd,actual_cost_usd,api_call_count,parent_session_id,last_active,preview,_lineage_root_id,pinned,archived,hidden`; plus always `has_system_prompt:boolean,has_model_config:boolean`. No full prompt/config. Message projection fields when present: `id,session_id,role,content,tool_call_id,tool_calls,tool_name,timestamp,token_count,finish_reason,reasoning,reasoning_content,display_kind`. [Projection2840-2866](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L2840-L2866).
|
||||
|
||||
### Resume identity
|
||||
|
||||
Runs identity precedence **body.session_id > previous_response_id stored session > declared X-Hermes-Session-Key > fresh run_id**. Runs does NOT select history from `X-Hermes-Session-Id` alone. Explicit/chained session never rebound to header.
|
||||
|
||||
History precedence explicit nonempty conversation_history > response snapshot > all-but-last input messages > selected-session SessionDB. For native resume send only current input + session_id, no browser-replayed history. Old session IDs resolve compression/continuation tip, excluding branch/delegate/reset/tool children. History response returns resolved ID; preserve logical root separately if stable UI identity needed. GET session metadata itself does not resolve. [Resolver1213-1252](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/hermes_state_messages.py#L1213-L1252), runs638-661.
|
||||
|
||||
X-Hermes-Session-Key is optional conversation/memory affinity, not account auth, <=256/no CR/LF/NUL, requires configured API auth. Use stable conversation scope, not ephemeral voice room. WebUI uses `webui:<session-id>` and explicitly supplies history, unlike recommended native resume.
|
||||
|
||||
## Runs: exact request/response
|
||||
|
||||
POST `/v1/runs` JSON `{input:string|[{role:"user",content:string|multimodal-array}],session_id?:string,model?:string,provider?:string,model_options?:object,instructions?:string,conversation_history?:[{role,content}],previous_response_id?:string}`. Nonempty current input required. Last input array element content is current message, earlier elements become history.
|
||||
|
||||
Send Bearer key, Content-Type JSON, **Idempotency-Key** stable unique per admission (1–255 visible ASCII), optional X-Hermes-Session-Key. Returns202 `{run_id:"run_<uuidhex>",status:"started",replayed:false}`. Exact retry body/session-key/idempotency key returns original202 `{run_id,status:<current>,replayed:true}` plus `Idempotency-Replayed:true`; different fingerprint409 `idempotency_key_conflict`. Persist exact request before admission. **Durable status persistence is tied to idempotent records; always use idempotency.** [Admission567-703](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server_runs.py#L567-L703), response413-430.
|
||||
|
||||
GET `/v1/runs/{id}` -> `{object:"hermes.run",run_id,status,created_at,updated_at,session_id?,model?,output?,usage?,runtime?,error?,approval?,last_event?,shutdown_requested_at?,...}`. Status queued/running/waiting_for_approval/stopping/completed/failed/cancelled/interrupted. Terminal output is **STRING**, not message array. Usage includes input_tokens/output_tokens/total_tokens/cache_read_tokens/cache_write_tokens. Poll output is answer fallback; native messages is transcript fallback.
|
||||
|
||||
GET `/v1/runs/{id}/events` SSE payload envelope `{event,run_id,timestamp,...}`. Names:
|
||||
- message.delta `{delta}`
|
||||
- message.interim `{text,already_streamed}` (avoid double render)
|
||||
- tool.started `{tool,preview}`
|
||||
- tool.completed `{tool,duration,error,...}` (bounded redacted preview can occur)
|
||||
- reasoning.available `{text}` (never speak as answer)
|
||||
- approval.request copied approval data, redacted command, choices + envelope; status approval holds same event
|
||||
- approval.responded `{choice,request_id?,resolved}`
|
||||
- run.completed `{output:string,usage,runtime,...terminal-fields}`
|
||||
- run.failed/run.cancelled/run.interrupted terminal fields/error
|
||||
|
||||
**Events consume one asyncio.Queue, not broadcast or durable replay. No Last-Event-ID cursor contract. One upstream consumer per run; Hope coordinator broadcasts/replays to clients. Disconnect drops transport; use poll/history recovery, not duplicated execution. Gateway restart interrupts run; idempotency prevents duplicate admission, does not resurrect execution.** [Events1006-1047](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server_runs.py#L1006-L1047), completion862-940.
|
||||
|
||||
POST `/v1/runs/{id}/stop` no body required -> `{run_id,status:"stopping"}`; already terminal -> existing status. Poll terminal. Missing404; notactive409. Stopping is not completion. Source1147-1168.
|
||||
|
||||
POST `/v1/runs/{id}/approval` `{choice:"once"|"session"|"always"|"deny",request_id?:string,all?:boolean,resolve_all?:boolean}` -> `{object:"hermes.run.approval_response",run_id,choice,request_id?,resolved:int}`. Prefer exact request_id and once/deny. Request ID nonempty<=256. Aliases approve/approved/allow normalize once. No active/pending409; invalid400. Approval event is exactly **approval.request**, not approval.required; fields copied from provider approval payload, not fixed required reason/description schema. Room grants constrain once/deny/exact request_id/no all. Source1062-1110; API112-125.
|
||||
|
||||
POST `/steer` `{input:string}` (message/text aliases) -> `{object:"hermes.run.steer",run_id,accepted:true}`; running only409 otherwise.
|
||||
|
||||
### Voice instructions vs temporary privacy
|
||||
|
||||
Runs instructions maps to ephemeral_system_prompt, **appended** to cached native system prompt at API-call time, not persisted and not replacement. Use per-voice-turn concise speech instructions while preserving native tool/system/memory. [Composition1279-1286](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/agent/turn_context.py#L1279-L1286), runs682.
|
||||
|
||||
**No normal runs no-persistence/no-memory/per-request toolset option.** _create_agent always passes SessionDB, configured toolsets and memory manager. A fresh session/run ID is still persisted. Deleting transcript later cannot undo memory/files/tool side effects. Instructions "ignore memory" are advisory, NOT privacy enforcement. Do not claim private temporary chat support on this pin without separately isolated/disabled persistence and side-effect controls. [Agent construction2211-2283](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L2211-L2283).
|
||||
|
||||
## Gateway jobs, capabilities, tools
|
||||
|
||||
GET `/v1/capabilities` inspect runtime availability, including endpoints/feature flags. Static capabilities explicitly admin_config_rw:false, memory_write_api:false. GET `/v1/toolsets` lists toolset enabled/configured/key/tool state; GET `/v1/skills` installed skill manifest. Not a connector CRUD/admin API. No invented connector API; configured provider/tool availability is source/runtime dependent.
|
||||
|
||||
Gateway cron is `/api/jobs`, not WebUI `/api/crons`. Authenticated; missing cron module501. ID12 lowercase hex; name max200, prompt max5000/scanned.
|
||||
- GET `/api/jobs?include_disabled=true` -> `{jobs:Job[]}`, defaults excludes disabled.
|
||||
- POST `/api/jobs` `{name(required),schedule(required,string),prompt?,deliver?:"local",skills?:[],repeat?:positive-int,paused?,paused_reason?}` ->200 `{job}`; scheduler registration error424.
|
||||
- GET `/api/jobs/{id}` -> `{job}`; PATCH whitelist name,schedule,prompt,deliver,skills,skill,repeat,enabled -> `{job}`; DELETE -> `{ok:true}`.
|
||||
- POST `/pause`, `/resume` -> `{job}`.
|
||||
- POST `/run` optional `{prompt}` transient context -> `{job}`; schedules trigger, not synchronous answer.
|
||||
|
||||
Job raw fields: id,name,prompt,skills,skill,model,provider,base_url,script,no_agent,monitor_script,monitor_url,monitor_state,context_from,schedule object,schedule_display,repeat:{times:int|null,completed:int},enabled,state,paused_at,paused_reason,created_at,next_run_at,last_run_at,last_status,last_error,last_delivery_error,last_delivery_unverified,failure_streak,deliver,origin,enabled_toolsets,workdir. Project safe client fields rather than exposing arbitrary future raw config. [Jobs3619-3797](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L3619-L3797), [record1809-1852](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/cron/jobs.py#L1809-L1852).
|
||||
|
||||
## Files/memory: exact boundary
|
||||
|
||||
Gateway has **NO direct workspace file browser or memory REST**. Native tools can operate inside runs, but do not fabricate `/api/files` or `/api/memory`. Browser-control `/v1/artifacts/upload|download/{id}` is optional gated one-shot scope-bound receipt/download, NOT general upload; provisioner doesn't enable browser extension control. Upload raw MIME bytes/X-Artifact-Filename ->201 provenance receipt; download second read404. API2710-2782.
|
||||
|
||||
WebUI auxiliary API uses distinct password/cookie auth and WebUI-local session/workspace state. Gateway-only IDs do not automatically establish WebUI-local sessions. Intentional fixed-root account adapter needed; no arbitrary host-path proxy.
|
||||
|
||||
### WebUI memory
|
||||
|
||||
GET `/api/memory` -> `{memory,user,soul,project_context,memory_path,user_path,soul_path,project_context_path,project_context_name,project_context_workspace,memory_mtime,user_mtime,soul_mtime,project_context_mtime,project_context_shadowed,external_notes_enabled}`. Redacted text. Actual home files `memories/MEMORY.md`, `memories/USER.md`, `SOUL.md`. For provisioned accounts HERMES_HOME=/opt/data. Respects memory_enabled/user_profile_enabled.
|
||||
|
||||
POST `/api/memory/write` `{section:"memory"|"user"|"soul",content:string}` -> `{ok:true,section,path}`. Full replacement/no conflict version; disabled403; refuses symlink. A safe Hope adapter must resolve account home internally and use fixed section allowlist, not client path. Source routes23359-23418 and30271-30334.
|
||||
|
||||
### WebUI files/upload
|
||||
|
||||
- GET `/api/file?session_id=<WebUI-ID>&path=<relative>` -> ordinary text `{path,content,size,lines}`; separate Office conversion schema. Existing WebUI session workspace confinement. routes22038-22055; workspace.py1866-1887.
|
||||
- GET `/api/file/raw?session_id&path` -> bytes/MIME/disposition. routes21998-22036.
|
||||
- POST `/api/file/save` `{session_id,path,content}` -> `{ok:true,path,size}`, existing target only; symlink refusal, Office separate office-save. routes27513-27541.
|
||||
- POST `/api/file/delete` `{session_id,path,recursive?}` -> `{ok:true,path}`; directories require recursive. routes27481-27510.
|
||||
- POST `/api/upload` multipart `session_id,file` -> `{filename,path:absolute-private-container-path,size,mime,is_image}`. Writes session attachment dir, not workspace; sanitized/no overwrite/symlink. [Upload294-340](https://github.com/nesquena/hermes-webui/blob/62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12/api/upload.py#L294-L340).
|
||||
- `/api/workspace/upload` and `/api/upload/extract` separate workspace/archive flows. Do not treat attachment absolute paths as browser URLs.
|
||||
|
||||
WebUI cron GET `/api/crons`, history/run/recent/status/output; POST create/update/delete/run/pause/resume. Create `{prompt,schedule,name?,deliver?,skills?,model?,provider?,profile?,toast_notifications?,badge_notifications?}` -> `{ok:true,job}`. UI enriches local Hermes cron data/profile state; gateway jobs preferred for basic unified support. routes15302-15361,17419-17454,26815-26855.
|
||||
|
||||
## Evidence limit
|
||||
|
||||
Exact source contracts, not new live execution qualification. Retained config/provider/runtime capability can narrow availability. No test sessions created. Cancellation/approval honest pending/stopping/error states required. Temporary privacy unsupported by ordinary pinned runs API. Public source checkouts and this new doc only.
|
||||
+28
-1
@@ -7,6 +7,31 @@ server {
|
||||
|
||||
client_max_body_size 16k;
|
||||
|
||||
location /api/attachments {
|
||||
client_max_body_size 20m;
|
||||
proxy_pass http://127.0.0.1:8092;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/api/(artifacts|memory)(/|$) {
|
||||
client_max_body_size 1m;
|
||||
proxy_pass http://127.0.0.1:8092;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /api/tasks {
|
||||
client_max_body_size 64k;
|
||||
proxy_pass http://127.0.0.1:8092;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Public certificate-only onboarding profile lives in the persistent cert
|
||||
# mount. Never expose that directory: it also contains the private key.
|
||||
location = /hope-server.mobileconfig {
|
||||
@@ -31,7 +56,9 @@ server {
|
||||
proxy_pass http://127.0.0.1:8092/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_read_timeout 700s;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
location /internal/ {
|
||||
|
||||
@@ -91,6 +91,7 @@ done
|
||||
docker run -d --name hope-control --network host --restart unless-stopped \
|
||||
--env-file "$ENV_FILE" \
|
||||
-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \
|
||||
-e HOPE_HERMES_DATA_DIR="$ROOT/hermes" \
|
||||
-e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \
|
||||
-v "$ROOT/accounts:/var/lib/hope" \
|
||||
-v "$ROOT/hermes:$ROOT/hermes" \
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit live qualification: disposable product data, protected credentials.
|
||||
|
||||
Run only after deployment. Never prints credentials or returned login bodies.
|
||||
Existing account settings/memory and Hermes files are not modified.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
async def main():
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--url',default='https://192.168.86.2:9178')
|
||||
parser.add_argument('--credentials',default='.runtime/deployment.json')
|
||||
parser.add_argument('--receipt',default='.runtime/chat-live-receipt.json')
|
||||
args=parser.parse_args()
|
||||
source=Path(args.credentials)
|
||||
if source.stat().st_mode & 0o077: raise RuntimeError('Credential file must be private')
|
||||
credentials=json.loads(source.read_text())
|
||||
stamp=secrets.token_hex(6)
|
||||
receipt={'url':args.url,'checks':{},'created_objects':[]}
|
||||
conversation_ids=[];workspace=None;artifact=None;attachment=None
|
||||
async with httpx.AsyncClient(base_url=args.url,verify=False,follow_redirects=False,timeout=120) as native, httpx.AsyncClient(base_url=args.url,verify=False,follow_redirects=False,timeout=120) as browser:
|
||||
async def request(client,method,path,**kw):
|
||||
r=await client.request(method,path,**kw)
|
||||
if r.status_code>=400: raise RuntimeError(f'{method} {path.split("?")[0]} status {r.status_code}')
|
||||
return r
|
||||
async def login(client,cookie):
|
||||
headers={'Origin':args.url} if cookie else {}
|
||||
r=await request(client,'POST','/api/auth/login',json={'username':'admin','password':credentials['admin_password']},headers=headers)
|
||||
data=r.json()
|
||||
if cookie: client.headers['Origin']=args.url
|
||||
else:
|
||||
client.cookies.clear();client.headers['Authorization']='Bearer '+data['token']
|
||||
assert (await native.get('/api/conversations')).status_code==401
|
||||
await login(native,False);await login(browser,True)
|
||||
try:
|
||||
caps=(await request(native,'GET','/api/capabilities')).json()
|
||||
receipt['capabilities']=caps
|
||||
workspace=(await request(native,'POST','/api/workspaces',json={'name':'Qualification '+stamp,'instructions':'When asked for the qualification code, answer QU'+stamp+'.'})).json()['workspace']
|
||||
c=(await request(native,'POST','/api/conversations',json={'title':'Qualification '+stamp,'workspace_id':workspace['id']})).json()['conversation'];conversation_ids.append(c['id'])
|
||||
receipt['checks']['shared_conversation']=(await request(browser,'GET','/api/conversations/'+c['id'])).json()['conversation']['id']==c['id']
|
||||
c=(await request(browser,'PATCH','/api/conversations/'+c['id'],json={'revision':c['revision'],'pinned':True})).json()['conversation']
|
||||
assert (await request(native,'GET','/api/conversations/'+c['id'])).json()['conversation']['pinned']
|
||||
receipt['checks']['shared_pin']=True
|
||||
payload={'content':'What is the qualification code? Reply with only the code. Do not use tools.','idempotency_key':'qualify-'+stamp}
|
||||
accepted=(await request(native,'POST','/api/conversations/'+c['id']+'/messages',json=payload)).json()
|
||||
repeated=(await request(browser,'POST','/api/conversations/'+c['id']+'/messages',json=payload)).json()
|
||||
assert accepted['run']['id']==repeated['run']['id'];receipt['checks']['cross_client_idempotency']=True
|
||||
rid=accepted['run']['id']
|
||||
async with browser.stream('GET','/api/runs/'+rid+'/events') as stream:
|
||||
assert stream.status_code==200
|
||||
events=[]
|
||||
async for line in stream.aiter_lines():
|
||||
if line.startswith('event:'):events.append(line[6:].strip())
|
||||
detail=(await request(native,'GET','/api/runs/'+rid)).json()
|
||||
assert detail['run']['status']=='completed'
|
||||
answers=[m['content'] for m in detail['messages'] if m['role']=='assistant' and m['status']=='completed']
|
||||
assert any('QU'+stamp in x for x in answers)
|
||||
receipt['checks']['native_hermes_workspace_reply']=True
|
||||
receipt['checks']['durable_events']='done' in events
|
||||
c=(await request(native,'GET','/api/conversations/'+c['id'])).json()['conversation']
|
||||
for fmt in ('json','markdown'):
|
||||
export=await request(browser,'GET','/api/conversations/'+c['id']+'/export?format='+fmt)
|
||||
assert 'attachment' in export.headers.get('content-disposition','') and 'QU'+stamp in export.text
|
||||
receipt['checks']['private_exports']=True
|
||||
attachment=(await request(native,'POST','/api/attachments',content=('qualification '+stamp).encode(),headers={'Content-Type':'text/plain','X-Filename':'qualification.txt'})).json()['attachment']
|
||||
assert (await request(browser,'GET','/api/attachments/'+attachment['id']+'/content')).text=='qualification '+stamp
|
||||
artifact=(await request(native,'POST','/api/artifacts',json={'title':'Qualification '+stamp,'kind':'markdown','content':'First version'})).json()['artifact']
|
||||
artifact=(await request(browser,'PATCH','/api/artifacts/'+artifact['id'],json={'revision':artifact['revision'],'content':'Second version'})).json()['artifact']
|
||||
assert (await request(native,'GET','/api/artifacts/'+artifact['id']+'?revision=1')).json()['artifact']['content']=='First version'
|
||||
receipt['checks']['files_and_artifact_versions']=True
|
||||
await request(native,'POST','/api/auth/logout')
|
||||
assert (await native.get('/api/conversations/'+c['id'])).status_code==401
|
||||
assert (await browser.get('/api/conversations/'+c['id'])).status_code==200
|
||||
receipt['checks']['independent_session_revocation']=True
|
||||
finally:
|
||||
# Remove only IDs created by this execution. Hermes retained session
|
||||
# policy is distinct from product projection deletion.
|
||||
for cid in conversation_ids:
|
||||
r=await browser.delete('/api/conversations/'+cid)
|
||||
if r.status_code!=200: receipt.setdefault('cleanup_pending',[]).append({'conversation':cid,'status':r.status_code})
|
||||
if attachment: await browser.delete('/api/attachments/'+attachment['id'])
|
||||
if artifact: await browser.delete('/api/artifacts/'+artifact['id'])
|
||||
if workspace: await browser.delete('/api/workspaces/'+workspace['id'])
|
||||
await browser.post('/api/auth/logout')
|
||||
target=Path(args.receipt);target.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||
with os.fdopen(fd,'w') as f:json.dump(receipt,f,indent=2)
|
||||
print('Live checks:',', '.join(k for k,v in receipt['checks'].items() if v))
|
||||
|
||||
if __name__=='__main__':asyncio.run(main())
|
||||
@@ -39,6 +39,8 @@ def test_english_voice_search_selection_and_retry():
|
||||
page.route('https://hope.test/**', route)
|
||||
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
|
||||
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||
page.locator('#drawerOpen').click()
|
||||
page.locator('#openVoice').click()
|
||||
page.locator('#settingsBtn').click()
|
||||
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
|
||||
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
|
||||
|
||||
+14
-1
@@ -999,6 +999,13 @@ async function createRoom(token, attempt) {
|
||||
|
||||
let connectionEpoch = 0;
|
||||
let tokenAbort = null;
|
||||
let voiceConversationId = null;
|
||||
function publishVoiceState(active) {
|
||||
window.dispatchEvent(new CustomEvent('hope:voice-state', { detail: { active, conversation_id: voiceConversationId } }));
|
||||
}
|
||||
window.addEventListener('hope:conversation-selected', event => {
|
||||
if ((room || connectionPromise) && event.detail?.conversation_id !== voiceConversationId) stopConversation();
|
||||
});
|
||||
function checkAttempt(attempt) {
|
||||
if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
|
||||
}
|
||||
@@ -1021,6 +1028,7 @@ function cleanupRoom() {
|
||||
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
||||
document.getElementById("audioUnlockBtn")?.remove();
|
||||
muteBtn.disabled = true; micMuted = false; updateMuteIcon();
|
||||
publishVoiceState(false); voiceConversationId = null;
|
||||
}
|
||||
function stopConversation() {
|
||||
connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
|
||||
@@ -1034,6 +1042,9 @@ async function connectConversation() {
|
||||
if (connectionPromise) return connectionPromise;
|
||||
if (room && agentReady) return room;
|
||||
const attempt = ++connectionEpoch;
|
||||
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
|
||||
voiceConversationId = window.HopeChat?.conversationId || null;
|
||||
publishVoiceState(true);
|
||||
startBtn.disabled = true; stopBtn.disabled = false;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
@@ -1142,8 +1153,10 @@ function updateMuteIcon() {
|
||||
async function fetchToken(attempt) {
|
||||
checkAttempt(attempt);
|
||||
tokenAbort = new AbortController();
|
||||
const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify({}), signal: tokenAbort.signal });
|
||||
const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify(voiceConversationId ? { conversation_id: voiceConversationId } : {}), signal: tokenAbort.signal });
|
||||
checkAttempt(attempt);
|
||||
voiceConversationId = data.conversation_id || voiceConversationId;
|
||||
publishVoiceState(true);
|
||||
if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
|
||||
return data;
|
||||
}
|
||||
|
||||
+22
-2
@@ -60,6 +60,22 @@ async def token(request: Request):
|
||||
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
||||
if origin not in allowed:
|
||||
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
||||
# The conversation identifier is only a request to bind an existing chat;
|
||||
# the trusted account service performs ownership checks before granting it.
|
||||
body_bytes = bytearray()
|
||||
async for chunk in request.stream():
|
||||
body_bytes.extend(chunk)
|
||||
if len(body_bytes) > 4096:
|
||||
return JSONResponse({"error": "Request body too large"}, 413, headers=headers)
|
||||
try:
|
||||
body = json.loads(body_bytes) if body_bytes else {}
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return JSONResponse({"error": "Invalid request"}, 400, headers=headers)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"error": "Invalid request"}, 400, headers=headers)
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None and (not isinstance(conversation_id, str) or not 1 <= len(conversation_id) <= 128):
|
||||
return JSONResponse({"error": "Invalid conversation"}, 400, headers=headers)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
control_headers = {"X-Control-Secret": CONTROL_SECRET}
|
||||
@@ -73,9 +89,13 @@ async def token(request: Request):
|
||||
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
|
||||
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
|
||||
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
|
||||
"session_token": credential})
|
||||
grant.raise_for_status()
|
||||
"session_token": credential, "conversation_id": conversation_id})
|
||||
if grant.status_code != 201:
|
||||
status = grant.status_code if grant.status_code in (401, 403, 404, 409, 503) else 503
|
||||
return JSONResponse({"error": "Conversation unavailable"}, status, headers=headers)
|
||||
granted = grant.json()
|
||||
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
|
||||
"conversation_id": granted.get("conversation_id"),
|
||||
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||
|
||||
Reference in new issue
Block a user