Compare commits
36
Commits
6b8d55fc92
...
91a0d8820f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a0d8820f | ||
|
|
980be44743 | ||
|
|
19df338c5c | ||
|
|
e59c7123cf | ||
|
|
cd7d7d6251 | ||
|
|
2be5725fbd | ||
|
|
fef3cb35a8 | ||
|
|
02ea1d0884 | ||
|
|
d8fa6f0654 | ||
|
|
c549b92b52 | ||
|
|
856238f47a | ||
|
|
7cb641b35a | ||
|
|
3752741e19 | ||
|
|
6bb2f53dce | ||
|
|
0c57217ad9 | ||
|
|
0cb00e6095 | ||
|
|
2ad9f36f4f | ||
|
|
523f6cbbf9 | ||
|
|
e059660194 | ||
|
|
33929b8c09 | ||
|
|
1a04dab031 | ||
|
|
550f7c2e91 | ||
|
|
08b7e20be8 | ||
|
|
135e0c0da2 | ||
|
|
e048793b02 | ||
|
|
fb3178d34b | ||
|
|
fe4192c8bc | ||
|
|
2048b33799 | ||
|
|
fdf905eb7e | ||
|
|
bfbaef8c68 | ||
|
|
8dda23a1d8 | ||
|
|
be2b9f83d0 | ||
|
|
bba522bc26 | ||
|
|
2261208a1b | ||
|
|
985c812a75 | ||
|
|
437745bcca |
No files matched your search
@@ -9,6 +9,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Qualify offline account and chat contracts before building or deploying
|
||||||
|
run: |
|
||||||
|
# No NAS, speech SDK, model calls or legacy live-default conftest.
|
||||||
|
# Read-only source mount; test secrets and databases use container /tmp.
|
||||||
|
docker run --rm --user "$(id -u):$(id -g)" \
|
||||||
|
-e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
-v "$PWD:/src:ro" -w /src python:3.12-slim-bookworm \
|
||||||
|
sh -eu -c '
|
||||||
|
python -m venv /tmp/offline-tests
|
||||||
|
/tmp/offline-tests/bin/python -m pip install --no-cache-dir pytest pytest-asyncio -r control/requirements.txt
|
||||||
|
/tmp/offline-tests/bin/python scripts/generate_hope_themes.py --check
|
||||||
|
/tmp/offline-tests/bin/python -m pytest --noconftest -p no:cacheprovider \
|
||||||
|
tests/unit/test_accounts.py tests/unit/test_provisioner.py \
|
||||||
|
tests/unit/test_token_bootstrap.py tests/unit/test_deploy_rollback.py \
|
||||||
|
tests/unit/test_model_preferences_store.py tests/unit/test_model_settings_api.py \
|
||||||
|
tests/unit/test_chat_store.py tests/unit/test_chat_runs.py tests/unit/test_chat_runs_limits.py \
|
||||||
|
tests/unit/test_chat_api.py tests/unit/test_chat_exports.py \
|
||||||
|
tests/unit/test_chat_features.py tests/unit/test_chat_security_review.py \
|
||||||
|
tests/unit/test_chat_cross_client.py tests/unit/test_chat_packaging.py -q
|
||||||
|
'
|
||||||
- name: Build images
|
- name: Build images
|
||||||
run: |
|
run: |
|
||||||
docker build -t hope-voice-api-voice:latest .
|
docker build -t hope-voice-api-voice:latest .
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ COPY agent/agent.py /opt/voice-agent/agent.py
|
|||||||
# Keep COPY compatible with the Unraid runner's legacy Docker builder.
|
# Keep COPY compatible with the Unraid runner's legacy Docker builder.
|
||||||
# The RUN chmod below sets these non-secret application files to mode 644.
|
# 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/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 agent/healthcheck.py /opt/voice-agent/healthcheck.py
|
||||||
|
|
||||||
# Copy the voice frontend + token endpoint. Hope Music is a separate service.
|
# Copy the voice frontend + token endpoint. Hope Music is a separate service.
|
||||||
@@ -51,6 +52,10 @@ COPY agent/healthcheck.py /opt/voice-agent/healthcheck.py
|
|||||||
# non-secret application assets must be readable by nginx/voiceuser.
|
# non-secret application assets must be readable by nginx/voiceuser.
|
||||||
COPY web/index.html /var/www/voice/
|
COPY web/index.html /var/www/voice/
|
||||||
COPY web/app.js /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/settings.js /var/www/voice/
|
||||||
|
COPY web/hope-themes.css /var/www/voice/
|
||||||
COPY web/auth.js /var/www/voice/
|
COPY web/auth.js /var/www/voice/
|
||||||
COPY web/brand /var/www/voice/brand
|
COPY web/brand /var/www/voice/brand
|
||||||
COPY web/style.css /var/www/voice/
|
COPY web/style.css /var/www/voice/
|
||||||
|
|||||||
@@ -15,6 +15,41 @@ credentials; requires manual iOS profile installation + full trust for WSS. Priv
|
|||||||
key URL remains404. Do not expose the entire cert directory. Initial container-copy
|
key URL remains404. Do not expose the entire cert directory. Initial container-copy
|
||||||
profile disappeared during intervening deployment; persistent alias fixed it.
|
profile disappeared during intervening deployment; persistent alias fixed it.
|
||||||
|
|
||||||
|
## Hermes auto image input — live receipt 2026-10-02
|
||||||
|
|
||||||
|
Verified upstream `/v1/responses` with model `auto`, an inline PNG containing
|
||||||
|
red-left/blue-right panels, and reasoning effort `none`: HTTP200, served model
|
||||||
|
`Qwen3.8-27b`, answer `Red Blue`, zero reasoning tokens. The initial control-exec
|
||||||
|
probe was interrupted with exit137 during a control replacement; the successful
|
||||||
|
probe ran from the stable administrator Hermes container.
|
||||||
|
|
||||||
|
The shared provisioner now declares only
|
||||||
|
`providers.hope-upstream.models.auto.supports_vision: true`. Existing managed
|
||||||
|
model lists migrate to metadata mappings, preserving other settings. A durable
|
||||||
|
private reload marker ensures restart retries survive control interruption.
|
||||||
|
No global vision override was added for other selectable models.
|
||||||
|
|
||||||
|
Deployed control image `hope-voice-control:vision-auto-v1`, also tagged `latest`,
|
||||||
|
image `sha256:570319b3fd60c8161741a6c46cfc61f16a214383b8f8114c5692e00ca506197d`.
|
||||||
|
Startup migrated and restarted both currently deployed accounts in place:
|
||||||
|
|
||||||
|
- `hope-hermes-admin-69101358`: ready; real authenticated gateway image request
|
||||||
|
at port20000 returned HTTP200 and `Blue, red` for a blue-left/red-right PNG.
|
||||||
|
- `hope-hermes-shane-fc44d140`: ready; real authenticated gateway image request
|
||||||
|
at port20001 returned HTTP200 and `Green, red.` for a green-left/red-right PNG.
|
||||||
|
|
||||||
|
Both runtime capability lookups returned true for the custom provider's `auto`;
|
||||||
|
both retained configs have the declaration and no pending reload marker. Account
|
||||||
|
settings, credentials, container identities, ports and bind mounts were retained;
|
||||||
|
voice remained healthy and control health returned OK. No unrelated deployment
|
||||||
|
was changed. This qualifies gateway image input, not a browser upload workflow.
|
||||||
|
|
||||||
|
Protected config backups/control inspect receipt are under
|
||||||
|
`/mnt/user/appdata/hope-voice/rollback/vision-20261002T201506Z`; original control is
|
||||||
|
retained stopped as `hope-control-pre-vision-20261002T201506Z`. Clean source was
|
||||||
|
staged separately; the NAS development checkout was not overwritten. Offline
|
||||||
|
account/provisioning/bootstrap/deployment rollback regression:44 passed.
|
||||||
|
|
||||||
## Per-account Hermes WebUI — live receipt 2026-10-02
|
## Per-account Hermes WebUI — live receipt 2026-10-02
|
||||||
|
|
||||||
Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12`
|
Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12`
|
||||||
|
|||||||
+5
-5
@@ -13,9 +13,9 @@ from livekit import rtc
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from agent.hermes_llm import HermesLLM
|
from agent.conversation_llm import ConversationLLM
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from hermes_llm import HermesLLM
|
from conversation_llm import ConversationLLM
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from web.voice_catalog import normalize_default_voice
|
from web.voice_catalog import normalize_default_voice
|
||||||
@@ -124,9 +124,9 @@ async def entrypoint(ctx: JobContext):
|
|||||||
raise RuntimeError("Account mismatch")
|
raise RuntimeError("Account mismatch")
|
||||||
return current["model_settings"]
|
return current["model_settings"]
|
||||||
|
|
||||||
bridge = HermesLLM(base_url=account["hermes_url"], api_key=account["hermes_api_key"], user_id=user_id,
|
bridge = ConversationLLM(control_url=CONTROL_URL, secret=CONTROL_SECRET,
|
||||||
room_id=ctx.room.name, on_progress=progress, on_wait_notice=wait_notice,
|
room_id=ctx.room.name, on_progress=progress,
|
||||||
on_answer_start=stop_notice, get_model_settings=model_settings)
|
on_wait_notice=wait_notice, on_answer_start=stop_notice)
|
||||||
tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000,
|
tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000,
|
||||||
speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus"))
|
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", ""),
|
session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""),
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""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
|
||||||
|
admission_rejected = False
|
||||||
|
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:
|
||||||
|
admission_rejected = 400 <= response.status_code < 500
|
||||||
|
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'}, timeout=httpx.Timeout(20, connect=5))
|
||||||
|
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, timeout=httpx.Timeout(10, connect=5)))
|
||||||
|
except Exception: pass
|
||||||
|
await self.progress('failed', 'Request cancelled.')
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# A failed HTTP receipt is not proof admission failed. Fail safely
|
||||||
|
# by tombstoning the stable operation key, including when JSON/SSE
|
||||||
|
# decoding failed after the coordinator already accepted the turn.
|
||||||
|
if not admission_rejected:
|
||||||
|
try:
|
||||||
|
await 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
|
||||||
|
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
|
FROM python:3.12-slim-bookworm
|
||||||
WORKDIR /app
|
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
|
COPY control /app/control
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
# Dedicated trusted management process: never share this socket with speech/Hermes.
|
# Dedicated trusted management process: never share this socket with speech/Hermes.
|
||||||
|
|||||||
@@ -114,6 +114,28 @@ ports. Strict outbound-only enforcement needs a separate host ingress firewall
|
|||||||
policy; host networking alone cannot guarantee that. Host networking does not
|
policy; host networking alone cannot guarantee that. Host networking does not
|
||||||
bypass LAN ACLs or configure mDNS/DNS.
|
bypass LAN ACLs or configure mDNS/DNS.
|
||||||
|
|
||||||
|
## Image input capability
|
||||||
|
|
||||||
|
The managed upstream `auto` model is explicitly declared with
|
||||||
|
`providers.hope-upstream.models.auto.supports_vision: true`. This is an exact-model
|
||||||
|
capability, not a global `model.supports_vision` override: account-selected models
|
||||||
|
are not automatically declared vision-capable. Non-auto `HERMES_UPSTREAM_MODEL`
|
||||||
|
overrides require independent qualification before declaring image support.
|
||||||
|
|
||||||
|
New accounts receive this declaration. On control startup/reconciliation, retained
|
||||||
|
accounts with an existing managed `auto` entry are migrated from a model list to a
|
||||||
|
metadata mapping, preserving other model IDs, capability metadata and settings.
|
||||||
|
Missing/custom provider declarations are not replaced with defaults. The migration
|
||||||
|
only changes the verified `auto` capability and existing managed listener settings.
|
||||||
|
|
||||||
|
A private `config-reload-pending` marker is written before an existing config is
|
||||||
|
changed. Running owned containers restart by immutable ID to load it; new/stopped
|
||||||
|
containers load it on start. The marker clears only after authenticated gateway
|
||||||
|
and (when enabled) WebUI health pass. Failed restarts/readiness retain the marker
|
||||||
|
for retry, including across control restarts. Expect brief per-account downtime
|
||||||
|
and interruption of active runs during migration. Credentials, ports, homes and
|
||||||
|
workspaces remain unchanged. Readiness rejects accounts awaiting this migration.
|
||||||
|
|
||||||
## Per-account Hermes WebUI
|
## Per-account Hermes WebUI
|
||||||
|
|
||||||
Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the
|
Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the
|
||||||
|
|||||||
+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)')}:
|
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.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT')
|
||||||
self.db.commit()
|
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:':
|
if path != ':memory:':
|
||||||
os.chmod(path, 0o600)
|
os.chmod(path, 0o600)
|
||||||
for suffix in ('-wal', '-shm'):
|
for suffix in ('-wal', '-shm'):
|
||||||
@@ -222,7 +225,7 @@ class Accounts:
|
|||||||
with self.lock, self.db:
|
with self.lock, self.db:
|
||||||
self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),))
|
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:
|
with self.lock, self.db:
|
||||||
user = self.get(user_id)
|
user = self.get(user_id)
|
||||||
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
|
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
|
||||||
@@ -234,9 +237,15 @@ class Accounts:
|
|||||||
if session is None:
|
if session is None:
|
||||||
raise ValueError('Invalid session')
|
raise ValueError('Invalid session')
|
||||||
self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),))
|
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):
|
def resolve_room(self, room_name):
|
||||||
with self.lock:
|
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
|
return dict(row) if row else None
|
||||||
+32
-3
@@ -206,13 +206,25 @@ class Lifecycle:
|
|||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings=None, provisioner=None, model_catalog=None):
|
def create_app(settings=None, provisioner=None, model_catalog=None, chat_adapter=None, feature_provider=None):
|
||||||
settings = settings or Settings()
|
settings = settings or Settings()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app):
|
async def lifespan(app):
|
||||||
store = Accounts(settings.db_path)
|
store = Accounts(settings.db_path)
|
||||||
app.state.accounts = store
|
app.state.accounts = store
|
||||||
|
from .chat_store import ChatStore
|
||||||
|
from .chat_runs import ChatRuns
|
||||||
|
app.state.chat_store = await asyncio.to_thread(ChatStore, store)
|
||||||
|
from .chat_features import FeatureStore, FeatureProvider
|
||||||
|
from pathlib import Path
|
||||||
|
feature_root = str(Path(settings.db_path).absolute().parent / 'chat-features')
|
||||||
|
app.state.feature_store = await asyncio.to_thread(FeatureStore, store, feature_root)
|
||||||
|
app.state.feature_provider = feature_provider or FeatureProvider(store)
|
||||||
|
from .chat_features import create_feature_router
|
||||||
|
app.include_router(create_feature_router(app.state.feature_store, app.state.feature_provider))
|
||||||
|
app.state.chat_runs = await asyncio.to_thread(ChatRuns, store, app.state.chat_store, chat_adapter, app.state.feature_store)
|
||||||
|
app.state.chat_runs.feature_provider = app.state.feature_provider
|
||||||
try:
|
try:
|
||||||
if not store.users():
|
if not store.users():
|
||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
@@ -231,9 +243,13 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
|||||||
app.state.lifecycle.schedule(user['id'])
|
app.state.lifecycle.schedule(user['id'])
|
||||||
app.state.dummy_hash = await asyncio.to_thread(password_hash, 'timing-only-password')
|
app.state.dummy_hash = await asyncio.to_thread(password_hash, 'timing-only-password')
|
||||||
loop = asyncio.create_task(app.state.lifecycle.loop(settings.reconcile_interval))
|
loop = asyncio.create_task(app.state.lifecycle.loop(settings.reconcile_interval))
|
||||||
|
await app.state.chat_runs.start()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
await app.state.chat_runs.stop()
|
||||||
|
if hasattr(app.state.feature_provider, 'aclose'):
|
||||||
|
await app.state.feature_provider.aclose()
|
||||||
loop.cancel()
|
loop.cancel()
|
||||||
await asyncio.gather(loop, return_exceptions=True)
|
await asyncio.gather(loop, return_exceptions=True)
|
||||||
await app.state.lifecycle.stop()
|
await app.state.lifecycle.stop()
|
||||||
@@ -407,6 +423,7 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
|||||||
if user['status'] != 'ready' or not user['hermes_url'] or not user['hermes_api_key']:
|
if user['status'] != 'ready' or not user['hermes_url'] or not user['hermes_api_key']:
|
||||||
raise HTTPException(503, 'User Hermes is not ready')
|
raise HTTPException(503, 'User Hermes is not ready')
|
||||||
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key'],
|
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key'],
|
||||||
|
'conversation_id': user.get('conversation_id'),
|
||||||
'model_settings': app.state.accounts.get_model_settings(user['id'])}
|
'model_settings': app.state.accounts.get_model_settings(user['id'])}
|
||||||
|
|
||||||
@app.get('/internal/users/{user_id}', dependencies=[Depends(internal_authorized)])
|
@app.get('/internal/users/{user_id}', dependencies=[Depends(internal_authorized)])
|
||||||
@@ -430,13 +447,21 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
|||||||
raise HTTPException(400, 'Invalid room grant')
|
raise HTTPException(400, 'Invalid room grant')
|
||||||
if type(ttl) is not int or not 1 <= ttl <= 86400:
|
if type(ttl) is not int or not 1 <= ttl <= 86400:
|
||||||
raise HTTPException(400, 'Invalid room expiry')
|
raise HTTPException(400, 'Invalid room expiry')
|
||||||
|
conversation_id = body.get('conversation_id')
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(app.state.accounts.grant_room, user_id, room, ttl, body.get('session_token'))
|
if conversation_id is None:
|
||||||
|
conversation = await asyncio.to_thread(app.state.chat_store.create_conversation, user_id)
|
||||||
|
conversation_id = conversation['id']
|
||||||
|
else:
|
||||||
|
await asyncio.to_thread(app.state.chat_store.get_conversation, user_id, conversation_id)
|
||||||
|
await asyncio.to_thread(app.state.accounts.grant_room, user_id, room, ttl, body.get('session_token'), conversation_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, 'Conversation not found') from None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(409, 'Account not ready') from None
|
raise HTTPException(409, 'Account not ready') from None
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
raise HTTPException(409, 'Room already granted') from None
|
raise HTTPException(409, 'Room already granted') from None
|
||||||
return {'room_name': room, 'user_id': user_id}
|
return {'room_name': room, 'user_id': user_id, 'conversation_id': conversation_id}
|
||||||
|
|
||||||
@app.get('/internal/rooms/{room_name}', dependencies=[Depends(internal_authorized)])
|
@app.get('/internal/rooms/{room_name}', dependencies=[Depends(internal_authorized)])
|
||||||
async def resolve_room(room_name: str):
|
async def resolve_room(room_name: str):
|
||||||
@@ -446,4 +471,8 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
|||||||
async def health():
|
async def health():
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
|
from .chat_api import create_chat_router
|
||||||
|
app.include_router(create_chat_router())
|
||||||
|
from .chat_exports import create_export_router
|
||||||
|
app.include_router(create_export_router())
|
||||||
return app
|
return app
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Authenticated durable chat routes; all SQLite work is offloaded."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from .app import bounded_json, current_user, internal_authorized
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
TERMINAL = {'completed', 'failed', 'cancelled'}
|
||||||
|
|
||||||
|
async def db(request, method, *args, **kwargs):
|
||||||
|
from .chat_store import ChatConflict
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(getattr(request.app.state.chat_store, method), *args, **kwargs)
|
||||||
|
except ChatConflict:
|
||||||
|
raise HTTPException(409, 'Revision or operation conflict') from None
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, 'Chat resource not found') from None
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, 'Invalid chat request') from None
|
||||||
|
|
||||||
|
async def body(request, allowed, required=()):
|
||||||
|
value = await bounded_json(request)
|
||||||
|
if not set(value) <= set(allowed) or not set(required) <= set(value):
|
||||||
|
raise HTTPException(400, 'Invalid chat fields')
|
||||||
|
return value
|
||||||
|
|
||||||
|
@router.get('/api/capabilities')
|
||||||
|
async def capabilities(request: Request, user=Depends(current_user)):
|
||||||
|
from .chat_features import local_capabilities
|
||||||
|
features = request.app.state.chat_runs.capabilities()
|
||||||
|
features.update(local_capabilities(request.app.state.feature_store))
|
||||||
|
try:
|
||||||
|
features.update(await request.app.state.feature_provider.feature_capabilities(user['id']))
|
||||||
|
except HTTPException:
|
||||||
|
features.update({'image_input': False, 'scheduled_tasks': False, 'memory_controls': False, 'connectors': False, 'research': False})
|
||||||
|
return {'version': 1, 'features': features, 'limits': {'message_bytes': 6000, 'page_size': 100, 'attachments': request.app.state.feature_store.limits.attachments_per_message}}
|
||||||
|
|
||||||
|
@router.get('/api/conversations')
|
||||||
|
async def conversations(request: Request, q: str = '', workspace_id: str | None = None, archived: bool = False, cursor: str | None = None, limit: int = 50, user=Depends(current_user)):
|
||||||
|
return await db(request, 'list_conversations', user['id'], q=q, workspace_id=workspace_id, archived=archived, cursor=cursor, limit=limit)
|
||||||
|
|
||||||
|
@router.post('/api/conversations')
|
||||||
|
async def create_conversation(request: Request, user=Depends(current_user)):
|
||||||
|
return {'conversation': await db(request, 'create_conversation', user['id'], **await body(request, ('title','workspace_id','temporary')))}
|
||||||
|
|
||||||
|
@router.get('/api/conversations/{cid}')
|
||||||
|
async def conversation(cid: str, request: Request, cursor: str | None = None, limit: int = 50, user=Depends(current_user)):
|
||||||
|
return await db(request, 'get_conversation', user['id'], cid, cursor=cursor, limit=limit)
|
||||||
|
|
||||||
|
@router.patch('/api/conversations/{cid}')
|
||||||
|
async def update_conversation(cid: str, request: Request, user=Depends(current_user)):
|
||||||
|
return {'conversation': await db(request, 'update_conversation', user['id'], cid, **await body(request, ('revision','title','workspace_id','pinned','pin_rank','archived'), ('revision',)))}
|
||||||
|
|
||||||
|
@router.delete('/api/conversations/{cid}')
|
||||||
|
async def delete_conversation(cid: str, request: Request, user=Depends(current_user)):
|
||||||
|
from .chat_store import ChatConflict
|
||||||
|
try:
|
||||||
|
await request.app.state.chat_runs.cancel_conversation(user['id'], cid)
|
||||||
|
except ChatConflict:
|
||||||
|
raise HTTPException(409, 'Cancellation pending; retry deletion after execution stops') from None
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, 'Chat resource not found') from None
|
||||||
|
detail = await db(request, 'get_conversation', user['id'], cid)
|
||||||
|
if any(run['status'] not in TERMINAL for run in detail['runs']):
|
||||||
|
raise HTTPException(409, 'Cancellation pending; retry deletion after execution stops')
|
||||||
|
await db(request, 'delete_conversation', user['id'], cid)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
@router.post('/api/conversations/{cid}/branch')
|
||||||
|
async def branch(cid: str, request: Request, user=Depends(current_user)):
|
||||||
|
return {'conversation': await db(request, 'branch', user['id'], cid, **await body(request, ('message_id','content','title'), ('message_id',)))}
|
||||||
|
|
||||||
|
@router.post('/api/conversations/{cid}/messages', status_code=202)
|
||||||
|
async def send(cid: str, request: Request, user=Depends(current_user)):
|
||||||
|
value = await body(request, ('content','idempotency_key','attachment_ids','mode'), ('content','idempotency_key'))
|
||||||
|
return await request.app.state.chat_runs.send(user['id'], cid, request.state.session_token, **value)
|
||||||
|
|
||||||
|
@router.get('/api/runs/{rid}')
|
||||||
|
async def run(rid: str, request: Request, user=Depends(current_user)):
|
||||||
|
return await db(request, 'get_run', user['id'], rid)
|
||||||
|
|
||||||
|
async def event_response(request, owner, rid, valid, after):
|
||||||
|
await db(request, 'get_run', owner, rid)
|
||||||
|
try:
|
||||||
|
after = max(after, int(request.headers.get('last-event-id', '0')))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, 'Invalid event cursor') from None
|
||||||
|
if after < 0:
|
||||||
|
raise HTTPException(400, 'Invalid event cursor')
|
||||||
|
async def stream():
|
||||||
|
sequence = after
|
||||||
|
while not await request.is_disconnected():
|
||||||
|
if not await valid():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
items = await asyncio.to_thread(request.app.state.chat_store.events, owner, rid, sequence)
|
||||||
|
detail = await asyncio.to_thread(request.app.state.chat_store.get_run, owner, rid)
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
for item in items:
|
||||||
|
sequence = item['sequence']
|
||||||
|
yield f"id: {sequence}\nevent: {item['event']}\ndata: {json.dumps(item['data'], ensure_ascii=False)}\n\n"
|
||||||
|
if not items and detail['run']['status'] in TERMINAL:
|
||||||
|
return
|
||||||
|
if not items:
|
||||||
|
yield ': keepalive\n\n'
|
||||||
|
await asyncio.sleep(.25)
|
||||||
|
return StreamingResponse(stream(), media_type='text/event-stream', headers={'Cache-Control':'no-store','X-Accel-Buffering':'no'})
|
||||||
|
|
||||||
|
@router.get('/api/runs/{rid}/events')
|
||||||
|
async def events(rid: str, request: Request, after: int = 0, user=Depends(current_user)):
|
||||||
|
async def valid():
|
||||||
|
account = await asyncio.to_thread(request.app.state.accounts.resolve_session, request.state.session_token)
|
||||||
|
return account is not None and account['id'] == user['id']
|
||||||
|
return await event_response(request, user['id'], rid, valid, after)
|
||||||
|
|
||||||
|
@router.post('/api/runs/{rid}/cancel')
|
||||||
|
async def cancel(rid: str, request: Request, user=Depends(current_user)):
|
||||||
|
return {'run': await request.app.state.chat_runs.cancel(user['id'], rid)}
|
||||||
|
|
||||||
|
@router.post('/api/runs/{rid}/approval')
|
||||||
|
async def approval(rid: str, request: Request, user=Depends(current_user)):
|
||||||
|
return {'run': await request.app.state.chat_runs.approval(user['id'], rid, **await body(request, ('approval_id','decision'), ('approval_id','decision')))}
|
||||||
|
|
||||||
|
@router.get('/api/workspaces')
|
||||||
|
async def workspaces(request: Request, user=Depends(current_user)):
|
||||||
|
return {'workspaces': await db(request, 'list_workspaces', user['id'])}
|
||||||
|
|
||||||
|
@router.post('/api/workspaces')
|
||||||
|
async def create_workspace(request: Request, user=Depends(current_user)):
|
||||||
|
return {'workspace': await db(request, 'create_workspace', user['id'], **await body(request, ('name','instructions'), ('name',)))}
|
||||||
|
|
||||||
|
@router.patch('/api/workspaces/{wid}')
|
||||||
|
async def update_workspace(wid: str, request: Request, user=Depends(current_user)):
|
||||||
|
return {'workspace': await db(request, 'update_workspace', user['id'], wid, **await body(request, ('revision','name','instructions'), ('revision',)))}
|
||||||
|
|
||||||
|
@router.delete('/api/workspaces/{wid}')
|
||||||
|
async def delete_workspace(wid: str, request: Request, user=Depends(current_user)):
|
||||||
|
await db(request, 'delete_workspace', user['id'], wid)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
@router.get('/api/preferences')
|
||||||
|
async def preferences(request: Request, user=Depends(current_user)):
|
||||||
|
return {'preferences': await db(request, 'get_preferences', user['id'])}
|
||||||
|
|
||||||
|
@router.put('/api/preferences')
|
||||||
|
async def set_preferences(request: Request, user=Depends(current_user)):
|
||||||
|
return {'preferences': await db(request, 'set_preferences', user['id'], **await body(request, ('revision','palette','appearance'), ('revision','palette','appearance')))}
|
||||||
|
|
||||||
|
@router.get('/api/sync')
|
||||||
|
async def sync(request: Request, after: int = 0, user=Depends(current_user)):
|
||||||
|
return await db(request, 'sync', user['id'], after=after)
|
||||||
|
|
||||||
|
async def room_owner(request, room):
|
||||||
|
account = await asyncio.to_thread(request.app.state.accounts.resolve_room, room)
|
||||||
|
if not account or not account.get('conversation_id'):
|
||||||
|
raise HTTPException(404, 'Room chat unavailable')
|
||||||
|
return account
|
||||||
|
|
||||||
|
@router.post('/internal/chat/rooms/{room}/messages', dependencies=[Depends(internal_authorized)], status_code=202)
|
||||||
|
async def voice_send(room: str, request: Request):
|
||||||
|
account = await room_owner(request, room)
|
||||||
|
value = await body(request, ('content','idempotency_key','modality'), ('content','idempotency_key','modality'))
|
||||||
|
if value.pop('modality') != 'voice':
|
||||||
|
raise HTTPException(400, 'Invalid modality')
|
||||||
|
return await request.app.state.chat_runs.send(account['id'], account['conversation_id'], None, room=room, **value)
|
||||||
|
|
||||||
|
@router.get('/internal/chat/rooms/{room}/runs/{rid}/events', dependencies=[Depends(internal_authorized)])
|
||||||
|
async def voice_events(room: str, rid: str, request: Request, after: int = 0):
|
||||||
|
account = await room_owner(request, room)
|
||||||
|
detail = await db(request, 'get_run', account['id'], rid)
|
||||||
|
if detail['run']['conversation_id'] != account['conversation_id']:
|
||||||
|
raise HTTPException(404, 'Run not found')
|
||||||
|
async def valid():
|
||||||
|
return await asyncio.to_thread(request.app.state.accounts.resolve_room, room) is not None
|
||||||
|
return await event_response(request, account['id'], rid, valid, after)
|
||||||
|
|
||||||
|
@router.post('/internal/chat/rooms/{room}/runs/{rid}/cancel', dependencies=[Depends(internal_authorized)])
|
||||||
|
async def voice_cancel(room: str, rid: str, request: Request):
|
||||||
|
account = await room_owner(request, room)
|
||||||
|
detail = await db(request, 'get_run', account['id'], rid)
|
||||||
|
if detail['run']['conversation_id'] != account['conversation_id']:
|
||||||
|
raise HTTPException(404, 'Run not found')
|
||||||
|
return {'run': await request.app.state.chat_runs.cancel(account['id'], rid)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/internal/chat/rooms/{room}/operations/{key}/cancel', dependencies=[Depends(internal_authorized)])
|
||||||
|
async def voice_cancel_operation(room: str, key: str, request: Request):
|
||||||
|
account = await room_owner(request, room)
|
||||||
|
try:
|
||||||
|
return await request.app.state.chat_runs.cancel_operation(account['id'], account['conversation_id'], key)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, 'Chat resource not found') from None
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat_router():
|
||||||
|
return router
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Private bounded conversation exports; no public sharing or provider execution."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
|
from .app import current_user
|
||||||
|
|
||||||
|
MAX_EXPORT_BYTES = 16 * 1024 * 1024
|
||||||
|
MAX_EXPORT_MESSAGES = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
def export_conversation(store, owner, identifier, format='json', byte_limit=MAX_EXPORT_BYTES):
|
||||||
|
if format not in ('json', 'markdown'):
|
||||||
|
raise HTTPException(422, 'Export format must be json or markdown')
|
||||||
|
with store.lock:
|
||||||
|
try:
|
||||||
|
conversation = store._conversation(store._row('chat_conversations', owner, identifier))
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
raise HTTPException(404, 'Conversation not found') from None
|
||||||
|
count, content_bytes = store.db.execute('SELECT COUNT(*),COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM chat_messages WHERE owner=? AND conversation_id=?', (owner, identifier)).fetchone()
|
||||||
|
if count > MAX_EXPORT_MESSAGES or content_bytes > byte_limit:
|
||||||
|
raise HTTPException(413, 'Conversation export limit exceeded')
|
||||||
|
messages = [store._message(row) for row in store.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? ORDER BY position', (owner, identifier))]
|
||||||
|
if format == 'json':
|
||||||
|
# Only public projection; no Hermes IDs, API URLs, credentials or tool events.
|
||||||
|
encoded = json.dumps({'version': 1, 'conversation': conversation, 'messages': messages}, ensure_ascii=False, indent=2).encode('utf-8')
|
||||||
|
mime, suffix = 'application/json', '.json'
|
||||||
|
else:
|
||||||
|
# Preserve original message text/citations. Always attachment+plain text:
|
||||||
|
# user markdown/HTML is never executed or rendered by this API.
|
||||||
|
sections = ['# ' + conversation['title'], '']
|
||||||
|
for message in messages:
|
||||||
|
sections += ['## ' + message['role'].capitalize() + ' — ' + message['status'], '', message['content'], '']
|
||||||
|
if message['attachments']:
|
||||||
|
sections += ['Attachments (IDs; private bytes not embedded): ' + ', '.join(message['attachments']), '']
|
||||||
|
encoded = '\n'.join(sections).encode('utf-8')
|
||||||
|
mime, suffix = 'text/plain', '.md'
|
||||||
|
if len(encoded) > byte_limit:
|
||||||
|
raise HTTPException(413, 'Conversation export limit exceeded')
|
||||||
|
return encoded, mime, 'conversation-' + conversation['id'] + suffix
|
||||||
|
|
||||||
|
|
||||||
|
def create_export_router():
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get('/api/conversations/{identifier}/export')
|
||||||
|
async def conversation_export(identifier: str, request: Request, format: str = 'json', user=Depends(current_user)):
|
||||||
|
data, mime, filename = await asyncio.to_thread(export_conversation, request.app.state.chat_store, user['id'], identifier, format)
|
||||||
|
await current_user(request) # Revocation during export denies final response.
|
||||||
|
return Response(data, media_type=mime, headers={'Content-Disposition': 'attachment; filename="' + filename + '"',
|
||||||
|
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'})
|
||||||
|
return router
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
"""Authenticated advanced chat surfaces. Hope stores metadata; Hermes executes.
|
||||||
|
|
||||||
|
Construct FeatureStore with the application's Accounts and include
|
||||||
|
create_feature_router(store, provider). No provider operation is enabled merely
|
||||||
|
because a method exists: the adapter must explicitly qualify that capability.
|
||||||
|
All persistence and extraction is offloaded by the async public methods.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
|
from .app import current_user
|
||||||
|
|
||||||
|
MIMES = ('text/plain', 'text/markdown', 'application/pdf', 'image/png', 'image/jpeg', 'image/webp')
|
||||||
|
ID = re.compile(r'[a-f0-9]{32}\Z')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FeatureLimits:
|
||||||
|
upload_bytes: int = 8 * 1024 * 1024
|
||||||
|
account_bytes: int = 64 * 1024 * 1024
|
||||||
|
disk_bytes: int = 512 * 1024 * 1024
|
||||||
|
extracted_chars: int = 100_000
|
||||||
|
artifact_bytes: int = 512 * 1024
|
||||||
|
artifact_count: int = 200
|
||||||
|
account_artifact_bytes: int = 32 * 1024 * 1024
|
||||||
|
global_artifact_bytes: int = 256 * 1024 * 1024
|
||||||
|
artifact_versions: int = 100
|
||||||
|
attachments_per_message: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureStore:
|
||||||
|
def __init__(self, accounts, root_path, limits=None):
|
||||||
|
self.accounts = accounts
|
||||||
|
self.db = accounts.db
|
||||||
|
self.lock = accounts.lock
|
||||||
|
self.limits = limits or FeatureLimits()
|
||||||
|
self.upload_admission = threading.BoundedSemaphore(2)
|
||||||
|
raw = Path(root_path).absolute()
|
||||||
|
if raw.is_symlink():
|
||||||
|
raise ValueError('Private storage cannot be a symlink')
|
||||||
|
raw.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
self.root = raw.resolve()
|
||||||
|
os.chmod(self.root, 0o700)
|
||||||
|
with self.lock, self.db:
|
||||||
|
self.db.executescript('''
|
||||||
|
CREATE TABLE IF NOT EXISTS hope_attachments (
|
||||||
|
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL,
|
||||||
|
extracted TEXT, extraction_status TEXT NOT NULL, created_at REAL NOT NULL,
|
||||||
|
deleted_at REAL);
|
||||||
|
CREATE INDEX IF NOT EXISTS hope_attachments_owner ON hope_attachments(owner);
|
||||||
|
CREATE TABLE IF NOT EXISTS hope_artifacts (
|
||||||
|
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL, updated_at REAL NOT NULL, deleted_at REAL);
|
||||||
|
CREATE TABLE IF NOT EXISTS hope_artifact_versions (
|
||||||
|
artifact_id TEXT NOT NULL REFERENCES hope_artifacts(id) ON DELETE CASCADE,
|
||||||
|
revision INTEGER NOT NULL, content TEXT NOT NULL, created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY(artifact_id, revision));
|
||||||
|
CREATE TABLE IF NOT EXISTS hope_feature_changes (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL, object_id TEXT NOT NULL, deleted INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL);
|
||||||
|
''')
|
||||||
|
|
||||||
|
def _owner(self, owner):
|
||||||
|
row = self.db.execute("SELECT id FROM users WHERE id=? AND desired!='deleted'", (owner,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(401, 'Account unavailable')
|
||||||
|
|
||||||
|
def _path(self, identifier):
|
||||||
|
if not isinstance(identifier, str) or not ID.fullmatch(identifier):
|
||||||
|
raise HTTPException(404, 'Attachment not found')
|
||||||
|
path = self.root / identifier
|
||||||
|
if path.parent.resolve() != self.root or path.is_symlink():
|
||||||
|
raise HTTPException(404, 'Attachment not found')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _change(self, owner, kind, identifier, deleted=False):
|
||||||
|
self.db.execute('INSERT INTO hope_feature_changes(owner,kind,object_id,deleted,created_at) VALUES(?,?,?,?,?)',
|
||||||
|
(owner, kind, identifier, int(deleted), time.time()))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attachment(row):
|
||||||
|
return {k: row[k] for k in ('id', 'filename', 'mime', 'size', 'extraction_status', 'created_at')}
|
||||||
|
|
||||||
|
def _extract(self, mime, data):
|
||||||
|
if mime.startswith('text/'):
|
||||||
|
try:
|
||||||
|
text = data.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise HTTPException(415, 'Text must be UTF-8') from None
|
||||||
|
if '\x00' in text:
|
||||||
|
raise HTTPException(415, 'Binary text is not supported')
|
||||||
|
return text[:self.limits.extracted_chars], 'truncated' if len(text) > self.limits.extracted_chars else 'ready'
|
||||||
|
signatures = {'application/pdf': b'%PDF-', 'image/png': b'\x89PNG\r\n\x1a\n', 'image/jpeg': b'\xff\xd8\xff'}
|
||||||
|
if mime in signatures and not data.startswith(signatures[mime]):
|
||||||
|
raise HTTPException(415, 'File does not match MIME type')
|
||||||
|
if mime == 'image/webp' and not (data.startswith(b'RIFF') and data[8:12] == b'WEBP'):
|
||||||
|
raise HTTPException(415, 'File does not match MIME type')
|
||||||
|
if mime == 'application/pdf':
|
||||||
|
if importlib.util.find_spec('pypdf') is None:
|
||||||
|
return None, 'unavailable'
|
||||||
|
# Untrusted compressed PDF structures must not run in the control
|
||||||
|
# process. Child has CPU/address-space/output limits and a wall timeout.
|
||||||
|
script = '''import resource,sys,io,json
|
||||||
|
resource.setrlimit(resource.RLIMIT_AS,(384*1024*1024,384*1024*1024))
|
||||||
|
resource.setrlimit(resource.RLIMIT_CPU,(4,4))
|
||||||
|
from pypdf import PdfReader
|
||||||
|
limit=int(sys.argv[1]); reader=PdfReader(io.BytesIO(sys.stdin.buffer.read()))
|
||||||
|
if reader.is_encrypted:
|
||||||
|
print(json.dumps([None,'encrypted'])); sys.exit(0)
|
||||||
|
parts=[]; size=0; truncated=False
|
||||||
|
for page in reader.pages:
|
||||||
|
text=page.extract_text() or ''
|
||||||
|
parts.append(text[:max(0,limit-size)])
|
||||||
|
size+=len(text)
|
||||||
|
if size>=limit:
|
||||||
|
truncated=True; break
|
||||||
|
print(json.dumps(['\\n'.join(parts)[:limit],'truncated' if truncated else 'ready']))
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
result = subprocess.run([sys.executable, '-I', '-c', script, str(self.limits.extracted_chars)],
|
||||||
|
input=data, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=6, check=True)
|
||||||
|
text, status = json.loads(result.stdout)
|
||||||
|
return text, status
|
||||||
|
except Exception:
|
||||||
|
return None, 'failed'
|
||||||
|
return None, 'image'
|
||||||
|
|
||||||
|
def _upload(self, owner, filename, mime, data):
|
||||||
|
if mime not in MIMES:
|
||||||
|
raise HTTPException(415, 'Unsupported MIME type')
|
||||||
|
if not isinstance(data, bytes) or not 0 < len(data) <= self.limits.upload_bytes:
|
||||||
|
raise HTTPException(413, 'Upload byte limit exceeded')
|
||||||
|
if not isinstance(filename, str) or not 1 <= len(filename) <= 160 or any(c in filename for c in ('/', '\\', '\x00', '\r', '\n')) or any(ord(c) < 32 for c in filename):
|
||||||
|
raise HTTPException(422, 'Invalid filename')
|
||||||
|
# Reserve parser concurrency before quota/extraction work; fail fast.
|
||||||
|
if not self.upload_admission.acquire(blocking=False):
|
||||||
|
raise HTTPException(429, 'Upload processing busy')
|
||||||
|
try:
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
used = self.db.execute('SELECT COALESCE(SUM(size),0) FROM hope_attachments WHERE owner=? AND deleted_at IS NULL', (owner,)).fetchone()[0]
|
||||||
|
disk = sum(p.stat(follow_symlinks=False).st_size for p in self.root.iterdir() if p.is_file() and not p.is_symlink())
|
||||||
|
if used + len(data) > self.limits.account_bytes or disk + len(data) > self.limits.disk_bytes:
|
||||||
|
raise HTTPException(413, 'Private storage quota exceeded')
|
||||||
|
text, status = self._extract(mime, data)
|
||||||
|
finally:
|
||||||
|
self.upload_admission.release()
|
||||||
|
identifier = uuid.uuid4().hex
|
||||||
|
path = self._path(identifier)
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._owner(owner)
|
||||||
|
used = self.db.execute('SELECT COALESCE(SUM(size),0) FROM hope_attachments WHERE owner=? AND deleted_at IS NULL', (owner,)).fetchone()[0]
|
||||||
|
# Include retained/orphaned files after account deletion in global quota.
|
||||||
|
disk = sum(p.stat(follow_symlinks=False).st_size for p in self.root.iterdir() if p.is_file() and not p.is_symlink())
|
||||||
|
if used + len(data) > self.limits.account_bytes or disk + len(data) > self.limits.disk_bytes:
|
||||||
|
raise HTTPException(413, 'Private storage quota exceeded')
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0), 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'wb') as stream:
|
||||||
|
stream.write(data)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
self.db.execute('INSERT INTO hope_attachments VALUES(?,?,?,?,?,?,?,?,NULL)',
|
||||||
|
(identifier, owner, filename, mime, len(data), text, status, time.time()))
|
||||||
|
self._change(owner, 'attachment', identifier)
|
||||||
|
except BaseException:
|
||||||
|
# _path has verified the resolved parent and server-generated ID.
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return self._attachment(self._get_attachment(owner, identifier))
|
||||||
|
|
||||||
|
def _get_attachment(self, owner, identifier):
|
||||||
|
self._owner(owner)
|
||||||
|
row = self.db.execute('SELECT * FROM hope_attachments WHERE owner=? AND id=? AND deleted_at IS NULL', (owner, identifier)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, 'Attachment not found')
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _read_attachment(self, owner, identifier):
|
||||||
|
with self.lock:
|
||||||
|
row = self._get_attachment(owner, identifier)
|
||||||
|
path = self._path(identifier)
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
|
||||||
|
with os.fdopen(fd, 'rb') as stream:
|
||||||
|
info = os.fstat(stream.fileno())
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_size != row['size']:
|
||||||
|
raise HTTPException(404, 'Attachment unavailable')
|
||||||
|
data = stream.read(self.limits.upload_bytes + 1)
|
||||||
|
except OSError:
|
||||||
|
raise HTTPException(404, 'Attachment unavailable') from None
|
||||||
|
return self._attachment(row), data
|
||||||
|
|
||||||
|
def _delete_attachment(self, owner, identifier):
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._get_attachment(owner, identifier)
|
||||||
|
path = self._path(identifier)
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
self.db.execute('UPDATE hope_attachments SET deleted_at=? WHERE owner=? AND id=?', (time.time(), owner, identifier))
|
||||||
|
self._change(owner, 'attachment', identifier, True)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
async def upload(self, owner, filename, mime, data):
|
||||||
|
return await asyncio.to_thread(self._upload, owner, filename, mime, data)
|
||||||
|
|
||||||
|
async def read_attachment(self, owner, identifier):
|
||||||
|
return await asyncio.to_thread(self._read_attachment, owner, identifier)
|
||||||
|
|
||||||
|
async def delete_attachment(self, owner, identifier):
|
||||||
|
return await asyncio.to_thread(self._delete_attachment, owner, identifier)
|
||||||
|
|
||||||
|
async def provider_content(self, owner, identifiers, *, vision=False):
|
||||||
|
def build():
|
||||||
|
if not isinstance(identifiers, list) or len(identifiers) > self.limits.attachments_per_message or any(not isinstance(identifier, str) or not ID.fullmatch(identifier) for identifier in identifiers) or len(set(identifiers)) != len(identifiers):
|
||||||
|
raise HTTPException(422, 'Invalid attachment selection')
|
||||||
|
parts = []
|
||||||
|
with self.lock:
|
||||||
|
for identifier in identifiers:
|
||||||
|
row = self._get_attachment(owner, identifier)
|
||||||
|
if row['mime'].startswith('image/'):
|
||||||
|
if not vision:
|
||||||
|
raise HTTPException(409, 'Selected model is not qualified for image input')
|
||||||
|
_, data = self._read_attachment(owner, identifier)
|
||||||
|
parts.append({'type': 'image_url', 'image_url': {'url': 'data:' + row['mime'] + ';base64,' + base64.b64encode(data).decode('ascii')}})
|
||||||
|
elif row['extraction_status'] in ('ready', 'truncated'):
|
||||||
|
parts.append({'type': 'text', 'text': 'Attachment: ' + row['filename'] + '\n' + (row['extracted'] or '')})
|
||||||
|
else:
|
||||||
|
raise HTTPException(409, 'Attachment text extraction unavailable')
|
||||||
|
return parts
|
||||||
|
return await asyncio.to_thread(build)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_artifact(title, kind, content, limit):
|
||||||
|
if not isinstance(title, str) or not 1 <= len(title.strip()) <= 160:
|
||||||
|
raise HTTPException(422, 'Invalid artifact title')
|
||||||
|
if kind not in ('text', 'markdown', 'code'):
|
||||||
|
raise HTTPException(422, 'Unsupported artifact kind')
|
||||||
|
if not isinstance(content, str) or len(content.encode('utf-8')) > limit:
|
||||||
|
raise HTTPException(413, 'Artifact byte limit exceeded')
|
||||||
|
|
||||||
|
def _get_artifact(self, owner, identifier, revision=None):
|
||||||
|
self._owner(owner)
|
||||||
|
row = self.db.execute('SELECT * FROM hope_artifacts WHERE owner=? AND id=? AND deleted_at IS NULL', (owner, identifier)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, 'Artifact not found')
|
||||||
|
result = {k: row[k] for k in ('id', 'title', 'kind', 'revision', 'created_at', 'updated_at')}
|
||||||
|
version = self.db.execute('SELECT content FROM hope_artifact_versions WHERE artifact_id=? AND revision=?', (identifier, revision if revision is not None else row['revision'])).fetchone()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(404, 'Artifact version not found')
|
||||||
|
result['content'] = version['content']
|
||||||
|
if revision is not None:
|
||||||
|
result['revision'] = revision
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _save_artifact(self, owner, title, kind, content, identifier=None, revision=None):
|
||||||
|
self._validate_artifact(title, kind, content, self.limits.artifact_bytes)
|
||||||
|
now = time.time()
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._owner(owner)
|
||||||
|
used = self.db.execute('SELECT COALESCE(SUM(length(CAST(v.content AS BLOB))),0) FROM hope_artifact_versions v JOIN hope_artifacts a ON a.id=v.artifact_id WHERE a.owner=?', (owner,)).fetchone()[0]
|
||||||
|
total = self.db.execute('SELECT COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM hope_artifact_versions').fetchone()[0]
|
||||||
|
incoming = len(content.encode('utf-8'))
|
||||||
|
if used + incoming > self.limits.account_artifact_bytes or total + incoming > self.limits.global_artifact_bytes:
|
||||||
|
raise HTTPException(413, 'Artifact storage quota exceeded')
|
||||||
|
if identifier is None:
|
||||||
|
count = self.db.execute('SELECT COUNT(*) FROM hope_artifacts WHERE owner=?', (owner,)).fetchone()[0]
|
||||||
|
if count >= self.limits.artifact_count:
|
||||||
|
raise HTTPException(413, 'Artifact quota exceeded')
|
||||||
|
identifier = uuid.uuid4().hex
|
||||||
|
self.db.execute('INSERT INTO hope_artifacts VALUES(?,?,?,?,1,?,?,NULL)', (identifier, owner, title.strip(), kind, now, now))
|
||||||
|
version = 1
|
||||||
|
else:
|
||||||
|
old = self._get_artifact(owner, identifier)
|
||||||
|
if type(revision) is not int or revision != old['revision']:
|
||||||
|
raise HTTPException(409, 'Artifact revision conflict')
|
||||||
|
if revision >= self.limits.artifact_versions:
|
||||||
|
raise HTTPException(413, 'Artifact version quota exceeded')
|
||||||
|
version = revision + 1
|
||||||
|
self.db.execute('UPDATE hope_artifacts SET title=?,kind=?,revision=?,updated_at=? WHERE owner=? AND id=?',
|
||||||
|
(title.strip(), kind, version, now, owner, identifier))
|
||||||
|
self.db.execute('INSERT INTO hope_artifact_versions VALUES(?,?,?,?)', (identifier, version, content, now))
|
||||||
|
self._change(owner, 'artifact', identifier)
|
||||||
|
return self._get_artifact(owner, identifier)
|
||||||
|
|
||||||
|
async def save_artifact(self, owner, title, kind, content, identifier=None, revision=None):
|
||||||
|
return await asyncio.to_thread(self._save_artifact, owner, title, kind, content, identifier, revision)
|
||||||
|
|
||||||
|
async def get_artifact(self, owner, identifier, revision=None):
|
||||||
|
def get():
|
||||||
|
with self.lock:
|
||||||
|
return self._get_artifact(owner, identifier, revision)
|
||||||
|
return await asyncio.to_thread(get)
|
||||||
|
|
||||||
|
async def delete_artifact(self, owner, identifier):
|
||||||
|
def remove():
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._get_artifact(owner, identifier)
|
||||||
|
self.db.execute('UPDATE hope_artifacts SET deleted_at=?,updated_at=? WHERE owner=? AND id=?', (time.time(), time.time(), owner, identifier))
|
||||||
|
self._change(owner, 'artifact', identifier, True)
|
||||||
|
return {'ok': True}
|
||||||
|
return await asyncio.to_thread(remove)
|
||||||
|
|
||||||
|
async def list_objects(self, owner, kind):
|
||||||
|
def collect():
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
if kind == 'attachments':
|
||||||
|
return [self._attachment(r) for r in self.db.execute('SELECT * FROM hope_attachments WHERE owner=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 200', (owner,))]
|
||||||
|
return [dict(row) for row in self.db.execute('SELECT id,title,kind,revision,created_at,updated_at FROM hope_artifacts WHERE owner=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 200', (owner,))]
|
||||||
|
return await asyncio.to_thread(collect)
|
||||||
|
|
||||||
|
async def changes(self, owner, after=0):
|
||||||
|
def collect():
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
rows = self.db.execute('SELECT * FROM hope_feature_changes WHERE owner=? AND sequence>? ORDER BY sequence LIMIT 200', (owner, after)).fetchall()
|
||||||
|
return {'changes': [{'sequence': r['sequence'], 'kind': r['kind'], 'id': r['object_id'], 'deleted': bool(r['deleted']), 'created_at': r['created_at']} for r in rows], 'cursor': rows[-1]['sequence'] if rows else after}
|
||||||
|
return await asyncio.to_thread(collect)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureProvider:
|
||||||
|
"""Fixed-route private gateway adapter; no client-selected URL or tool loop."""
|
||||||
|
def __init__(self, accounts, http_client=None, state_dir=None):
|
||||||
|
import httpx
|
||||||
|
self.accounts = accounts
|
||||||
|
self.http = http_client or httpx.AsyncClient(timeout=30, follow_redirects=False)
|
||||||
|
self.owns_http = http_client is None
|
||||||
|
self.state_dir = Path(state_dir or os.getenv('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
if self.owns_http:
|
||||||
|
await self.http.aclose()
|
||||||
|
|
||||||
|
async def _account(self, owner):
|
||||||
|
user = await asyncio.to_thread(self.accounts.get, owner)
|
||||||
|
if not user or user['desired'] == 'deleted':
|
||||||
|
raise HTTPException(401, 'Account unavailable')
|
||||||
|
if user['status'] != 'ready' or not user.get('hermes_url') or not user.get('hermes_api_key'):
|
||||||
|
raise HTTPException(503, 'Hermes not ready')
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def toolset_status(self, owner):
|
||||||
|
user = await self._account(owner)
|
||||||
|
try:
|
||||||
|
response = await self.http.get(user['hermes_url'].rstrip('/') + '/v1/toolsets', headers={'Authorization': 'Bearer ' + user['hermes_api_key']})
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError()
|
||||||
|
body = response.json()
|
||||||
|
if not isinstance(body, dict) or not isinstance(body.get('data'), list):
|
||||||
|
raise ValueError()
|
||||||
|
return [{'name': row['name'], 'enabled': row.get('enabled') is True, 'configured': row.get('configured') is True,
|
||||||
|
'tools': [tool for tool in row.get('tools', []) if isinstance(tool, str)][:200]}
|
||||||
|
for row in body['data'][:100] if isinstance(row, dict) and isinstance(row.get('name'), str) and isinstance(row.get('tools'), list)]
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(503, 'Hermes tool capability status unavailable') from None
|
||||||
|
|
||||||
|
async def research_status(self, owner):
|
||||||
|
toolsets = await self.toolset_status(owner)
|
||||||
|
available = any(row['name'] == 'web' and row['enabled'] and row['configured'] and
|
||||||
|
{'web_search', 'web_extract'} <= set(row['tools']) for row in toolsets)
|
||||||
|
return {'available': available, 'mode': 'hermes_tools' if available else None,
|
||||||
|
'reason': None if available else 'Hermes web search and extraction must be enabled and configured'}
|
||||||
|
|
||||||
|
async def research_instruction(self, owner):
|
||||||
|
status = await self.research_status(owner)
|
||||||
|
if not status['available']:
|
||||||
|
raise HTTPException(409, 'Research tools are unavailable for this account')
|
||||||
|
return ('Research mode requested by the user. Use your Hermes web search and extraction tools to investigate the question. '
|
||||||
|
'Prefer primary sources, corroborate material claims, distinguish evidence from uncertainty, and cite source URLs in the final answer. '
|
||||||
|
'Do not claim sources were checked if tools failed or access was unavailable. Complete this research in this same Hermes run.')
|
||||||
|
|
||||||
|
async def feature_capabilities(self, owner):
|
||||||
|
await self._account(owner)
|
||||||
|
settings = await asyncio.to_thread(self.accounts.get_model_settings, owner)
|
||||||
|
# Exact pinned jobs API and previously qualified managed auto vision only.
|
||||||
|
def memory_available():
|
||||||
|
try:
|
||||||
|
self._memory_file(owner, 'memory')
|
||||||
|
self._memory_file(owner, 'user')
|
||||||
|
return True
|
||||||
|
except HTTPException:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
research = (await self.research_status(owner))['available']
|
||||||
|
except HTTPException:
|
||||||
|
research = False
|
||||||
|
return {'scheduled_tasks': True, 'memory_controls': await asyncio.to_thread(memory_available),
|
||||||
|
'image_input': settings['model'] == 'auto', 'connectors': False, 'research': research}
|
||||||
|
|
||||||
|
async def feature_request(self, owner, method, path, *, json=None, params=None):
|
||||||
|
user = await self._account(owner)
|
||||||
|
permitted = ((path == '/api/jobs' and method in ('GET', 'POST')) or
|
||||||
|
(re.fullmatch(r'/api/jobs/[a-f0-9]{12}', path) and method in ('PATCH', 'DELETE')) or
|
||||||
|
(re.fullmatch(r'/api/jobs/[a-f0-9]{12}/(?:pause|resume|run)', path) and method == 'POST'))
|
||||||
|
if not permitted:
|
||||||
|
raise HTTPException(501, 'Unqualified provider operation')
|
||||||
|
try:
|
||||||
|
response = await self.http.request(method, user['hermes_url'].rstrip('/') + path,
|
||||||
|
headers={'Authorization': 'Bearer ' + user['hermes_api_key']}, json=json, params=params)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
# Remote detail can include config, paths or credentials.
|
||||||
|
status = response.status_code if response.status_code in (400, 404, 409, 422, 429, 503) else 502
|
||||||
|
raise HTTPException(status, 'Hermes task operation rejected')
|
||||||
|
return response.json()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(502, 'Hermes task operation failed') from None
|
||||||
|
|
||||||
|
def _memory_file(self, owner, section):
|
||||||
|
if not ID.fullmatch(owner) or section not in ('memory', 'user'):
|
||||||
|
raise HTTPException(404, 'Memory section not found')
|
||||||
|
# Fixed layout shared with Provisioner._paths; never accept caller paths.
|
||||||
|
components = (self.state_dir, self.state_dir / owner, self.state_dir / owner / 'home',
|
||||||
|
self.state_dir / owner / 'home' / 'memories')
|
||||||
|
for component in components:
|
||||||
|
if component.is_symlink():
|
||||||
|
raise HTTPException(409, 'Unsafe memory storage')
|
||||||
|
directory = components[-1]
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise HTTPException(503, 'Hermes memory storage unavailable')
|
||||||
|
path = directory / ('MEMORY.md' if section == 'memory' else 'USER.md')
|
||||||
|
if path.is_symlink():
|
||||||
|
raise HTTPException(409, 'Unsafe memory storage')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _memory_open(self, owner, section, flags):
|
||||||
|
self._memory_file(owner, section)
|
||||||
|
directory = os.open(self.state_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_NOFOLLOW', 0))
|
||||||
|
try:
|
||||||
|
for name in (owner, 'home', 'memories'):
|
||||||
|
next_directory = os.open(name, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_NOFOLLOW', 0), dir_fd=directory)
|
||||||
|
os.close(directory)
|
||||||
|
directory = next_directory
|
||||||
|
fd = os.open('MEMORY.md' if section == 'memory' else 'USER.md', flags | getattr(os, 'O_NOFOLLOW', 0) | os.O_NONBLOCK, 0o600, dir_fd=directory)
|
||||||
|
file_info = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(file_info.st_mode) or file_info.st_nlink != 1:
|
||||||
|
os.close(fd)
|
||||||
|
raise HTTPException(409, 'Unsafe memory storage')
|
||||||
|
if flags & os.O_CREAT and os.geteuid() == 0:
|
||||||
|
info = os.fstat(directory)
|
||||||
|
os.fchown(fd, info.st_uid, info.st_gid)
|
||||||
|
return fd
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
|
||||||
|
def _read_memory_section(self, owner, section):
|
||||||
|
path = self._memory_file(owner, section)
|
||||||
|
try:
|
||||||
|
fd = self._memory_open(owner, section, os.O_RDONLY)
|
||||||
|
except FileNotFoundError:
|
||||||
|
data = b''
|
||||||
|
else:
|
||||||
|
with os.fdopen(fd, 'rb') as stream:
|
||||||
|
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||||
|
raise HTTPException(409, 'Unsafe memory storage')
|
||||||
|
data = stream.read(65537)
|
||||||
|
if len(data) > 65536:
|
||||||
|
raise HTTPException(413, 'Memory section too large')
|
||||||
|
try:
|
||||||
|
content = data.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise HTTPException(409, 'Invalid memory encoding') from None
|
||||||
|
return {'content': content, 'revision': hashlib.sha256(data).hexdigest()}
|
||||||
|
|
||||||
|
async def read_memory(self, owner):
|
||||||
|
await self._account(owner)
|
||||||
|
def read():
|
||||||
|
with self.accounts.lock:
|
||||||
|
return {section: self._read_memory_section(owner, section) for section in ('memory', 'user')}
|
||||||
|
return await asyncio.to_thread(read)
|
||||||
|
|
||||||
|
async def write_memory(self, owner, section, content, revision):
|
||||||
|
await self._account(owner)
|
||||||
|
def write():
|
||||||
|
with self.accounts.lock:
|
||||||
|
current = self.accounts.get(owner)
|
||||||
|
if not current or current['desired'] == 'deleted':
|
||||||
|
raise HTTPException(401, 'Account unavailable')
|
||||||
|
old = self._read_memory_section(owner, section)
|
||||||
|
if old['revision'] != revision:
|
||||||
|
raise HTTPException(409, 'Memory revision conflict')
|
||||||
|
path = self._memory_file(owner, section)
|
||||||
|
data = content.encode('utf-8')
|
||||||
|
if len(data) > 65536:
|
||||||
|
raise HTTPException(413, 'Memory section too large')
|
||||||
|
# Reopen without following symlinks and compare under the same
|
||||||
|
# descriptor before in-place writing. Preserve runtime ownership.
|
||||||
|
fd = self._memory_open(owner, section, os.O_RDWR | os.O_CREAT)
|
||||||
|
with os.fdopen(fd, 'r+b') as stream:
|
||||||
|
info = os.fstat(stream.fileno())
|
||||||
|
if not stat.S_ISREG(info.st_mode) or hashlib.sha256(stream.read(65537)).hexdigest() != revision:
|
||||||
|
raise HTTPException(409, 'Memory revision conflict')
|
||||||
|
stream.seek(0)
|
||||||
|
stream.write(data)
|
||||||
|
stream.truncate()
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
return {'revision': hashlib.sha256(data).hexdigest()}
|
||||||
|
return await asyncio.to_thread(write)
|
||||||
|
|
||||||
|
|
||||||
|
async def feature_json(request, limit):
|
||||||
|
if request.headers.get('content-type', '').split(';')[0].lower() != 'application/json':
|
||||||
|
raise HTTPException(415, 'JSON required')
|
||||||
|
data = bytearray()
|
||||||
|
async for chunk in request.stream():
|
||||||
|
if len(data) + len(chunk) > limit:
|
||||||
|
raise HTTPException(413, 'Request body too large')
|
||||||
|
data.extend(chunk)
|
||||||
|
try:
|
||||||
|
result = json.loads(data)
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
raise HTTPException(400, 'Invalid JSON') from None
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise HTTPException(422, 'JSON object required')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_feature_router(store, provider=None):
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post('/api/attachments', status_code=201)
|
||||||
|
async def upload(request: Request, user=Depends(current_user)):
|
||||||
|
data = bytearray()
|
||||||
|
async for chunk in request.stream():
|
||||||
|
if len(data) + len(chunk) > store.limits.upload_bytes:
|
||||||
|
raise HTTPException(413, 'Upload byte limit exceeded')
|
||||||
|
data.extend(chunk)
|
||||||
|
user = await current_user(request) # Revalidate after potentially slow body upload.
|
||||||
|
attachment = await store.upload(user['id'], request.headers.get('x-filename', 'attachment'), request.headers.get('content-type', '').split(';')[0].strip().lower(), bytes(data))
|
||||||
|
return {'attachment': attachment}
|
||||||
|
|
||||||
|
@router.get('/api/attachments')
|
||||||
|
async def attachments(user=Depends(current_user)):
|
||||||
|
return {'attachments': await store.list_objects(user['id'], 'attachments'), 'next_cursor': None}
|
||||||
|
|
||||||
|
@router.get('/api/attachments/{identifier}')
|
||||||
|
async def attachment(identifier: str, user=Depends(current_user)):
|
||||||
|
metadata, _ = await store.read_attachment(user['id'], identifier)
|
||||||
|
return {'attachment': metadata}
|
||||||
|
|
||||||
|
@router.get('/api/attachments/{identifier}/content')
|
||||||
|
async def download(identifier: str, user=Depends(current_user)):
|
||||||
|
metadata, data = await store.read_attachment(user['id'], identifier)
|
||||||
|
return Response(data, media_type=metadata['mime'], headers={'Content-Disposition': "attachment; filename*=UTF-8''" + quote(metadata['filename'], safe=''), 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store'})
|
||||||
|
|
||||||
|
@router.delete('/api/attachments/{identifier}')
|
||||||
|
async def delete_attachment(identifier: str, user=Depends(current_user)):
|
||||||
|
return await store.delete_attachment(user['id'], identifier)
|
||||||
|
|
||||||
|
@router.get('/api/artifacts')
|
||||||
|
async def artifacts(user=Depends(current_user)):
|
||||||
|
return {'artifacts': await store.list_objects(user['id'], 'artifacts'), 'next_cursor': None}
|
||||||
|
|
||||||
|
@router.post('/api/artifacts', status_code=201)
|
||||||
|
async def create_artifact(request: Request, user=Depends(current_user)):
|
||||||
|
body = await feature_json(request, store.limits.artifact_bytes + 8192)
|
||||||
|
if set(body) - {'title', 'kind', 'content'}:
|
||||||
|
raise HTTPException(422, 'Unknown artifact fields')
|
||||||
|
user = await current_user(request)
|
||||||
|
return {'artifact': await store.save_artifact(user['id'], body.get('title'), body.get('kind', 'markdown'), body.get('content', ''))}
|
||||||
|
|
||||||
|
@router.get('/api/artifacts/{identifier}')
|
||||||
|
async def artifact(identifier: str, revision: int | None = None, user=Depends(current_user)):
|
||||||
|
return {'artifact': await store.get_artifact(user['id'], identifier, revision)}
|
||||||
|
|
||||||
|
@router.patch('/api/artifacts/{identifier}')
|
||||||
|
async def edit_artifact(identifier: str, request: Request, user=Depends(current_user)):
|
||||||
|
body = await feature_json(request, store.limits.artifact_bytes + 8192)
|
||||||
|
if set(body) - {'revision', 'title', 'kind', 'content'}:
|
||||||
|
raise HTTPException(422, 'Unknown artifact fields')
|
||||||
|
user = await current_user(request)
|
||||||
|
old = await store.get_artifact(user['id'], identifier)
|
||||||
|
return {'artifact': await store.save_artifact(user['id'], body.get('title', old['title']), body.get('kind', old['kind']), body.get('content', old['content']), identifier, body.get('revision'))}
|
||||||
|
|
||||||
|
@router.get('/api/artifacts/{identifier}/versions')
|
||||||
|
async def artifact_versions(identifier: str, user=Depends(current_user)):
|
||||||
|
def collect():
|
||||||
|
with store.lock:
|
||||||
|
store._get_artifact(user['id'], identifier)
|
||||||
|
return [dict(row) for row in store.db.execute('SELECT revision,created_at FROM hope_artifact_versions WHERE artifact_id=? ORDER BY revision DESC', (identifier,))]
|
||||||
|
return {'versions': await asyncio.to_thread(collect), 'next_cursor': None}
|
||||||
|
|
||||||
|
@router.get('/api/artifacts/{identifier}/export')
|
||||||
|
async def export_artifact(identifier: str, revision: int | None = None, user=Depends(current_user)):
|
||||||
|
item = await store.get_artifact(user['id'], identifier, revision)
|
||||||
|
suffix = '.md' if item['kind'] == 'markdown' else '.txt'
|
||||||
|
return Response(item['content'].encode('utf-8'), media_type='text/plain', headers={'Content-Disposition': 'attachment; filename="artifact-' + item['id'] + suffix + '"', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'})
|
||||||
|
|
||||||
|
@router.delete('/api/artifacts/{identifier}')
|
||||||
|
async def delete_artifact(identifier: str, user=Depends(current_user)):
|
||||||
|
return await store.delete_artifact(user['id'], identifier)
|
||||||
|
|
||||||
|
@router.get('/api/features/sync')
|
||||||
|
async def sync(after: int = 0, user=Depends(current_user)):
|
||||||
|
if after < 0:
|
||||||
|
raise HTTPException(422, 'Invalid cursor')
|
||||||
|
return await store.changes(user['id'], after)
|
||||||
|
|
||||||
|
async def qualified(owner, capability):
|
||||||
|
if provider is None or not hasattr(provider, 'feature_capabilities'):
|
||||||
|
raise HTTPException(501, 'Operation is not qualified for this account')
|
||||||
|
try:
|
||||||
|
capabilities = await provider.feature_capabilities(owner)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(503, 'Hermes capabilities unavailable') from None
|
||||||
|
if not isinstance(capabilities, dict) or capabilities.get(capability) is not True:
|
||||||
|
raise HTTPException(501, 'Operation is not qualified for this account')
|
||||||
|
|
||||||
|
async def job_request(owner, method, suffix='', body=None, params=None):
|
||||||
|
await qualified(owner, 'scheduled_tasks')
|
||||||
|
try:
|
||||||
|
return await provider.feature_request(owner, method, '/api/jobs' + suffix, json=body, params=params)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(502, 'Hermes task operation failed') from None
|
||||||
|
|
||||||
|
def task(job):
|
||||||
|
if not isinstance(job, dict) or not isinstance(job.get('id'), str) or not re.fullmatch(r'[a-f0-9]{12}', job['id']):
|
||||||
|
raise HTTPException(502, 'Invalid Hermes task response')
|
||||||
|
# Raw job rows contain upstream credentials/configuration. Never return them.
|
||||||
|
keys = ('id', 'name', 'schedule', 'prompt', 'enabled', 'repeat', 'created_at', 'last_run_at', 'next_run_at')
|
||||||
|
return {key: job[key] for key in keys if key in job}
|
||||||
|
|
||||||
|
def task_id(identifier):
|
||||||
|
if not re.fullmatch(r'[a-f0-9]{12}', identifier):
|
||||||
|
raise HTTPException(404, 'Task not found')
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
async def task_body(request, *, patch=False):
|
||||||
|
body = await feature_json(request, 32768)
|
||||||
|
allowed = {'name', 'schedule', 'prompt', 'repeat', 'enabled'} if patch else {'name', 'schedule', 'prompt', 'repeat', 'paused'}
|
||||||
|
if set(body) - allowed or not body:
|
||||||
|
raise HTTPException(422, 'Invalid task fields')
|
||||||
|
for key, maximum in (('name', 200), ('schedule', 200), ('prompt', 5000)):
|
||||||
|
if key in body and (not isinstance(body[key], str) or not 1 <= len(body[key].strip()) <= maximum):
|
||||||
|
raise HTTPException(422, 'Invalid task ' + key)
|
||||||
|
if not patch and key not in body:
|
||||||
|
raise HTTPException(422, 'Task ' + key + ' required')
|
||||||
|
if 'repeat' in body and (type(body['repeat']) is not int or body['repeat'] <= 0):
|
||||||
|
raise HTTPException(422, 'Invalid task repeat')
|
||||||
|
for key in ('enabled', 'paused'):
|
||||||
|
if key in body and type(body[key]) is not bool:
|
||||||
|
raise HTTPException(422, 'Invalid task ' + key)
|
||||||
|
body['deliver'] = 'local'
|
||||||
|
return body
|
||||||
|
|
||||||
|
@router.get('/api/tasks')
|
||||||
|
async def tasks(user=Depends(current_user)):
|
||||||
|
result = await job_request(user['id'], 'GET', params={'include_disabled': 'true'})
|
||||||
|
if not isinstance(result, dict) or not isinstance(result.get('jobs'), list):
|
||||||
|
raise HTTPException(502, 'Invalid Hermes task response')
|
||||||
|
return {'tasks': [task(job) for job in result['jobs']], 'next_cursor': None}
|
||||||
|
|
||||||
|
@router.post('/api/tasks', status_code=201)
|
||||||
|
async def create_task(request: Request, user=Depends(current_user)):
|
||||||
|
body = await task_body(request)
|
||||||
|
user = await current_user(request)
|
||||||
|
result = await job_request(user['id'], 'POST', body=body)
|
||||||
|
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
|
||||||
|
|
||||||
|
@router.patch('/api/tasks/{identifier}')
|
||||||
|
async def update_task(identifier: str, request: Request, user=Depends(current_user)):
|
||||||
|
body = await task_body(request, patch=True)
|
||||||
|
user = await current_user(request)
|
||||||
|
result = await job_request(user['id'], 'PATCH', '/' + task_id(identifier), body)
|
||||||
|
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
|
||||||
|
|
||||||
|
@router.delete('/api/tasks/{identifier}')
|
||||||
|
async def delete_task(identifier: str, user=Depends(current_user)):
|
||||||
|
result = await job_request(user['id'], 'DELETE', '/' + task_id(identifier))
|
||||||
|
if not isinstance(result, dict) or result.get('ok') is not True:
|
||||||
|
raise HTTPException(502, 'Invalid Hermes task response')
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
@router.post('/api/tasks/{identifier}/{action}')
|
||||||
|
async def task_action(identifier: str, action: str, user=Depends(current_user)):
|
||||||
|
if action not in ('pause', 'resume', 'run'):
|
||||||
|
raise HTTPException(404, 'Task action not found')
|
||||||
|
result = await job_request(user['id'], 'POST', '/' + task_id(identifier) + '/' + action)
|
||||||
|
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
|
||||||
|
|
||||||
|
@router.get('/api/memory')
|
||||||
|
async def memory(user=Depends(current_user)):
|
||||||
|
await qualified(user['id'], 'memory_controls')
|
||||||
|
try:
|
||||||
|
result = await provider.read_memory(user['id'])
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(502, 'Memory unavailable') from None
|
||||||
|
# Dedicated trusted adapter returns only fixed sections, never paths/config.
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise HTTPException(502, 'Invalid memory response')
|
||||||
|
sections = []
|
||||||
|
for section in ('memory', 'user'):
|
||||||
|
value = result.get(section)
|
||||||
|
if not isinstance(value, dict) or not isinstance(value.get('content'), str) or not isinstance(value.get('revision'), str):
|
||||||
|
raise HTTPException(502, 'Invalid memory response')
|
||||||
|
sections.append({'section': section, 'content': value['content'], 'revision': value['revision']})
|
||||||
|
return {'sections': sections}
|
||||||
|
|
||||||
|
@router.put('/api/memory/{section}')
|
||||||
|
async def update_memory(section: str, request: Request, user=Depends(current_user)):
|
||||||
|
if section not in ('memory', 'user'):
|
||||||
|
raise HTTPException(404, 'Memory section not found')
|
||||||
|
await qualified(user['id'], 'memory_controls')
|
||||||
|
body = await feature_json(request, 131072)
|
||||||
|
if set(body) != {'content', 'revision'} or not isinstance(body['content'], str) or len(body['content'].encode('utf-8')) > 65536 or not isinstance(body['revision'], str) or len(body['revision']) > 128:
|
||||||
|
raise HTTPException(422, 'Invalid memory update')
|
||||||
|
user = await current_user(request)
|
||||||
|
try:
|
||||||
|
result = await provider.write_memory(user['id'], section, body['content'], body['revision'])
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(502, 'Memory update failed') from None
|
||||||
|
if not isinstance(result, dict) or not isinstance(result.get('revision'), str):
|
||||||
|
raise HTTPException(502, 'Invalid memory response')
|
||||||
|
return {'section': section, 'content': body['content'], 'revision': result['revision']}
|
||||||
|
|
||||||
|
@router.get('/api/connectors')
|
||||||
|
async def connectors(user=Depends(current_user)):
|
||||||
|
# The pinned gateway has no connector management API. Do not invent one.
|
||||||
|
statuses = []
|
||||||
|
if provider is not None and hasattr(provider, 'toolset_status'):
|
||||||
|
try:
|
||||||
|
statuses = await provider.toolset_status(user['id'])
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
return {'connectors': [], 'available': False, 'reason': 'Pinned Hermes has no connector credential/settings write API',
|
||||||
|
'toolsets': [{'name': row['name'], 'enabled': row['enabled'], 'configured': row['configured']} for row in statuses]}
|
||||||
|
|
||||||
|
@router.get('/api/research')
|
||||||
|
async def research(user=Depends(current_user)):
|
||||||
|
# Research remains normal Hermes execution, never a Hope reasoning loop.
|
||||||
|
if provider is not None and hasattr(provider, 'research_status'):
|
||||||
|
try:
|
||||||
|
return await provider.research_status(user['id'])
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
return {'available': False, 'mode': None, 'reason': 'Hermes research tool availability could not be qualified'}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Core merges these into its single /api/capabilities response.
|
||||||
|
def local_capabilities(store):
|
||||||
|
return {'attachments': True, 'artifacts': True, 'pdf_extraction': importlib.util.find_spec('pypdf') is not None,
|
||||||
|
'attachment_mimes': list(MIMES), 'upload_bytes': store.limits.upload_bytes,
|
||||||
|
'account_attachment_bytes': store.limits.account_bytes, 'artifact_bytes': store.limits.artifact_bytes,
|
||||||
|
'image_input': False, 'scheduled_tasks': False, 'memory_controls': False,
|
||||||
|
'connectors': False, 'research': False}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
"""Single account execution coordinator and trusted Hermes runs transport.
|
||||||
|
|
||||||
|
Restart recovery reattaches by upstream run ID; admission retries use the exact
|
||||||
|
persisted payload and stable idempotency key. No transcript/session replay occurs.
|
||||||
|
Revocation cancels the initiating session's execution (including room grants).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .accounts import token_hash
|
||||||
|
|
||||||
|
TERMINAL = {'completed', 'failed', 'cancelled'}
|
||||||
|
|
||||||
|
|
||||||
|
class HermesRuns:
|
||||||
|
"""Credentials obtained exclusively from trusted Accounts rows."""
|
||||||
|
available = True
|
||||||
|
approvals = True
|
||||||
|
|
||||||
|
def __init__(self, accounts, client=None):
|
||||||
|
self.accounts = accounts
|
||||||
|
self.client = client or httpx.AsyncClient(timeout=httpx.Timeout(30, read=60), follow_redirects=False)
|
||||||
|
self.owns_client = client is None
|
||||||
|
|
||||||
|
async def connection(self, owner):
|
||||||
|
user = await asyncio.to_thread(self.accounts.get, owner)
|
||||||
|
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
|
||||||
|
raise RuntimeError('Account unavailable')
|
||||||
|
root, key = user.get('hermes_url'), user.get('hermes_api_key')
|
||||||
|
parsed = urlsplit(root or '')
|
||||||
|
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or not key or '\n' in key or '\r' in key:
|
||||||
|
raise RuntimeError('Invalid trusted connection')
|
||||||
|
root = root.rstrip('/')
|
||||||
|
if not root.endswith('/v1'):
|
||||||
|
root += '/v1'
|
||||||
|
return root, {'Authorization': f'Bearer {key}', 'X-Hermes-Session-Key': f'hope:{owner}'}
|
||||||
|
|
||||||
|
async def request(self, owner, method, path, **kwargs):
|
||||||
|
root, headers = await self.connection(owner)
|
||||||
|
headers.update(kwargs.pop('headers', {}))
|
||||||
|
response = await self.client.request(method, root + path, headers=headers, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
if len(response.content) > 2 * 1024 * 1024:
|
||||||
|
raise RuntimeError('Response too large')
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def start(self, owner, rid, payload):
|
||||||
|
return await self.request(owner, 'POST', '/runs', json=payload, headers={'Idempotency-Key': rid})
|
||||||
|
|
||||||
|
async def status(self, owner, upstream):
|
||||||
|
return await self.request(owner, 'GET', f'/runs/{upstream}')
|
||||||
|
|
||||||
|
async def stop(self, owner, upstream):
|
||||||
|
return await self.request(owner, 'POST', f'/runs/{upstream}/stop')
|
||||||
|
|
||||||
|
async def approve(self, owner, upstream, approval_id, decision):
|
||||||
|
return await self.request(owner, 'POST', f'/runs/{upstream}/approval', json={'request_id': approval_id, 'choice': 'once' if decision == 'approve' else 'deny'})
|
||||||
|
|
||||||
|
async def events(self, owner, upstream):
|
||||||
|
root, headers = await self.connection(owner)
|
||||||
|
headers['Accept'] = 'text/event-stream'
|
||||||
|
async with self.client.stream('GET', root + f'/runs/{upstream}/events', headers=headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
lines, size = [], 0
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
if lines:
|
||||||
|
value = json.loads('\n'.join(lines))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
yield value
|
||||||
|
lines, size = [], 0
|
||||||
|
elif line.startswith('data:'):
|
||||||
|
size += len(line)
|
||||||
|
if size > 1024 * 1024:
|
||||||
|
raise RuntimeError('Event too large')
|
||||||
|
lines.append(line[5:].lstrip())
|
||||||
|
if lines:
|
||||||
|
value = json.loads('\n'.join(lines))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
yield value
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
if self.owns_client:
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRuns:
|
||||||
|
def __init__(self, accounts, store, adapter=None, feature_store=None):
|
||||||
|
self.accounts, self.store = accounts, store
|
||||||
|
self.feature_store = feature_store
|
||||||
|
self.feature_provider = None
|
||||||
|
self.adapter = adapter or HermesRuns(accounts)
|
||||||
|
self.locks = defaultdict(asyncio.Lock)
|
||||||
|
self.admission_locks = defaultdict(asyncio.Lock)
|
||||||
|
self.tasks = {}
|
||||||
|
self.confirmed_stops = set()
|
||||||
|
self.task_owners = {}
|
||||||
|
self.closed = False
|
||||||
|
with accounts.lock, accounts.db:
|
||||||
|
accounts.db.execute('''CREATE TABLE IF NOT EXISTS chat_transport (
|
||||||
|
run_id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
conversation_id TEXT NOT NULL, metadata TEXT NOT NULL)''')
|
||||||
|
accounts.db.execute('''CREATE TABLE IF NOT EXISTS chat_operation_cancellations (
|
||||||
|
owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
conversation_id TEXT NOT NULL, operation_key TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(owner,conversation_id,operation_key))''')
|
||||||
|
|
||||||
|
def capabilities(self):
|
||||||
|
enabled = bool(getattr(self.adapter, 'available', True))
|
||||||
|
return {'conversations': True, 'workspaces': True, 'preferences': True,
|
||||||
|
'search': True, 'branching': True, 'sync': True, 'text_chat': enabled,
|
||||||
|
'durable_runs': enabled, 'cancellation': enabled,
|
||||||
|
'approvals': enabled and bool(getattr(self.adapter, 'approvals', False)),
|
||||||
|
'attachments': self.feature_store is not None, 'artifacts': False, 'tasks': False,
|
||||||
|
'memory': False, 'research': False, 'voice_chat': enabled, 'temporary_chat': False}
|
||||||
|
|
||||||
|
def metadata(self, owner, rid, value=None):
|
||||||
|
with self.accounts.lock, self.accounts.db:
|
||||||
|
if value is not None:
|
||||||
|
existing = self.accounts.db.execute('SELECT metadata FROM chat_transport WHERE owner=? AND run_id=?', (owner, rid)).fetchone()
|
||||||
|
if existing:
|
||||||
|
prior = json.loads(existing[0])
|
||||||
|
for flag in ('stop_requested', 'storage_failed', 'remote_terminal'):
|
||||||
|
if prior.get(flag):
|
||||||
|
value[flag] = True
|
||||||
|
self.accounts.db.execute('INSERT INTO chat_transport VALUES(?,?,?,?) ON CONFLICT(run_id) DO UPDATE SET metadata=excluded.metadata',
|
||||||
|
(rid, owner, value['conversation_id'], json.dumps(value, ensure_ascii=False)))
|
||||||
|
row = self.accounts.db.execute('SELECT metadata FROM chat_transport WHERE owner=? AND run_id=?', (owner, rid)).fetchone()
|
||||||
|
return json.loads(row[0]) if row else None
|
||||||
|
|
||||||
|
def operation_cancelled(self, owner, cid, key):
|
||||||
|
with self.accounts.lock:
|
||||||
|
return self.accounts.db.execute('SELECT 1 FROM chat_operation_cancellations WHERE owner=? AND conversation_id=? AND operation_key=?', (owner, cid, key)).fetchone() is not None
|
||||||
|
|
||||||
|
async def cancel_operation(self, owner, cid, key):
|
||||||
|
if not isinstance(key, str) or not 1 <= len(key) <= 200 or '\x00' in key:
|
||||||
|
raise HTTPException(400, 'Invalid operation key')
|
||||||
|
async with self.admission_locks[owner]:
|
||||||
|
await self.call('get_conversation', owner, cid)
|
||||||
|
def remember():
|
||||||
|
with self.accounts.lock, self.accounts.db:
|
||||||
|
existing = self.accounts.db.execute('SELECT 1 FROM chat_operation_cancellations WHERE owner=? AND conversation_id=? AND operation_key=?', (owner, cid, key)).fetchone()
|
||||||
|
if not existing and self.accounts.db.execute('SELECT count(*) FROM chat_operation_cancellations WHERE owner=?', (owner,)).fetchone()[0] >= 10000:
|
||||||
|
raise HTTPException(429, 'Operation cancellation limit reached')
|
||||||
|
self.accounts.db.execute('INSERT OR IGNORE INTO chat_operation_cancellations VALUES(?,?,?)', (owner, cid, key))
|
||||||
|
row = self.accounts.db.execute('SELECT run_id FROM chat_idempotency WHERE owner=? AND conversation_id=? AND key=?', (owner, cid, key)).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
rid = await asyncio.to_thread(remember)
|
||||||
|
if rid:
|
||||||
|
return {'ok': True, 'run': await self.cancel(owner, rid)}
|
||||||
|
return {'ok': True, 'run': None}
|
||||||
|
|
||||||
|
def authorized(self, owner, metadata):
|
||||||
|
with self.accounts.lock:
|
||||||
|
if metadata.get('room'):
|
||||||
|
user = self.accounts.resolve_room(metadata['room'])
|
||||||
|
return bool(user and user['id'] == owner and user.get('conversation_id') == metadata['conversation_id'])
|
||||||
|
row = self.accounts.db.execute("SELECT 1 FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.hash=? AND s.user_id=? AND s.expires_at>? AND u.desired!='deleted'", (metadata.get('session_hash'), owner, time.time())).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
async def call(self, method, *args, **kwargs):
|
||||||
|
return await asyncio.to_thread(getattr(self.store, method), *args, **kwargs)
|
||||||
|
|
||||||
|
async def send(self, owner, cid, session_token, *, content, idempotency_key, attachment_ids=None, room=None, mode='normal'):
|
||||||
|
from .chat_store import ChatConflict
|
||||||
|
if mode not in ('normal', 'research'):
|
||||||
|
raise HTTPException(400, 'Invalid chat mode')
|
||||||
|
if mode == 'research':
|
||||||
|
if self.feature_provider is None or not (await self.feature_provider.feature_capabilities(owner)).get('research'):
|
||||||
|
raise HTTPException(409, 'Research is unavailable')
|
||||||
|
parts = None
|
||||||
|
if attachment_ids:
|
||||||
|
if self.feature_store is None:
|
||||||
|
raise HTTPException(400, 'Attachments are not enabled')
|
||||||
|
vision = False
|
||||||
|
if self.feature_provider is not None:
|
||||||
|
qualified = await self.feature_provider.feature_capabilities(owner)
|
||||||
|
vision = qualified.get('image_input') is True
|
||||||
|
parts = await self.feature_store.provider_content(owner, attachment_ids, vision=vision)
|
||||||
|
if not self.capabilities()['text_chat']:
|
||||||
|
raise HTTPException(503, 'Chat unavailable')
|
||||||
|
operation = None
|
||||||
|
try:
|
||||||
|
# Coordinate payload admission with execution and recovery metadata.
|
||||||
|
async with self.admission_locks[owner]:
|
||||||
|
def prior_exists():
|
||||||
|
with self.accounts.lock:
|
||||||
|
return self.accounts.db.execute('SELECT 1 FROM chat_idempotency WHERE owner=? AND conversation_id=? AND key=?', (owner, cid, idempotency_key)).fetchone() is not None
|
||||||
|
for done_id in [key for key, task in self.tasks.items() if task.done()]:
|
||||||
|
self.tasks.pop(done_id, None)
|
||||||
|
self.task_owners.pop(done_id, None)
|
||||||
|
if not await asyncio.to_thread(prior_exists):
|
||||||
|
if len(self.tasks) >= 256 or sum(self.task_owners.get(rid) == owner for rid in self.tasks) >= 8:
|
||||||
|
raise HTTPException(429, 'Chat queue full')
|
||||||
|
operation = await self.call('send', owner, cid, content, idempotency_key, attachment_ids, mode=mode)
|
||||||
|
rid = operation['run']['id']
|
||||||
|
if await asyncio.to_thread(self.operation_cancelled, owner, cid, idempotency_key):
|
||||||
|
if operation['run']['status'] not in TERMINAL:
|
||||||
|
operation['run'] = await self.cancel(owner, rid)
|
||||||
|
return operation
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if metadata is None:
|
||||||
|
settings = await asyncio.to_thread(self.accounts.get_model_settings, owner)
|
||||||
|
session_state = await self.call('get_session', owner, cid)
|
||||||
|
session = session_state['hermes_session_id'] or 'hope-' + cid
|
||||||
|
seed_history = None
|
||||||
|
if not session_state['hermes_seeded']:
|
||||||
|
def copied_history():
|
||||||
|
with self.accounts.lock:
|
||||||
|
return [{'role': row['role'], 'content': row['content']} for row in self.accounts.db.execute("SELECT role,content FROM chat_messages WHERE owner=? AND conversation_id=? AND run_id IS NULL AND status='completed' ORDER BY position", (owner, cid))]
|
||||||
|
seed_history = await asyncio.to_thread(copied_history)
|
||||||
|
if len(seed_history) > 200 or len(json.dumps(seed_history).encode()) > 256000:
|
||||||
|
raise HTTPException(409, 'Branch context exceeds the supported history limit')
|
||||||
|
await self.call('set_session', owner, cid, session, False)
|
||||||
|
user_input = content if not parts else [{'role': 'user', 'content': [{'type': 'text', 'text': content}, *parts]}]
|
||||||
|
payload = {'input': user_input, 'session_id': session, 'model': settings['model'],
|
||||||
|
'provider': 'custom:hope-upstream',
|
||||||
|
'model_options': {'reasoning': {'enabled': False} if settings['thinking_level'] == 'none' else {'enabled': True, 'effort': settings['thinking_level']}},
|
||||||
|
'instructions': 'Respond concisely for spoken conversation. Avoid markdown.' if room else 'Respond helpfully to a text chat. Use readable markdown when useful.'}
|
||||||
|
if mode == 'research':
|
||||||
|
payload['instructions'] += '\n' + await self.feature_provider.research_instruction(owner)
|
||||||
|
conversation = (await self.call('get_conversation', owner, cid))['conversation']
|
||||||
|
if conversation['workspace_id']:
|
||||||
|
workspace = await self.call('get_workspace', owner, conversation['workspace_id'])
|
||||||
|
payload['instructions'] += '\nWorkspace instructions:\n' + workspace['instructions']
|
||||||
|
if seed_history:
|
||||||
|
payload['conversation_history'] = seed_history
|
||||||
|
metadata = {'conversation_id': cid, 'payload': payload,
|
||||||
|
'session_hash': token_hash(session_token) if session_token else None,
|
||||||
|
'room': room, 'upstream_id': None, 'assistant_id': None}
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
if operation['run']['status'] not in TERMINAL:
|
||||||
|
self.schedule(owner, rid)
|
||||||
|
return operation
|
||||||
|
except HTTPException:
|
||||||
|
if operation and await asyncio.to_thread(self.metadata, owner, operation['run']['id']) is None:
|
||||||
|
rid = operation['run']['id']
|
||||||
|
run = await self.set_status(owner, rid, 'failed', 'Chat admission could not be prepared. Start a new request.')
|
||||||
|
await self.emit(owner, rid, 'done', {'run': run})
|
||||||
|
raise
|
||||||
|
except ChatConflict:
|
||||||
|
if operation and await asyncio.to_thread(self.metadata, owner, operation['run']['id']) is None:
|
||||||
|
run = await self.set_status(owner, operation['run']['id'], 'failed', 'Chat admission could not be prepared. Start a new request.')
|
||||||
|
await self.emit(owner, operation['run']['id'], 'done', {'run': run})
|
||||||
|
raise HTTPException(409, 'Idempotency or operation conflict') from None
|
||||||
|
except KeyError:
|
||||||
|
if operation and await asyncio.to_thread(self.metadata, owner, operation['run']['id']) is None:
|
||||||
|
try:
|
||||||
|
run = await self.set_status(owner, operation['run']['id'], 'failed', 'Chat admission could not be prepared. Start a new request.')
|
||||||
|
await self.emit(owner, operation['run']['id'], 'done', {'run': run})
|
||||||
|
except KeyError:
|
||||||
|
pass # Owner/conversation was concurrently removed.
|
||||||
|
raise HTTPException(404, 'Chat resource not found') from None
|
||||||
|
except ValueError:
|
||||||
|
if operation and await asyncio.to_thread(self.metadata, owner, operation['run']['id']) is None:
|
||||||
|
run = await self.set_status(owner, operation['run']['id'], 'failed', 'Chat admission could not be prepared. Start a new request.')
|
||||||
|
await self.emit(owner, operation['run']['id'], 'done', {'run': run})
|
||||||
|
raise HTTPException(400, 'Invalid chat request') from None
|
||||||
|
except Exception:
|
||||||
|
if operation and await asyncio.to_thread(self.metadata, owner, operation['run']['id']) is None:
|
||||||
|
run = await self.set_status(owner, operation['run']['id'], 'failed', 'Chat admission could not be prepared. Start a new request.')
|
||||||
|
await self.emit(owner, operation['run']['id'], 'done', {'run': run})
|
||||||
|
raise HTTPException(503, 'Chat admission unavailable') from None
|
||||||
|
|
||||||
|
def schedule(self, owner, rid):
|
||||||
|
if not self.closed and (rid not in self.tasks or self.tasks[rid].done()):
|
||||||
|
self.task_owners[rid] = owner
|
||||||
|
self.tasks[rid] = asyncio.create_task(self.execute(owner, rid))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
def rows():
|
||||||
|
with self.accounts.lock:
|
||||||
|
return [(r['owner'], r['run_id']) for r in self.accounts.db.execute('SELECT owner,run_id FROM chat_transport')]
|
||||||
|
known = await asyncio.to_thread(rows)
|
||||||
|
known_ids = {rid for _, rid in known}
|
||||||
|
for run in await self.call('active_runs'):
|
||||||
|
if run['id'] not in known_ids:
|
||||||
|
await self.set_status(run['owner'], run['id'], 'failed', 'Chat admission interrupted. Start a new request.')
|
||||||
|
await self.emit(run['owner'], run['id'], 'done', {'run': (await self.call('get_run', run['owner'], run['id']))['run']})
|
||||||
|
for owner, rid in known:
|
||||||
|
try:
|
||||||
|
detail = await self.call('get_run', owner, rid)
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if detail['run']['status'] not in TERMINAL or (metadata.get('stop_requested') and metadata.get('upstream_id') and not metadata.get('remote_terminal')):
|
||||||
|
self.schedule(owner, rid)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def emit(self, owner, rid, event, data):
|
||||||
|
try:
|
||||||
|
await self.call('append_event', owner, rid, event, data)
|
||||||
|
except ValueError:
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if metadata:
|
||||||
|
metadata['storage_failed'] = True
|
||||||
|
metadata['stop_requested'] = True
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def set_status(self, owner, rid, status, error=None):
|
||||||
|
current = (await self.call('get_run', owner, rid))['run']
|
||||||
|
if current['status'] in TERMINAL:
|
||||||
|
return current
|
||||||
|
run = await self.call('update_run', owner, rid, status, error=error)
|
||||||
|
await self.emit(owner, rid, 'run', {'run': run})
|
||||||
|
return run
|
||||||
|
|
||||||
|
async def finish(self, owner, rid, metadata, status, content=None, error=None):
|
||||||
|
detail = await self.call('get_run', owner, rid)
|
||||||
|
if detail['run']['status'] in TERMINAL:
|
||||||
|
return detail['run']
|
||||||
|
if metadata.get('assistant_id'):
|
||||||
|
message = await self.call('update_message', owner, metadata['assistant_id'], content=content, status=status)
|
||||||
|
await self.emit(owner, rid, 'message', {'message': message})
|
||||||
|
elif content is not None and status == 'completed':
|
||||||
|
message = await self.call('add_message', owner, metadata['conversation_id'], 'assistant', content, status='completed', run_id=rid)
|
||||||
|
await self.emit(owner, rid, 'message', {'message': message})
|
||||||
|
run = await self.set_status(owner, rid, status, error)
|
||||||
|
await self.emit(owner, rid, 'done', {'run': run})
|
||||||
|
return run
|
||||||
|
|
||||||
|
async def monitor_authorization(self, owner, rid, metadata):
|
||||||
|
while True:
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if not await asyncio.to_thread(self.authorized, owner, metadata):
|
||||||
|
await self.cancel(owner, rid)
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if metadata.get('stop_requested') and metadata.get('upstream_id'):
|
||||||
|
try:
|
||||||
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
||||||
|
state = await self.adapter.status(owner, metadata['upstream_id'])
|
||||||
|
if await self.consume_status(owner, rid, metadata, state):
|
||||||
|
self.confirmed_stops.add(rid)
|
||||||
|
task = self.tasks.get(rid)
|
||||||
|
if task and task is not asyncio.current_task():
|
||||||
|
task.cancel()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(.25)
|
||||||
|
|
||||||
|
async def execute(self, owner, rid):
|
||||||
|
watcher = None
|
||||||
|
try:
|
||||||
|
async with self.locks[owner]:
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
detail = await self.call('get_run', owner, rid)
|
||||||
|
if not metadata or (detail['run']['status'] in TERMINAL and not (metadata.get('stop_requested') and metadata.get('upstream_id') and not metadata.get('remote_terminal'))):
|
||||||
|
return
|
||||||
|
if not await asyncio.to_thread(self.authorized, owner, metadata):
|
||||||
|
if not metadata.get('upstream_id'):
|
||||||
|
await self.finish(owner, rid, metadata, 'cancelled')
|
||||||
|
return
|
||||||
|
metadata['stop_requested'] = True
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
watcher = asyncio.create_task(self.monitor_authorization(owner, rid, metadata))
|
||||||
|
if not metadata['upstream_id']:
|
||||||
|
if not metadata.get('submission_attempted'):
|
||||||
|
session_state = await self.call('get_session', owner, metadata['conversation_id'])
|
||||||
|
if session_state['hermes_seeded']:
|
||||||
|
metadata['payload'].pop('conversation_history', None)
|
||||||
|
metadata['submission_attempted'] = True
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
started = await self.adapter.start(owner, rid, metadata['payload'])
|
||||||
|
upstream = started.get('run_id')
|
||||||
|
if not isinstance(upstream, str) or not upstream or len(upstream) > 200 or not all(c.isalnum() or c in '-_' for c in upstream):
|
||||||
|
raise RuntimeError('Invalid upstream run')
|
||||||
|
metadata['upstream_id'] = upstream
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
session_id = metadata['payload'].get('session_id')
|
||||||
|
if session_id:
|
||||||
|
await self.call('set_session', owner, metadata['conversation_id'], session_id, True)
|
||||||
|
break
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code in (400, 401, 403, 404, 409, 422):
|
||||||
|
raise RuntimeError('Chat admission rejected') from None
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except (httpx.HTTPError, RuntimeError):
|
||||||
|
# Unknown outcome: retain account slot, retry exact
|
||||||
|
# durable payload/key, never create a second action.
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
current = await self.call('get_run', owner, rid)
|
||||||
|
if current['run']['status'] in TERMINAL or not await asyncio.to_thread(self.authorized, owner, metadata):
|
||||||
|
metadata['stop_requested'] = True
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
try:
|
||||||
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Keep ownership of the execution slot until remote terminal.
|
||||||
|
else:
|
||||||
|
await self.set_status(owner, rid, 'running')
|
||||||
|
# Reconnect only this single queue-consuming relay; never expose upstream SSE.
|
||||||
|
while True:
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
try:
|
||||||
|
if metadata.get('stop_requested'):
|
||||||
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
||||||
|
state = await self.adapter.status(owner, metadata['upstream_id'])
|
||||||
|
if await self.consume_status(owner, rid, metadata, state):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
async for event in self.adapter.events(owner, metadata['upstream_id']):
|
||||||
|
current = await self.call('get_run', owner, rid)
|
||||||
|
if current['run']['status'] in TERMINAL:
|
||||||
|
if await self.consume_status(owner, rid, metadata, {'status': event.get('event', '').removeprefix('run.'), 'output': event.get('output')}):
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
latest = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if latest.get('storage_failed'):
|
||||||
|
metadata = latest
|
||||||
|
continue
|
||||||
|
if await self.consume_event(owner, rid, metadata, event):
|
||||||
|
return
|
||||||
|
state = await self.adapter.status(owner, metadata['upstream_id'])
|
||||||
|
if await self.consume_status(owner, rid, metadata, state):
|
||||||
|
return
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if rid in self.confirmed_stops:
|
||||||
|
self.confirmed_stops.discard(rid)
|
||||||
|
return
|
||||||
|
# Shutdown detaches; durable metadata is recovered on startup.
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
await self.finish(owner, rid, metadata or {}, 'failed', error='Chat execution unavailable. Try again.')
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if watcher:
|
||||||
|
watcher.cancel()
|
||||||
|
await asyncio.gather(watcher, return_exceptions=True)
|
||||||
|
|
||||||
|
async def consume_status(self, owner, rid, metadata, state):
|
||||||
|
status = state.get('status')
|
||||||
|
if status in ('completed', 'failed', 'cancelled', 'interrupted'):
|
||||||
|
metadata['remote_terminal'] = True
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
if (await self.call('get_run', owner, rid))['run']['status'] in TERMINAL:
|
||||||
|
return True
|
||||||
|
if status in ('completed', 'failed', 'cancelled', 'interrupted') and metadata.get('storage_failed'):
|
||||||
|
if metadata.get('assistant_id'):
|
||||||
|
await self.call('update_message', owner, metadata['assistant_id'], status='failed')
|
||||||
|
run = await self.set_status(owner, rid, 'failed', 'Chat storage limit reached.')
|
||||||
|
await self.emit(owner, rid, 'done', {'run': run})
|
||||||
|
return True
|
||||||
|
if status == 'waiting_for_approval':
|
||||||
|
approval = state.get('approval')
|
||||||
|
if isinstance(approval, dict) and isinstance(approval.get('request_id'), str):
|
||||||
|
await self.consume_event(owner, rid, metadata, {'event': 'approval.request', 'request_id': approval['request_id']})
|
||||||
|
if status == 'completed':
|
||||||
|
output = state.get('output')
|
||||||
|
if not isinstance(output, str) or not output.strip():
|
||||||
|
raise RuntimeError('Invalid final output')
|
||||||
|
await self.finish(owner, rid, metadata, 'completed', content=output[:32000])
|
||||||
|
return True
|
||||||
|
if status in ('failed', 'cancelled', 'interrupted'):
|
||||||
|
await self.finish(owner, rid, metadata, 'cancelled' if status == 'cancelled' else 'failed', error=None if status == 'cancelled' else 'Chat execution failed.')
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def consume_event(self, owner, rid, metadata, event):
|
||||||
|
name = event.get('event')
|
||||||
|
if name == 'message.delta':
|
||||||
|
delta = event.get('delta')
|
||||||
|
if isinstance(delta, str) and delta:
|
||||||
|
if not metadata.get('assistant_id'):
|
||||||
|
message = await self.call('add_message', owner, metadata['conversation_id'], 'assistant', '', status='streaming', run_id=rid)
|
||||||
|
metadata['assistant_id'] = message['id']
|
||||||
|
metadata['content'] = ''
|
||||||
|
metadata['content'] = (metadata.get('content', '') + delta)[:32000]
|
||||||
|
now = time.monotonic()
|
||||||
|
if len(metadata['content']) - metadata.get('emitted_length', 0) >= 256 or now - metadata.get('emitted_at', 0) >= .1:
|
||||||
|
metadata['emitted_at'] = now
|
||||||
|
metadata['emitted_length'] = len(metadata['content'])
|
||||||
|
message = await self.call('update_message', owner, metadata['assistant_id'], content=metadata['content'], status='streaming')
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
await self.emit(owner, rid, 'message', {'message': message})
|
||||||
|
elif name == 'approval.request':
|
||||||
|
approval_id = event.get('request_id')
|
||||||
|
if isinstance(approval_id, str) and 0 < len(approval_id) <= 200:
|
||||||
|
metadata['approval_id'] = approval_id
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
await self.set_status(owner, rid, 'awaiting_approval')
|
||||||
|
await self.emit(owner, rid, 'approval', {'approval_id': approval_id, 'prompt': 'Hermes requests permission to execute an action.'})
|
||||||
|
elif name in ('tool.started', 'tool.completed', 'run.started'):
|
||||||
|
await self.emit(owner, rid, 'progress', {'status': 'working', 'message': 'Working on your request.'})
|
||||||
|
elif name in ('run.completed', 'run.failed', 'run.cancelled'):
|
||||||
|
return await self.consume_status(owner, rid, metadata, {'status': name.split('.')[1], 'output': event.get('output')})
|
||||||
|
# Unknown/reasoning/raw tools deliberately never enter the product stream.
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def cancel(self, owner, rid):
|
||||||
|
try:
|
||||||
|
detail = await self.call('get_run', owner, rid)
|
||||||
|
if detail['run']['status'] in TERMINAL:
|
||||||
|
return detail['run']
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
if metadata:
|
||||||
|
metadata['stop_requested'] = True
|
||||||
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
||||||
|
if metadata.get('upstream_id'):
|
||||||
|
try:
|
||||||
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
||||||
|
except Exception:
|
||||||
|
pass # Durable intent remains; never claim remote success.
|
||||||
|
return detail['run']
|
||||||
|
# Queued/not-yet-admitted runs are safe to cancel locally. An admission
|
||||||
|
# in flight rechecks this terminal projection and stops its returned ID.
|
||||||
|
return await self.finish(owner, rid, metadata or {}, 'cancelled')
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, 'Run not found') from None
|
||||||
|
|
||||||
|
async def cancel_conversation(self, owner, cid):
|
||||||
|
from .chat_store import ChatConflict
|
||||||
|
detail = await self.call('get_conversation', owner, cid)
|
||||||
|
pending = False
|
||||||
|
for run in detail['runs']:
|
||||||
|
if run['status'] not in TERMINAL:
|
||||||
|
result = await self.cancel(owner, run['id'])
|
||||||
|
pending |= result['status'] not in TERMINAL
|
||||||
|
if pending:
|
||||||
|
raise ChatConflict('Remote cancellation is pending')
|
||||||
|
|
||||||
|
async def approval(self, owner, rid, approval_id, decision):
|
||||||
|
if decision not in ('approve', 'deny') or not isinstance(approval_id, str) or not approval_id or len(approval_id) > 200:
|
||||||
|
raise HTTPException(400, 'Invalid approval')
|
||||||
|
if not self.capabilities()['approvals']:
|
||||||
|
raise HTTPException(409, 'Approvals unavailable')
|
||||||
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
||||||
|
try:
|
||||||
|
detail = await self.call('get_run', owner, rid)
|
||||||
|
if not metadata or detail['run']['status'] != 'awaiting_approval' or metadata.get('approval_id') != approval_id:
|
||||||
|
raise HTTPException(409, 'Approval no longer pending')
|
||||||
|
await self.adapter.approve(owner, metadata['upstream_id'], approval_id, decision)
|
||||||
|
return await self.set_status(owner, rid, 'running')
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, 'Run not found') from None
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(503, 'Approval unavailable') from None
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self.closed = True
|
||||||
|
tasks = list(self.tasks.values())
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if hasattr(self.adapter, 'aclose'):
|
||||||
|
await self.adapter.aclose()
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
"""Synchronous owner-scoped SQLite projections; routes must offload calls.
|
||||||
|
|
||||||
|
Missing/foreign objects raise KeyError; invalid input ValueError; conflicting
|
||||||
|
revision, idempotency payload or terminal transition ChatConflict. Branch with
|
||||||
|
edited user content copies BEFORE that user, without submitting replacement:
|
||||||
|
caller must send the edited turn. Otherwise copy through the selected message.
|
||||||
|
Only completed role/content history seeds a fresh Hermes session; no tools rerun.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConflict(ValueError):
|
||||||
|
"""Optimistic revision, idempotency payload or state conflict (HTTP 409)."""
|
||||||
|
|
||||||
|
|
||||||
|
def text(value, name, maximum, empty=False):
|
||||||
|
if not isinstance(value, str) or len(value) > maximum or (not empty and not value.strip()) or '\x00' in value:
|
||||||
|
raise ValueError(f'Invalid {name}')
|
||||||
|
try:
|
||||||
|
if len(value.encode('utf-8')) > maximum * 4:
|
||||||
|
raise ValueError(f'Invalid {name}')
|
||||||
|
except UnicodeError as exc:
|
||||||
|
raise ValueError(f'Invalid {name}') from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def integer(value, name, minimum=0, maximum=2**63-1):
|
||||||
|
if type(value) is not int or not minimum <= value <= maximum:
|
||||||
|
raise ValueError(f'Invalid {name}')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStore:
|
||||||
|
TERMINAL = {'completed', 'failed', 'cancelled'}
|
||||||
|
|
||||||
|
def __init__(self, accounts):
|
||||||
|
self.accounts, self.db, self.lock = accounts, accounts.db, accounts.lock
|
||||||
|
with self.lock, self.db:
|
||||||
|
self.db.execute('PRAGMA foreign_keys=ON')
|
||||||
|
self.db.executescript('''
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_workspaces (
|
||||||
|
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL, instructions TEXT NOT NULL, revision INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL, updated_at REAL NOT NULL, UNIQUE(id,owner));
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||||
|
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL, workspace_id TEXT, pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pin_rank INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
temporary INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL, updated_at REAL NOT NULL,
|
||||||
|
hermes_session_id TEXT, hermes_seeded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(id,owner), FOREIGN KEY(workspace_id,owner) REFERENCES chat_workspaces(id,owner));
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id TEXT PRIMARY KEY, owner TEXT NOT NULL, conversation_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('user','assistant')), content TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, run_id TEXT, created_at REAL NOT NULL,
|
||||||
|
position INTEGER NOT NULL, UNIQUE(conversation_id,position),
|
||||||
|
FOREIGN KEY(conversation_id,owner) REFERENCES chat_conversations(id,owner) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(run_id,owner) REFERENCES chat_runs(id,owner) ON DELETE CASCADE);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_runs (
|
||||||
|
id TEXT PRIMARY KEY, owner TEXT NOT NULL, conversation_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, message_id TEXT NOT NULL, created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL, error TEXT, UNIQUE(id,owner),
|
||||||
|
FOREIGN KEY(conversation_id,owner) REFERENCES chat_conversations(id,owner) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(message_id) REFERENCES chat_messages(id) DEFERRABLE INITIALLY DEFERRED);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_idempotency (
|
||||||
|
owner TEXT NOT NULL, conversation_id TEXT NOT NULL, key TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL, message_id TEXT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||||
|
run_id TEXT NOT NULL REFERENCES chat_runs(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY(owner,conversation_id,key),
|
||||||
|
FOREIGN KEY(conversation_id,owner) REFERENCES chat_conversations(id,owner) ON DELETE CASCADE);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_events (
|
||||||
|
owner TEXT NOT NULL, run_id TEXT NOT NULL, sequence INTEGER NOT NULL,
|
||||||
|
event TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(run_id,sequence),
|
||||||
|
FOREIGN KEY(run_id,owner) REFERENCES chat_runs(id,owner) ON DELETE CASCADE);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_preferences (
|
||||||
|
owner TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
palette TEXT NOT NULL, appearance TEXT NOT NULL, revision INTEGER NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_changes (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL, object_id TEXT NOT NULL, deleted INTEGER NOT NULL,
|
||||||
|
data TEXT, created_at REAL NOT NULL);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_changes_owner ON chat_changes(owner,sequence);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_conversation_owner ON chat_conversations(owner,updated_at,id);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_message_order ON chat_messages(conversation_id,position);
|
||||||
|
''')
|
||||||
|
columns = {r[1] for r in self.db.execute('PRAGMA table_info(chat_conversations)')}
|
||||||
|
for column, definition in [('hermes_session_id', 'TEXT'), ('hermes_seeded', 'INTEGER NOT NULL DEFAULT 0')]:
|
||||||
|
if column not in columns:
|
||||||
|
self.db.execute(f'ALTER TABLE chat_conversations ADD COLUMN {column} {definition}')
|
||||||
|
if 'attachments' not in {r[1] for r in self.db.execute('PRAGMA table_info(chat_messages)')}:
|
||||||
|
self.db.execute("ALTER TABLE chat_messages ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
|
||||||
|
def _owner(self, owner):
|
||||||
|
text(owner, 'owner', 128)
|
||||||
|
if not self.db.execute("SELECT 1 FROM users WHERE id=? AND desired!='deleted'", (owner,)).fetchone():
|
||||||
|
raise KeyError(owner)
|
||||||
|
|
||||||
|
def _quota(self, table, owner, maximum):
|
||||||
|
if self.db.execute(f'SELECT count(*) FROM {table} WHERE owner=?',(owner,)).fetchone()[0]>=maximum:
|
||||||
|
raise ValueError('Account chat storage limit reached')
|
||||||
|
|
||||||
|
def _row(self, table, owner, identity):
|
||||||
|
self._owner(owner)
|
||||||
|
text(identity, 'id', 128)
|
||||||
|
row = self.db.execute(f'SELECT * FROM {table} WHERE owner=? AND id=?', (owner, identity)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(identity)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _conversation(row):
|
||||||
|
result = {k: row[k] for k in ('id','title','workspace_id','pinned','pin_rank','archived','temporary','revision','created_at','updated_at')}
|
||||||
|
for k in ('pinned', 'archived', 'temporary'):
|
||||||
|
result[k] = bool(result[k])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message(row):
|
||||||
|
return {**{k: row[k] for k in ('id','conversation_id','role','content','status','run_id','created_at')}, 'attachments': json.loads(row['attachments']) if 'attachments' in row.keys() else []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run(row):
|
||||||
|
return {k: row[k] for k in ('id','conversation_id','status','message_id','created_at','updated_at','error')}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _workspace(row):
|
||||||
|
return {k: row[k] for k in ('id','name','instructions','revision','created_at','updated_at')}
|
||||||
|
|
||||||
|
def _touch(self, owner, cid):
|
||||||
|
self.db.execute('UPDATE chat_conversations SET revision=revision+1,updated_at=? WHERE id=? AND owner=?',(time.time(),cid,owner))
|
||||||
|
self._change(owner,'conversation',cid,self._conversation(self._row('chat_conversations',owner,cid)))
|
||||||
|
|
||||||
|
def _change(self, owner, kind, identity, data=None):
|
||||||
|
self._quota('chat_changes',owner,100000)
|
||||||
|
self.db.execute('INSERT INTO chat_changes(owner,kind,object_id,deleted,data,created_at) VALUES(?,?,?,?,?,?)',
|
||||||
|
(owner, kind, identity, data is None, json.dumps(data) if data is not None else None, time.time()))
|
||||||
|
|
||||||
|
def create_conversation(self, owner, title='New chat', workspace_id=None, temporary=False):
|
||||||
|
text(title, 'title', 200)
|
||||||
|
if type(temporary) is not bool or temporary:
|
||||||
|
raise ValueError('Temporary conversations are not enabled')
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._owner(owner)
|
||||||
|
if workspace_id is not None:
|
||||||
|
self._row('chat_workspaces', owner, workspace_id)
|
||||||
|
self._quota('chat_conversations',owner,1000)
|
||||||
|
identity, now = uuid.uuid4().hex, time.time()
|
||||||
|
self.db.execute('INSERT INTO chat_conversations(id,owner,title,workspace_id,temporary,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,?,?)',
|
||||||
|
(identity, owner, title, workspace_id, temporary, now, now))
|
||||||
|
result = self._conversation(self._row('chat_conversations', owner, identity))
|
||||||
|
self._change(owner, 'conversation', identity, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cursor(cursor):
|
||||||
|
if cursor is None:
|
||||||
|
return 0
|
||||||
|
if not isinstance(cursor, str) or not cursor.isascii() or not cursor.isdigit() or len(cursor)>9:
|
||||||
|
raise ValueError('Invalid cursor')
|
||||||
|
return int(cursor)
|
||||||
|
|
||||||
|
def list_conversations(self, owner, q='', workspace_id=None, archived=False, cursor=None, limit=50):
|
||||||
|
text(q, 'search', 200, empty=True)
|
||||||
|
integer(limit, 'limit', 1, 200)
|
||||||
|
if type(archived) is not bool:
|
||||||
|
raise ValueError('Invalid archived')
|
||||||
|
offset = self._cursor(cursor)
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
clauses, params = ['owner=?', 'archived=?'], [owner, archived]
|
||||||
|
if workspace_id is not None:
|
||||||
|
self._row('chat_workspaces', owner, workspace_id)
|
||||||
|
clauses.append('workspace_id=?')
|
||||||
|
params.append(workspace_id)
|
||||||
|
if q:
|
||||||
|
clauses.append('(instr(lower(title),lower(?))>0 OR EXISTS (SELECT 1 FROM chat_messages m WHERE m.conversation_id=chat_conversations.id AND instr(lower(m.content),lower(?))>0))')
|
||||||
|
params.extend([q, q])
|
||||||
|
rows = list(self.db.execute('SELECT * FROM chat_conversations WHERE ' + ' AND '.join(clauses) + ' ORDER BY pinned DESC,pin_rank,updated_at DESC,id LIMIT ? OFFSET ?', (*params, limit+1, offset)))
|
||||||
|
return {'conversations': [self._conversation(r) for r in rows[:limit]], 'next_cursor': str(offset+limit) if len(rows)>limit else None}
|
||||||
|
|
||||||
|
def get_conversation(self, owner, cid, cursor=None, limit=100):
|
||||||
|
integer(limit, 'limit', 1, 500)
|
||||||
|
offset = self._cursor(cursor)
|
||||||
|
with self.lock:
|
||||||
|
conversation = self._conversation(self._row('chat_conversations', owner, cid))
|
||||||
|
if cursor is None:
|
||||||
|
rows = list(self.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? ORDER BY position DESC LIMIT ?', (owner,cid,limit+1)))
|
||||||
|
else:
|
||||||
|
rows = list(self.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? AND position<? ORDER BY position DESC LIMIT ?', (owner,cid,offset,limit+1)))
|
||||||
|
runs = [self._run(r) for r in self.db.execute('SELECT * FROM chat_runs WHERE owner=? AND conversation_id=? ORDER BY created_at,id', (owner,cid))]
|
||||||
|
return {'conversation': conversation, 'messages': [self._message(r) for r in reversed(rows[:limit])], 'runs': runs, 'next_cursor': str(rows[limit-1]['position']) if len(rows)>limit else None}
|
||||||
|
|
||||||
|
def update_conversation(self, owner, cid, revision, **fields):
|
||||||
|
integer(revision, 'revision', 1)
|
||||||
|
if not fields or not fields.keys() <= {'title','workspace_id','pinned','pin_rank','archived'}:
|
||||||
|
raise ValueError('Invalid conversation update')
|
||||||
|
for key,value in fields.items():
|
||||||
|
if key=='title': text(value, key, 200)
|
||||||
|
if key in ('pinned','archived') and type(value) is not bool: raise ValueError(f'Invalid {key}')
|
||||||
|
if key=='pin_rank': integer(value,key,0,1000000)
|
||||||
|
with self.lock, self.db:
|
||||||
|
row = self._row('chat_conversations',owner,cid)
|
||||||
|
if row['revision']!=revision: raise ChatConflict('Stale revision')
|
||||||
|
if fields.get('workspace_id') is not None: self._row('chat_workspaces',owner,fields['workspace_id'])
|
||||||
|
self.db.execute('UPDATE chat_conversations SET '+','.join(f'{k}=?' for k in fields)+',revision=revision+1,updated_at=? WHERE id=? AND owner=?', (*fields.values(),time.time(),cid,owner))
|
||||||
|
result = self._conversation(self._row('chat_conversations',owner,cid))
|
||||||
|
self._change(owner,'conversation',cid,result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_conversation(self, owner, cid):
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._row('chat_conversations',owner,cid)
|
||||||
|
if self.db.execute("SELECT 1 FROM chat_runs WHERE owner=? AND conversation_id=? AND status IN ('queued','running','awaiting_approval') LIMIT 1",(owner,cid)).fetchone():
|
||||||
|
raise ChatConflict('Execution still active')
|
||||||
|
if self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='chat_transport'").fetchone():
|
||||||
|
for row in self.db.execute('SELECT metadata FROM chat_transport WHERE owner=? AND conversation_id=?',(owner,cid)):
|
||||||
|
metadata=json.loads(row['metadata'])
|
||||||
|
if metadata.get('upstream_id') and not metadata.get('remote_terminal'):
|
||||||
|
raise ChatConflict('Remote execution still active')
|
||||||
|
self.db.execute('DELETE FROM chat_transport WHERE owner=? AND conversation_id=?',(owner,cid))
|
||||||
|
self.db.execute('DELETE FROM chat_conversations WHERE id=? AND owner=?',(cid,owner))
|
||||||
|
self._change(owner,'conversation',cid)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
def create_workspace(self, owner, name, instructions=''):
|
||||||
|
text(name,'name',100); text(instructions,'instructions',16000,empty=True)
|
||||||
|
with self.lock, self.db:
|
||||||
|
self._owner(owner)
|
||||||
|
self._quota('chat_workspaces',owner,100)
|
||||||
|
identity,now = uuid.uuid4().hex,time.time()
|
||||||
|
self.db.execute('INSERT INTO chat_workspaces VALUES(?,?,?,?,1,?,?)',(identity,owner,name,instructions,now,now))
|
||||||
|
result=self._workspace(self._row('chat_workspaces',owner,identity))
|
||||||
|
self._change(owner,'workspace',identity,result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def list_workspaces(self, owner):
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
return [self._workspace(r) for r in self.db.execute('SELECT * FROM chat_workspaces WHERE owner=? ORDER BY created_at,id',(owner,))]
|
||||||
|
|
||||||
|
def get_workspace(self, owner, wid):
|
||||||
|
with self.lock: return self._workspace(self._row('chat_workspaces',owner,wid))
|
||||||
|
|
||||||
|
def update_workspace(self, owner, wid, revision, **fields):
|
||||||
|
integer(revision,'revision',1)
|
||||||
|
if not fields or not fields.keys()<={'name','instructions'}: raise ValueError('Invalid workspace update')
|
||||||
|
for key,value in fields.items(): text(value,key,100 if key=='name' else 16000,empty=key=='instructions')
|
||||||
|
with self.lock,self.db:
|
||||||
|
row=self._row('chat_workspaces',owner,wid)
|
||||||
|
if row['revision']!=revision: raise ChatConflict('Stale revision')
|
||||||
|
self.db.execute('UPDATE chat_workspaces SET '+','.join(f'{k}=?' for k in fields)+',revision=revision+1,updated_at=? WHERE id=? AND owner=?',(*fields.values(),time.time(),wid,owner))
|
||||||
|
result=self._workspace(self._row('chat_workspaces',owner,wid)); self._change(owner,'workspace',wid,result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_workspace(self, owner, wid):
|
||||||
|
with self.lock,self.db:
|
||||||
|
self._row('chat_workspaces',owner,wid)
|
||||||
|
ids=[r[0] for r in self.db.execute('SELECT id FROM chat_conversations WHERE owner=? AND workspace_id=?',(owner,wid))]
|
||||||
|
self.db.execute('UPDATE chat_conversations SET workspace_id=NULL,revision=revision+1,updated_at=? WHERE owner=? AND workspace_id=?',(time.time(),owner,wid))
|
||||||
|
for cid in ids: self._change(owner,'conversation',cid,self._conversation(self._row('chat_conversations',owner,cid)))
|
||||||
|
self.db.execute('DELETE FROM chat_workspaces WHERE owner=? AND id=?',(owner,wid)); self._change(owner,'workspace',wid)
|
||||||
|
return {'ok':True}
|
||||||
|
|
||||||
|
def get_preferences(self, owner):
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
row=self.db.execute('SELECT palette,appearance,revision FROM chat_preferences WHERE owner=?',(owner,)).fetchone()
|
||||||
|
return dict(row) if row else {'palette':'opal','appearance':'system','revision':0}
|
||||||
|
|
||||||
|
def set_preferences(self, owner, revision, palette, appearance):
|
||||||
|
integer(revision,'revision')
|
||||||
|
if not isinstance(palette,str) or palette not in ('opal','graphite','ocean','sage','lavender'): raise ValueError('Invalid palette')
|
||||||
|
if not isinstance(appearance,str) or appearance not in ('system','light','dark'): raise ValueError('Invalid appearance')
|
||||||
|
with self.lock,self.db:
|
||||||
|
if self.get_preferences(owner)['revision']!=revision: raise ChatConflict('Stale revision')
|
||||||
|
self.db.execute('INSERT INTO chat_preferences VALUES(?,?,?,?) ON CONFLICT(owner) DO UPDATE SET palette=excluded.palette,appearance=excluded.appearance,revision=excluded.revision',(owner,palette,appearance,revision+1))
|
||||||
|
result=self.get_preferences(owner); self._change(owner,'preferences',owner,result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def sync(self, owner, after=0, limit=200):
|
||||||
|
integer(after,'after'); integer(limit,'limit',1,500)
|
||||||
|
with self.lock:
|
||||||
|
self._owner(owner)
|
||||||
|
rows=list(self.db.execute('SELECT * FROM chat_changes WHERE owner=? AND sequence>? ORDER BY sequence LIMIT ?',(owner,after,limit)))
|
||||||
|
changes=[{'sequence':r['sequence'],'kind':r['kind'],'id':r['object_id'],'deleted':bool(r['deleted']),'data':json.loads(r['data']) if r['data'] is not None else None,'created_at':r['created_at']} for r in rows]
|
||||||
|
return {'changes':changes,'cursor':rows[-1]['sequence'] if rows else after}
|
||||||
|
|
||||||
|
def _insert_message(self,owner,cid,role,content,status,run_id=None,identity=None):
|
||||||
|
identity=identity or uuid.uuid4().hex
|
||||||
|
position=self.db.execute('SELECT COALESCE(MAX(position),0)+1 FROM chat_messages WHERE conversation_id=?',(cid,)).fetchone()[0]
|
||||||
|
self._quota('chat_messages', owner, 20000)
|
||||||
|
self.db.execute('INSERT INTO chat_messages(id,owner,conversation_id,role,content,status,run_id,created_at,position) VALUES(?,?,?,?,?,?,?,?,?)',(identity,owner,cid,role,content,status,run_id,time.time(),position))
|
||||||
|
return self._message(self._row('chat_messages',owner,identity))
|
||||||
|
|
||||||
|
def send(self, owner, cid, content, key, attachment_ids=None, mode='normal'):
|
||||||
|
"""Atomic admission; replay returns same IDs and never creates new work."""
|
||||||
|
text(content,'content',32000); text(key,'idempotency key',200)
|
||||||
|
if attachment_ids is None: attachment_ids=[]
|
||||||
|
if not isinstance(attachment_ids,list) or len(attachment_ids)>8: raise ValueError('Invalid attachments')
|
||||||
|
for attachment_id in attachment_ids: text(attachment_id,'attachment id',128)
|
||||||
|
if len(set(attachment_ids))!=len(attachment_ids): raise ValueError('Duplicate attachments')
|
||||||
|
if not isinstance(mode,str) or mode not in ('normal','research'): raise ValueError('Invalid mode')
|
||||||
|
payload=json.dumps({'content':content,'attachment_ids':attachment_ids,'mode':mode},sort_keys=True)
|
||||||
|
with self.lock,self.db:
|
||||||
|
self._row('chat_conversations',owner,cid)
|
||||||
|
prior=self.db.execute('SELECT * FROM chat_idempotency WHERE owner=? AND conversation_id=? AND key=?',(owner,cid,key)).fetchone()
|
||||||
|
if prior:
|
||||||
|
if prior['payload']!=payload: raise ChatConflict('Idempotency payload changed')
|
||||||
|
return {'message':self._message(self._row('chat_messages',owner,prior['message_id'])),'run':self._run(self._row('chat_runs',owner,prior['run_id']))}
|
||||||
|
for attachment_id in attachment_ids:
|
||||||
|
exists=self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='hope_attachments'").fetchone()
|
||||||
|
if not exists or not self.db.execute('SELECT 1 FROM hope_attachments WHERE owner=? AND id=? AND deleted_at IS NULL',(owner,attachment_id)).fetchone(): raise KeyError(attachment_id)
|
||||||
|
self._quota('chat_runs',owner,10000)
|
||||||
|
mid,rid,now=uuid.uuid4().hex,uuid.uuid4().hex,time.time()
|
||||||
|
self.db.execute('INSERT INTO chat_runs VALUES(?,?,?,?,?,?,?,?)',(rid,owner,cid,'queued',mid,now,now,None))
|
||||||
|
self._insert_message(owner,cid,'user',content,'completed',rid,mid)
|
||||||
|
self.db.execute('UPDATE chat_messages SET attachments=? WHERE id=?',(json.dumps(attachment_ids),mid))
|
||||||
|
message=self._message(self._row('chat_messages',owner,mid))
|
||||||
|
self.db.execute('INSERT INTO chat_idempotency VALUES(?,?,?,?,?,?)',(owner,cid,key,payload,mid,rid))
|
||||||
|
self.db.execute('UPDATE chat_conversations SET revision=revision+1,updated_at=? WHERE id=?',(now,cid))
|
||||||
|
self._change(owner,'conversation',cid,self._conversation(self._row('chat_conversations',owner,cid)))
|
||||||
|
return {'message':message,'run':self._run(self._row('chat_runs',owner,rid))}
|
||||||
|
|
||||||
|
def get_run(self, owner, rid):
|
||||||
|
with self.lock:
|
||||||
|
run=self._run(self._row('chat_runs',owner,rid))
|
||||||
|
return {'run':run,'messages':[self._message(r) for r in self.db.execute('SELECT * FROM chat_messages WHERE owner=? AND run_id=? ORDER BY position',(owner,rid))]}
|
||||||
|
|
||||||
|
def update_run(self, owner, rid, status, error=None):
|
||||||
|
transitions={'queued':{'running','failed','cancelled'},'running':{'awaiting_approval','completed','failed','cancelled'},'awaiting_approval':{'running','failed','cancelled'}}
|
||||||
|
if not isinstance(status,str) or status not in {*transitions,*self.TERMINAL}: raise ValueError('Invalid run status')
|
||||||
|
if error is not None: text(error,'error',1000,empty=True)
|
||||||
|
with self.lock,self.db:
|
||||||
|
row=self._row('chat_runs',owner,rid)
|
||||||
|
if row['status']==status:
|
||||||
|
if row['error']!=error: raise ChatConflict('Run already has this status')
|
||||||
|
return self._run(row)
|
||||||
|
if status not in transitions.get(row['status'],set()): raise ChatConflict('Invalid run transition')
|
||||||
|
self.db.execute('UPDATE chat_runs SET status=?,error=?,updated_at=? WHERE id=? AND owner=?',(status,error,time.time(),rid,owner))
|
||||||
|
if status in self.TERMINAL: self._touch(owner,row['conversation_id'])
|
||||||
|
return self._run(self._row('chat_runs',owner,rid))
|
||||||
|
|
||||||
|
def add_message(self, owner, cid, role, content, status='completed', run_id=None):
|
||||||
|
text(content,'content',32000,empty=True)
|
||||||
|
if not isinstance(role,str) or role not in ('user','assistant') or not isinstance(status,str) or status not in ('pending','streaming','completed','failed','cancelled'): raise ValueError('Invalid message')
|
||||||
|
with self.lock,self.db:
|
||||||
|
self._row('chat_conversations',owner,cid)
|
||||||
|
if run_id is not None:
|
||||||
|
run=self._row('chat_runs',owner,run_id)
|
||||||
|
if run['conversation_id']!=cid: raise KeyError(run_id)
|
||||||
|
if run['status'] in self.TERMINAL: raise ChatConflict('Run finished')
|
||||||
|
result=self._insert_message(owner,cid,role,content,status,run_id)
|
||||||
|
if status in self.TERMINAL: self._touch(owner,cid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_message(self, owner, mid, content=None, status=None):
|
||||||
|
if content is not None: text(content,'content',32000,empty=True)
|
||||||
|
if status is not None and (not isinstance(status,str) or status not in ('pending','streaming','completed','failed','cancelled')): raise ValueError('Invalid message status')
|
||||||
|
with self.lock,self.db:
|
||||||
|
row=self._row('chat_messages',owner,mid)
|
||||||
|
new_content=row['content'] if content is None else content
|
||||||
|
new_status=row['status'] if status is None else status
|
||||||
|
transitions={'pending':{'streaming','completed','failed','cancelled'},'streaming':self.TERMINAL}
|
||||||
|
if row['status'] in self.TERMINAL and (new_content!=row['content'] or new_status!=row['status']): raise ChatConflict('Message finished')
|
||||||
|
if new_status!=row['status'] and new_status not in transitions.get(row['status'],set()): raise ChatConflict('Invalid message transition')
|
||||||
|
if row['run_id'] and new_status in ('pending','streaming','completed'):
|
||||||
|
run=self._row('chat_runs',owner,row['run_id'])
|
||||||
|
if run['status'] in ('failed','cancelled') and (new_content!=row['content'] or new_status!=row['status']): raise ChatConflict('Run finished')
|
||||||
|
self.db.execute('UPDATE chat_messages SET content=?,status=? WHERE owner=? AND id=?',(new_content,new_status,owner,mid))
|
||||||
|
if new_status in self.TERMINAL and (new_content!=row['content'] or new_status!=row['status']): self._touch(owner,row['conversation_id'])
|
||||||
|
return self._message(self._row('chat_messages',owner,mid))
|
||||||
|
|
||||||
|
def append_event(self, owner, rid, event, data):
|
||||||
|
if not isinstance(event,str) or event not in ('message','run','progress','approval','done'): raise ValueError('Invalid event')
|
||||||
|
if not isinstance(data,dict): raise ValueError('Invalid event data')
|
||||||
|
try: encoded=json.dumps(data,allow_nan=False)
|
||||||
|
except (ValueError,TypeError) as exc: raise ValueError('Invalid event data') from exc
|
||||||
|
if len(encoded.encode())>131072: raise ValueError('Event too large')
|
||||||
|
with self.lock,self.db:
|
||||||
|
self._row('chat_runs',owner,rid)
|
||||||
|
budget=self.db.execute('SELECT count(*),COALESCE(SUM(length(CAST(data AS BLOB))),0) FROM chat_events WHERE run_id=?',(rid,)).fetchone()
|
||||||
|
# Reserve terminal event slots so capacity failures can terminate cleanly.
|
||||||
|
ceiling=10010 if event in ('run','done') else 10000
|
||||||
|
if budget[0]>=ceiling or budget[1]+len(encoded.encode())>16*1024*1024+(131072 if event in ('run','done') else 0):
|
||||||
|
raise ValueError('Run event storage limit reached')
|
||||||
|
sequence=self.db.execute('SELECT COALESCE(MAX(sequence),0)+1 FROM chat_events WHERE run_id=?',(rid,)).fetchone()[0]
|
||||||
|
self.db.execute('INSERT INTO chat_events VALUES(?,?,?,?,?)',(owner,rid,sequence,event,encoded))
|
||||||
|
return {'sequence':sequence,'event':event,'data':json.loads(encoded)}
|
||||||
|
|
||||||
|
def events(self, owner, rid, after=0, limit=500):
|
||||||
|
integer(after,'after'); integer(limit,'limit',1,1000)
|
||||||
|
with self.lock:
|
||||||
|
self._row('chat_runs',owner,rid)
|
||||||
|
return [{'sequence':r['sequence'],'event':r['event'],'data':json.loads(r['data'])} for r in self.db.execute('SELECT * FROM chat_events WHERE owner=? AND run_id=? AND sequence>? ORDER BY sequence LIMIT ?',(owner,rid,after,limit))]
|
||||||
|
|
||||||
|
def branch(self, owner, cid, message_id, content=None, title=None):
|
||||||
|
if content is not None: text(content,'content',32000)
|
||||||
|
if title is not None: text(title,'title',200)
|
||||||
|
with self.lock,self.db:
|
||||||
|
parent=self._row('chat_conversations',owner,cid)
|
||||||
|
cutoff=self._row('chat_messages',owner,message_id)
|
||||||
|
if cutoff['conversation_id']!=cid: raise KeyError(message_id)
|
||||||
|
if content is not None and cutoff['role']!='user': raise ValueError('Only user messages can be edited')
|
||||||
|
if cutoff['status']!='completed': raise ChatConflict('Cannot branch unfinished message')
|
||||||
|
self._quota('chat_conversations',owner,1000)
|
||||||
|
identity,now=uuid.uuid4().hex,time.time()
|
||||||
|
self.db.execute('INSERT INTO chat_conversations(id,owner,title,workspace_id,temporary,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,?,?)',(identity,owner,title or parent['title'],parent['workspace_id'],parent['temporary'],now,now))
|
||||||
|
comparator='<' if content is not None else '<='
|
||||||
|
rows=list(self.db.execute(f'SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? AND position{comparator}? ORDER BY position',(owner,cid,cutoff['position'])))
|
||||||
|
for row in rows:
|
||||||
|
if row['status']=='completed':
|
||||||
|
copied=self._insert_message(owner,identity,row['role'],row['content'],'completed')
|
||||||
|
self.db.execute('UPDATE chat_messages SET attachments=? WHERE id=?',(row['attachments'],copied['id']))
|
||||||
|
result=self._conversation(self._row('chat_conversations',owner,identity)); self._change(owner,'conversation',identity,result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def history(self, owner, cid):
|
||||||
|
"""Completed branch-local turns for one-time Hermes history seeding."""
|
||||||
|
with self.lock:
|
||||||
|
self._row('chat_conversations',owner,cid)
|
||||||
|
return [{'role':r['role'],'content':r['content']} for r in self.db.execute("SELECT role,content FROM chat_messages WHERE owner=? AND conversation_id=? AND status='completed' ORDER BY position",(owner,cid))]
|
||||||
|
|
||||||
|
def get_session(self, owner, cid):
|
||||||
|
"""Trusted executor only; never include this result in public JSON."""
|
||||||
|
with self.lock:
|
||||||
|
row=self._row('chat_conversations',owner,cid)
|
||||||
|
return {'hermes_session_id':row['hermes_session_id'],'hermes_seeded':bool(row['hermes_seeded'])}
|
||||||
|
|
||||||
|
def set_session(self, owner, cid, hermes_session_id, seeded=False):
|
||||||
|
text(hermes_session_id,'Hermes session',200)
|
||||||
|
if type(seeded) is not bool: raise ValueError('Invalid seeded flag')
|
||||||
|
with self.lock,self.db:
|
||||||
|
row=self._row('chat_conversations',owner,cid)
|
||||||
|
if row['hermes_session_id'] is not None and row['hermes_session_id']!=hermes_session_id: raise ChatConflict('Session already assigned')
|
||||||
|
if row['hermes_seeded'] and not seeded: raise ChatConflict('Session already seeded')
|
||||||
|
self.db.execute('UPDATE chat_conversations SET hermes_session_id=?,hermes_seeded=? WHERE owner=? AND id=?',(hermes_session_id,seeded,owner,cid))
|
||||||
|
return {'hermes_session_id':hermes_session_id,'hermes_seeded':seeded}
|
||||||
|
|
||||||
|
def get_hermes_session(self, owner, cid):
|
||||||
|
return self.get_session(owner,cid)['hermes_session_id']
|
||||||
|
|
||||||
|
def set_hermes_session(self, owner, cid, session_id, seeded=True):
|
||||||
|
return self.set_session(owner,cid,session_id,seeded)
|
||||||
|
|
||||||
|
def active_runs(self, owner=None):
|
||||||
|
"""Trusted recovery listing; None lists all owners, with private owner ID."""
|
||||||
|
with self.lock:
|
||||||
|
if owner is not None: self._owner(owner)
|
||||||
|
where="status IN ('queued','running','awaiting_approval')"
|
||||||
|
params=()
|
||||||
|
if owner is not None: where+=' AND owner=?'; params=(owner,)
|
||||||
|
return [{**self._run(r),'owner':r['owner']} for r in self.db.execute('SELECT * FROM chat_runs WHERE '+where+' ORDER BY created_at,id',params)]
|
||||||
|
|
||||||
|
def claim_run(self, owner, rid):
|
||||||
|
"""Only queued -> running admits execution; a retry cannot claim twice."""
|
||||||
|
with self.lock,self.db:
|
||||||
|
row=self._row('chat_runs',owner,rid)
|
||||||
|
if row['status']!='queued': return False
|
||||||
|
self.db.execute("UPDATE chat_runs SET status='running',updated_at=? WHERE id=? AND owner=?",(time.time(),rid,owner))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_message(self, owner, mid):
|
||||||
|
with self.lock: return self._message(self._row('chat_messages',owner,mid))
|
||||||
|
|
||||||
|
get_events = events
|
||||||
|
get_history = history
|
||||||
|
conversation_history = history
|
||||||
|
branch_conversation = branch
|
||||||
|
update_preferences = set_preferences
|
||||||
|
list_events = events
|
||||||
|
pending_runs = active_runs
|
||||||
+60
-3
@@ -252,6 +252,40 @@ class Provisioner:
|
|||||||
raise ProvisioningError('Invalid persisted Hermes API credential')
|
raise ProvisioningError('Invalid persisted Hermes API credential')
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _declare_auto_vision(value):
|
||||||
|
"""Patch only the verified managed auto model, preserving other metadata."""
|
||||||
|
providers = value.get('providers', {})
|
||||||
|
if not isinstance(providers, dict):
|
||||||
|
raise ValueError('Invalid provider configuration')
|
||||||
|
provider = providers.get('hope-upstream')
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
if not isinstance(provider, dict):
|
||||||
|
raise ValueError('Invalid managed provider')
|
||||||
|
models = provider.get('models', {})
|
||||||
|
if isinstance(models, list):
|
||||||
|
if not all(isinstance(model, str) for model in models):
|
||||||
|
raise ValueError('Invalid model list')
|
||||||
|
if 'auto' not in models:
|
||||||
|
return False
|
||||||
|
models = {model: {} for model in models}
|
||||||
|
provider['models'] = models
|
||||||
|
if not isinstance(models, dict):
|
||||||
|
raise ValueError('Invalid model metadata')
|
||||||
|
if 'auto' not in models:
|
||||||
|
return False
|
||||||
|
metadata = models['auto']
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError('Invalid auto metadata')
|
||||||
|
if metadata.get('supports_vision') is True:
|
||||||
|
return False
|
||||||
|
metadata['supports_vision'] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _reload_marker(self, user):
|
||||||
|
return self._paths(user)[0] / 'config-reload-pending'
|
||||||
|
|
||||||
def _prepare(self, user, exists, port):
|
def _prepare(self, user, exists, port):
|
||||||
private, home, workspace = self._paths(user)
|
private, home, workspace = self._paths(user)
|
||||||
self._directory(self.state_dir)
|
self._directory(self.state_dir)
|
||||||
@@ -270,7 +304,7 @@ class Provisioner:
|
|||||||
elif config.is_symlink():
|
elif config.is_symlink():
|
||||||
raise ProvisioningError('Hermes config cannot be a symlink')
|
raise ProvisioningError('Hermes config cannot be a symlink')
|
||||||
else:
|
else:
|
||||||
# Preserve user configuration; only control-owned listener settings change.
|
# Preserve user configuration except managed listeners/verified auto capability.
|
||||||
try:
|
try:
|
||||||
value = yaml.safe_load(config.read_text())
|
value = yaml.safe_load(config.read_text())
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
@@ -278,8 +312,13 @@ class Provisioner:
|
|||||||
server = value.setdefault('gateway', {}).setdefault('api_server', {})
|
server = value.setdefault('gateway', {}).setdefault('api_server', {})
|
||||||
if not isinstance(server, dict):
|
if not isinstance(server, dict):
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
|
changed = self._declare_auto_vision(value)
|
||||||
if server.get('host') != '127.0.0.1' or server.get('port') != port:
|
if server.get('host') != '127.0.0.1' or server.get('port') != port:
|
||||||
server.update(host='127.0.0.1', port=port)
|
server.update(host='127.0.0.1', port=port)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
# Durable before writing: retry must reload even after a crash.
|
||||||
|
self._write_private(self._reload_marker(user), 'pending\n')
|
||||||
self._write_private(config, yaml.safe_dump(value, sort_keys=False))
|
self._write_private(config, yaml.safe_dump(value, sort_keys=False))
|
||||||
except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc:
|
except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc:
|
||||||
raise ProvisioningError('Invalid Hermes runtime configuration') from exc
|
raise ProvisioningError('Invalid Hermes runtime configuration') from exc
|
||||||
@@ -298,7 +337,7 @@ class Provisioner:
|
|||||||
'model': {'default': model, 'provider': 'custom:hope-upstream'},
|
'model': {'default': model, 'provider': 'custom:hope-upstream'},
|
||||||
'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'),
|
'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'),
|
||||||
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model,
|
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model,
|
||||||
'models': [model], 'discover_models': False}},
|
'models': {'auto': {'supports_vision': True}} if model == 'auto' else [model], 'discover_models': False}},
|
||||||
'agent': {'reasoning_effort': 'none'},
|
'agent': {'reasoning_effort': 'none'},
|
||||||
'terminal': {'backend': 'local', 'cwd': '/workspace'},
|
'terminal': {'backend': 'local', 'cwd': '/workspace'},
|
||||||
'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']},
|
'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']},
|
||||||
@@ -422,8 +461,13 @@ class Provisioner:
|
|||||||
# Never start a host-networked container with an unexpected listener.
|
# Never start a host-networked container with an unexpected listener.
|
||||||
self._connection(user, info, key)
|
self._connection(user, info, key)
|
||||||
await self._remove_legacy_network(user)
|
await self._remove_legacy_network(user)
|
||||||
|
marker = self._reload_marker(user)
|
||||||
|
if marker.is_symlink():
|
||||||
|
raise ProvisioningError('Reload marker cannot be a symlink')
|
||||||
if not info.get('State', {}).get('Running'):
|
if not info.get('State', {}).get('Running'):
|
||||||
await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304))
|
await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304))
|
||||||
|
elif marker.exists():
|
||||||
|
await self._request('POST', f'/containers/{info["Id"]}/restart', allowed=(204,))
|
||||||
# One overall deadline also bounds Docker inspect / health I/O; no
|
# One overall deadline also bounds Docker inspect / health I/O; no
|
||||||
# deletion on failed health, so a retry starts/adopts the same state.
|
# deletion on failed health, so a retry starts/adopts the same state.
|
||||||
try:
|
try:
|
||||||
@@ -436,6 +480,10 @@ class Provisioner:
|
|||||||
if info.get('State', {}).get('Running'):
|
if info.get('State', {}).get('Running'):
|
||||||
connection = self._connection(user, info, key)
|
connection = self._connection(user, info, key)
|
||||||
if await self._healthy(connection):
|
if await self._healthy(connection):
|
||||||
|
if marker.exists():
|
||||||
|
if marker.is_symlink() or marker.resolve() != self._paths(user)[0].resolve() / 'config-reload-pending':
|
||||||
|
raise ProvisioningError('Invalid reload marker path')
|
||||||
|
marker.unlink()
|
||||||
return connection
|
return connection
|
||||||
await asyncio.sleep(self.poll_interval)
|
await asyncio.sleep(self.poll_interval)
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
@@ -446,9 +494,18 @@ class Provisioner:
|
|||||||
info = await self._inspect(container_name(user))
|
info = await self._inspect(container_name(user))
|
||||||
if not info or not info.get('State', {}).get('Running'):
|
if not info or not info.get('State', {}).get('Running'):
|
||||||
return False
|
return False
|
||||||
|
marker = self._reload_marker(user)
|
||||||
|
if marker.exists() or marker.is_symlink():
|
||||||
|
return False
|
||||||
|
config = self._paths(user)[1] / 'config.yaml'
|
||||||
|
if config.is_symlink():
|
||||||
|
return False
|
||||||
|
value = yaml.safe_load(config.read_text())
|
||||||
|
if not isinstance(value, dict) or self._declare_auto_vision(value):
|
||||||
|
return False
|
||||||
key = self._read_key(user)
|
key = self._read_key(user)
|
||||||
return bool(key) and await self._healthy(self._connection(user, info, key))
|
return bool(key) and await self._healthy(self._connection(user, info, key))
|
||||||
except (ProvisioningError, ValueError, OSError):
|
except (ProvisioningError, ValueError, OSError, yaml.YAMLError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def remove(self, user: dict):
|
async def remove(self, user: dict):
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ fastapi>=0.115,<1
|
|||||||
uvicorn>=0.30,<1
|
uvicorn>=0.30,<1
|
||||||
httpx>=0.27,<1
|
httpx>=0.27,<1
|
||||||
PyYAML>=6,<7
|
PyYAML>=6,<7
|
||||||
|
pypdf>=6,<7
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Hope interface review and approval proposal
|
||||||
|
|
||||||
|
## Scope and method
|
||||||
|
Approval-only overhaul. Production web and native UI are unchanged by this proposal.
|
||||||
|
Independent assessments: product design (4638a6b7-0052-4043-924b-d20d21345fc1) and technical evidence (7d81a007-b618-49c2-91a5-22a92c0c1903). Both read current source and desktop/mobile screenshots independently. Technical assessment also ran the detector and offline Chromium tests. No production logins, microphone access, or live mutations during review.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
Hope's calm reading surface is a good foundation. The structure undermines its distinctive promise: a realtime companion with a recognizable square presence. Text chat and voice behave like separate products. The visible Talk button navigates to another screen and focuses another Start button; it does not start talking. The desktop header exposes specialist conversation administration at the same priority as the conversation itself.
|
||||||
|
|
||||||
|
### Heuristic assessment
|
||||||
|
Source/static screenshot quality scores; not a claim of complete live usability qualification.
|
||||||
|
|
||||||
|
| Heuristic | Score / 4 | Main problem |
|
||||||
|
|---|---:|---|
|
||||||
|
| System status | 2 | Microphone/session state leaves the composer |
|
||||||
|
| Real-world match | 2 | Talk is a two-step navigation detour |
|
||||||
|
| Control and freedom | 2 | Split context for voice and text |
|
||||||
|
| Consistency | 1 | Separate chat/voice identities and layouts |
|
||||||
|
| Error prevention | 3 | Safe branching and confirmations are sound |
|
||||||
|
| Recognition | 2 | Voice/account conflated; too many concepts |
|
||||||
|
| Efficiency | 3 | Shortcuts/history are useful; voice takes extra steps |
|
||||||
|
| Minimalism | 2 | Management toolbar and feature stack clutter |
|
||||||
|
| Recovery | 2 | Repair/status are remote from composing |
|
||||||
|
| Help | 1 | Realtime vs dictation and mic truth need explanation |
|
||||||
|
| Total | **20 / 40** | Major structural improvement warranted |
|
||||||
|
|
||||||
|
## Priority findings
|
||||||
|
1. **P1 — Voice is a portal instead of an input method.** Put a labeled square Lightwell control inside the composer. In production, its intentional activation starts the permission/connection flow directly, not merely another screen. Keep the transcript and draft in place.
|
||||||
|
2. **P1 — Active voice needs persistent session truth.** Connecting, listening, thinking, speaking, muted, reconnecting, ending, failed, and ended must map to real state. Mute and End remain reachable. Never indicate mic-on before connection and microphone readiness are verified. Preserve editable drafts; current text-send exclusion must remain explicit.
|
||||||
|
3. **P1 — Light-theme voice transcript contrast fails.** A hardcoded dark background inherits dark Opal text: heading 1.35:1; muted copy 3.15:1. Replace the inconsistent legacy surface with shared semantic tokens. Primary-chat text contrast passed and should not be misreported as a failure.
|
||||||
|
4. **P2 — Management competes with conversation.** Group pin, rename, move, export, archive, delete into a clear overflow menu. Attachments belong inside the composer. Secondary tools belong in Library/Activity/account controls.
|
||||||
|
5. **P2 — Signature identity is disconnected.** Replace the generic star/mic approach with a square matrix presence, sampled for legibility at button scale. Use text and pattern as well as color to communicate state. Avoid an orb, decorative glow, or dictation icon as the primary voice affordance.
|
||||||
|
|
||||||
|
## Technical evidence
|
||||||
|
Detector: three warnings, one rule `layout-transition`, one file `web/style.css`, lines 371/613/792. Active voice animates LED size, meter width and waveform height. No measured jank claim. Reduced-motion CSS does not stop JS-driven layout changes.
|
||||||
|
Offline current-product browser suite: seven passed. No horizontal overflow at 390px or 320px. Drawer inert/Escape behavior and draft preservation work. Some 30–40px actions fall below preferred 44px comfort, not automatically below WCAG's 24px minimum. Timeline completion announcements exist, but response replacement/focus and screenreader reading need deliberate treatment.
|
||||||
|
No user-visible detector overlay was created; evidence is source, screenshots, computed browser styles and offline tests. No temporary servers remained running.
|
||||||
|
|
||||||
|
## Proposal design commitments
|
||||||
|
- Physical scene: a person switching between quiet typing and natural conversation at their desk or on a phone; calm light Opal is the default, with a fully composed dark variant.
|
||||||
|
- Restrained color strategy: pink identity at moments of action/presence, not everywhere.
|
||||||
|
- One system sans family; 14–15px reading copy; 740px rail; distinct content, navigation and input responsibilities.
|
||||||
|
- Square matrix action sits beside Send in the composer. Label reads Talk to Hope, accessible name Start realtime voice conversation. It is not dictation.
|
||||||
|
- Inline square persona appears in the same scrolling conversation surface. Existing transcript remains available; no separate voice application takeover.
|
||||||
|
- User action activates a simulated listening state only in this HTML. The preview neither requests microphone permission nor starts a backend session.
|
||||||
|
- Voice mute/end and speaking/listening examples are interactive. Text draft survives; text sending is disabled until simulated voice ends, reflecting the currently implemented exclusion.
|
||||||
|
- Mobile keeps the action in the bottom composer with 44px targets, drawer navigation, safe-area padding and no horizontal overflow.
|
||||||
|
- Native dialogs, accessible labels, keyboard focus, Escape, reduced motion and light/dark states.
|
||||||
|
|
||||||
|
## Approval boundary
|
||||||
|
The standalone HTML contains illustrative sample data and simulated controls. Search, settings, Library and Activity demonstrate information architecture, not complete production feature implementations. Connection, permission denial, reconnecting, approval, upload progress, errors and physical keyboard/microphone behavior still require implementation and qualification after approval.
|
||||||
|
|
||||||
|
**Nothing in this proposal authorizes production replacement.** User approval is required before adapting the real UI, and the real voice flow must be validated without regressing interruption, mic truth, draft retention, conversation ownership or account isolation.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Hope shared themes · v1
|
||||||
|
|
||||||
|
## Identity and scope
|
||||||
|
|
||||||
|
Hope is an authenticated voice companion, not a model dashboard. Users may talk in a dim room or review text in daylight; appearance therefore follows the device by default, with explicit light/dark overrides. The restrained Nearlight system uses opaque tinted surfaces, readable ink and one action accent. **Opal Pink remains the default**, preserving the existing iOS identity rather than the historical web navy palette.
|
||||||
|
|
||||||
|
The source of truth is [hope-palette.v1.json](./hope-palette.v1.json), schema version `1`, token version `1.0.0`. Palette IDs are stable persisted/API values: `opal` (display label **Opal Pink**), `graphite`, `ocean`, `sage`, `lavender`. Appearance IDs are `system`, `light`, `dark`; default `system`. Labels are presentation, never storage keys. Theme settings must not affect authentication, voice, conversation or error state. Consumers should normalize unknown IDs to Opal/system.
|
||||||
|
|
||||||
|
The extracted native source is [Nearlight.swift](../../voice-ios/Sources/App/Nearlight.swift), lines 11–43. Opal's exact ground/surface/personaSurface/ink/quiet/rosewood/onRosewood values are preserved. Alert channels are deliberately stored as normalized decimal sRGB `[0.675, 0.208, 0.282]` and `[1.0, 0.604, 0.675]`: rounding them to hex would change the existing UIColor values. All other opaque primitives use six-digit sRGB hex. The generator emits round-trip precision channel values, not an approximate color conversion.
|
||||||
|
|
||||||
|
## Architecture and outputs
|
||||||
|
|
||||||
|
1. **Primitive values**: explicit sRGB palette values and shared alert primitives.
|
||||||
|
2. **Semantic roles**: `ground`, `surface`, `personaSurface`, `ink`, `quiet`, `accent`, `onAccent`, `alert`, `onAlert`, `focus`, `controlBorder`; aliases are documented in the JSON. `accent` replaces color-specific naming for new code; existing `rosewood` semantics remain a compatibility alias.
|
||||||
|
3. **Component aliases**: action, input, persona and error roles refer to semantic roles, never raw values.
|
||||||
|
|
||||||
|
[generate_hope_themes.py](../scripts/generate_hope_themes.py) uses the standard library only. It validates required roles and contrast **before writing**, generates deterministic artifacts, and embeds source version/SHA256. Change the source and regenerate; do not hand-edit outputs. A changed schema requires a new schema version; removed/repurposed role or palette IDs require a major token version. Additive roles/palettes use a minor version; compatible corrections use a patch version. Native identity changes require explicit product approval.
|
||||||
|
|
||||||
|
### Web handoff
|
||||||
|
|
||||||
|
Load [hope-themes.css](../web/hope-themes.css) after existing base styles, then consume prefixed variables:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-palette="opal" data-appearance="system">
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
body { background: var(--hope-ground); color: var(--hope-ink); }
|
||||||
|
button.primary { background: var(--hope-action-background); color: var(--hope-action-foreground); }
|
||||||
|
input { background: var(--hope-input-background); color: var(--hope-input-foreground); border-color: var(--hope-input-border); }
|
||||||
|
input::placeholder { color: var(--hope-input-placeholder); opacity: 1; }
|
||||||
|
:focus-visible { outline: 2px solid var(--hope-focus); outline-offset: 3px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Attributes belong on the document root, not arbitrary descendants. Missing/stale palette IDs inherit Opal. Missing/stale/system appearance follows `prefers-color-scheme`; explicit light/dark overrides the OS. `color-scheme` matches the effective appearance so native controls match. Theme styles define tokens only; they do not modify layout, attach event listeners, store preferences, or override legacy unprefixed CSS automatically. Consumers own preference loading/persistence and loading the sheet before rendering to avoid a theme flash.
|
||||||
|
|
||||||
|
### iOS handoff
|
||||||
|
|
||||||
|
[HopeTheme.generated.swift](../generated/HopeTheme.generated.swift) is staged **in this backend repository**, not installed into the iOS target. The iOS teammate may copy the unchanged artifact into the app build and map existing Nearlight roles:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
HopeTheme.color(.ground, palette: .opal, appearance: .system)
|
||||||
|
HopeTheme.color(.accent, palette: palette, appearance: appearance) // Nearlight.rosewood
|
||||||
|
HopeTheme.color(.onAccent, palette: palette, appearance: appearance) // Nearlight.onRosewood
|
||||||
|
```
|
||||||
|
|
||||||
|
`HopePalette` and `HopeAppearance` are raw-string enums for persistence. `HopePalette.label` supplies display labels; `HopeTheme.version` identifies the source version. System colors use the native trait-aware UIColor provider; explicit overrides return a fixed color. UIKit/SwiftUI are required. Spacing, typography, radii, motion and native materials remain the responsibility of the existing Nearlight system; this task does not alter them.
|
||||||
|
|
||||||
|
## Accessibility and state rules
|
||||||
|
|
||||||
|
- Primary/supporting/placeholder/accent/error text is tested at **≥4.5:1** on ground, surface and persona surfaces for every palette and appearance. Action/destructive labels also meet 4.5:1; focus and essential control boundaries meet **≥3:1** against those surfaces.
|
||||||
|
- All 230 declared foreground/background checks pass. Lowest normal-text contrast is **4.558:1** (Sage light `quiet` on `selectedWash`). Checks use WCAG relative luminance and sRGB transfer functions. Run `--contrast` for the complete measured report. This verifies declared token pairings, **not** every rendered UI, arbitrary layering or hardware display.
|
||||||
|
- `separator` is decorative only. Native's `ink × 14%`, `accent × 10%` selected wash and `alert × 8%` error wash are precomposited over `surface` into opaque shared tokens. On that surface they preserve the original appearance. Do not reuse the baked wash expecting translucency over a different parent; use a reviewed derivation if needed.
|
||||||
|
- Essential boundaries use `controlBorder`, not the low-contrast separator. A focus ring needs thickness and offset, not color alone. In forced-colors mode consumers must retain browser/system focus and control boundaries.
|
||||||
|
- **Errors are not represented only by palette/color**: keep explicit messages, icons or labels, accessibility announcements and recovery actions. `alert` is shared across palettes, not the theme's accent. Selected controls also need checkmarks/pressed or selected semantics. Never use color alone for listening, speaking, offline, loading, success or destructive state.
|
||||||
|
- Disabled controls may use native disabled semantics; do not dim readable supporting text into a disabled color. Consumers retain reduced-motion handling and Dynamic Type/text zoom support.
|
||||||
|
|
||||||
|
## Offline verification (no NAS, no deployment)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/generate_hope_themes.py
|
||||||
|
python3 scripts/generate_hope_themes.py --check
|
||||||
|
python3 scripts/generate_hope_themes.py --check --contrast
|
||||||
|
python3 -m unittest discover -s tests/unit -p test_hope_themes.py -v
|
||||||
|
swiftc -frontend -parse generated/HopeTheme.generated.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
[Token tests](../tests/unit/test_hope_themes.py) fix the native identity, ensure shared alert semantics and opaque derivations, reject malformed primitives/missing roles/low contrast, verify deterministic artifact drift, and confirm round-trip Swift channels. The optional Playwright test runs local Chromium against in-memory HTML/CSS with **70 palette/appearance/OS combinations**, including missing/stale values and overrides; it does not call an API or server. It skips if the Playwright module is absent; install Playwright and its Chromium browser for full verification. Swift parsing verifies syntax only; native UIKit compilation and final device rendering belong to the iOS integration qualification. Do not run the legacy top-level live-default test suite for token changes.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hope — interface proposal</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light;--bg:#fffdfd;--side:#f8f3f6;--panel:#fff;--ink:#302b40;--quiet:#706074;--line:#e6dce3;--rose:#a64270;--wash:#f8eaf1;--on:#fff;--persona:#fff2f7;--hover:#eee5ec}*{box-sizing:border-box}body{margin:0;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);font-size:14px}button,input,textarea{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--rose);outline-offset:4px}button{border:0;background:none;min-height:44px;border-radius:8px}button:hover{background:var(--hover)}button:disabled{cursor:default;opacity:.45}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.icon{display:inline-flex;align-items:center;justify-content:center;width:44px}.app{display:flex;height:100dvh}.sidebar{width:252px;flex-shrink:0;background:var(--side);display:flex;flex-direction:column;padding:28px 18px 16px;border-right:1px solid var(--line)}.brand{display:flex;align-items:center;gap:11px;padding:0 10px 28px;font-size:23px;letter-spacing:-.03em;font-weight:650}.brand-square{width:27px;height:27px;background:var(--rose);border-radius:6px;display:grid;place-items:center}.brand-square:after{content:"";width:11px;height:11px;background:#fff;border-radius:2px}.new{display:flex;gap:11px;align-items:center;background:var(--panel);padding:0 13px;width:100%;border:1px solid var(--line);font-weight:550}.search{display:flex;align-items:center;gap:10px;margin:13px 0 25px;padding:0 12px;color:var(--quiet);width:100%;text-align:left}.search kbd{margin-left:auto;font-size:11px}.group{margin-bottom:23px}.group-head{display:flex;align-items:center;justify-content:space-between;padding-left:12px;margin-bottom:4px;color:var(--quiet);font-size:12px;font-weight:600;height:28px}.navrow{display:flex;align-items:center;gap:11px;min-height:41px;padding:0 12px;width:100%;text-align:left;font-size:13px}.navrow svg{width:16px;height:16px}.navrow.active{background:var(--wash);color:var(--rose);font-weight:550}.navrow .count{margin-left:auto;font-size:11px;color:var(--quiet)}.sidebar-bottom{margin-top:auto;border-top:1px solid var(--line);padding-top:10px}.profile{display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:7px 10px}.avatar{width:31px;height:31px;display:grid;place-items:center;background:var(--wash);border-radius:50%;color:var(--rose);font-size:12px;font-weight:650}.profile small{display:block;color:var(--quiet);margin-top:3px;font-size:11px}.profile svg{margin-left:auto;width:16px}.main{flex:1;min-width:0;display:flex;flex-direction:column}.topbar{height:82px;flex-shrink:0;padding:0 38px;display:flex;align-items:center;gap:12px}.heading{min-width:0}.heading strong{font-size:15px;font-weight:550}.heading small{display:block;margin-top:6px;color:var(--quiet);font-size:12px}.heading small span{margin:0 5px;color:var(--quiet)}.topbar-right{margin-left:auto;display:flex;align-items:center;gap:6px}.badge{font-size:11px;display:flex;align-items:center;gap:6px;color:var(--quiet);margin-right:15px}.badge svg{width:12px;height:12px}.content{flex:1;overflow:auto;min-height:0;scroll-behavior:smooth}.thread{max-width:740px;margin:0 auto;padding:46px 24px 40px}.date{text-align:center;font-size:11px;color:var(--quiet);margin-bottom:34px}.user-message{margin-left:auto;width:fit-content;max-width:85%;padding:15px 20px;background:var(--wash);border-radius:14px 14px 4px 14px;font-size:15px;line-height:1.6}.assistant{margin-top:38px}.assistant-heading{display:flex;align-items:center;gap:9px;font-weight:600;font-size:13px;margin-bottom:18px}.mini-square{width:20px;height:20px;border-radius:4px;background:var(--rose);display:grid;place-items:center}.mini-square:after{content:"";width:7px;height:7px;background:var(--on);border-radius:1px}.prose{font-size:15px;line-height:1.8}.prose p{margin:0 0 17px}.prose h2{font-size:16px;font-weight:600;margin:28px 0 11px}.prose ul{padding-left:20px}.prose li{padding-left:4px;margin-bottom:9px}.prose a{color:var(--rose);text-decoration:underline;text-underline-offset:3px}.message-actions{display:flex;align-items:center;gap:1px;margin-top:12px;color:var(--quiet)}.message-actions button{min-height:36px;width:36px}.message-actions svg{width:16px;height:16px}.message-actions .time{font-size:11px;margin-left:8px}.composer-area{flex-shrink:0;width:100%;max-width:788px;margin:0 auto;padding:18px 24px 15px;background:var(--bg)}.composer{border:1px solid #cdbbc7;border-radius:14px;background:var(--panel);padding:16px 16px 12px}.composer:focus-within{border-color:var(--rose)}textarea{width:100%;min-height:48px;max-height:140px;resize:none;border:0;background:none;color:var(--ink);outline:none!important;font-size:15px;line-height:1.6;padding:0 2px}textarea::placeholder{color:var(--quiet)}.composer-tools{display:flex;align-items:center;gap:7px;margin-top:8px}.tool-label{Line truncated
|
||||||
|
@media(min-width:1500px){.thread{padding-top:65px}.topbar{padding:0 48px}.composer-area{padding-bottom:25px}}@media(max-width:960px){.sidebar{width:220px}.topbar{padding:0 24px}.badge{display:none}.tool-label{display:none}}@media(max-width:720px){.sidebar{position:fixed;inset:0 auto 0 0;width:268px;z-index:4;transform:translateX(-100%);transition:transform .2s}.drawer-open .sidebar{transform:none}.drawer-open .drawer-scrim{display:block;position:fixed;inset:0;background:rgba(30,20,30,.3);z-index:3;border-radius:0;width:100%;height:100%}.mobile-menu{display:inline-flex}.topbar{height:70px;padding:0 12px;gap:6px}.heading strong{font-size:13px}.heading small{font-size:11px;margin-top:4px}.topbar-right{gap:0}.topbar-right .theme-top{display:none}.thread{padding:24px 22px 20px}.date{margin-bottom:24px}.user-message{font-size:14px;padding:13px 16px}.prose{font-size:14px;line-height:1.8}.assistant{margin-top:28px}.composer-area{padding:10px 12px max(12px,env(safe-area-inset-bottom))}.composer{padding:13px 12px 10px}.composer-note{font-size:10px;justify-content:center}.composer-note .keyhint{display:none}.mode-button{padding:0 5px;font-size:11px}.voice-button{min-width:112px;height:44px;padding:0 10px;font-size:12px;gap:8px}.voice-symbol{height:22px;width:22px}.composer-tools{gap:0}.right-tools{gap:6px}.empty{margin-top:9vh}.empty h1{font-size:26px}.voice-session{padding-top:15px}.persona{width:154px;height:154px;gap:4px;padding:22px}.voice-session h2{font-size:22px}.voice-transcript{margin-top:23px}.demo-label{display:none}.toast{bottom:185px;width:max-content;max-width:90%}.voice-toolbar button{padding:0 12px}.composer-tools>.icon{width:36px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
|
.search{font-size:12px;gap:8px}.search kbd{white-space:nowrap}.voice-button{position:relative;width:48px;min-width:48px;height:48px;padding:0;border-radius:9px}.voice-symbol{width:30px;height:30px;gap:3px;background:none;padding:2px}.voice-symbol i{width:3px;height:3px}.voice-button #voiceLabel{position:absolute;bottom:57px;right:0;white-space:nowrap;background:var(--ink);color:var(--bg);font-size:11px;padding:7px 9px;border-radius:5px;opacity:0;pointer-events:none}.voice-button:hover #voiceLabel,.voice-button:focus-visible #voiceLabel{opacity:1}.right-tools:before{content:'Talk';font-size:11px;color:var(--quiet);margin-right:3px}.voice-session h2:focus{outline:none}.demo-label{max-width:220px;line-height:1.4}@media(max-width:720px){.voice-button{width:48px;min-width:48px;height:48px}.right-tools{gap:8px}.right-tools:before{font-size:11px}.voice-symbol{width:30px;height:30px}}
|
||||||
|
.settings-window{width:min(850px,calc(100% - 32px));max-height:90dvh;padding:0;overflow:auto}.settings-window>.dialog-head{padding:22px 26px;border-bottom:1px solid var(--line)}.settings-window h2{margin:0}.settings-subtitle{margin:6px 0 0!important}.settings-layout{display:flex;min-height:420px}.settings-nav{width:195px;flex-shrink:0;background:var(--side);padding:18px 12px;border-right:1px solid var(--line)}.settings-nav button{width:100%;text-align:left;padding:0 12px;font-size:13px;margin-bottom:4px}.settings-nav .selected{background:var(--wash);color:var(--rose);font-weight:600}.settings-nav .admin-link{border-top:1px solid var(--line);margin-top:18px;border-radius:0;padding-top:10px;min-height:54px}.admin-link span{font-size:9px;margin-left:4px;color:var(--quiet)}.settings-body{flex:1;min-width:0;padding:26px 30px}.settings-body h3{font-size:18px;font-weight:600;margin:0 0 8px}.settings-body h4{font-size:13px;margin:24px 0 12px}.settings-body p{margin:8px 0 20px}.account-heading{display:flex;gap:12px;align-items:center;margin:22px 0}.account-heading .avatar{width:44px;height:44px;font-size:16px}.account-heading small,.dialog-row small,.admin-account small{display:block;margin-top:5px;color:var(--quiet);font-size:11px;line-height:1.5}.field-label{display:block;font-size:12px;font-weight:550;margin:15px 0 8px}.settings-primary{background:var(--rose);color:var(--on);padding:0 16px;margin:15px 0;font-size:12px;font-weight:550}.settings-primary:hover{background:var(--ink);color:var(--bg)}.settings-preview{padding:14px 26px;background:var(--side);border-top:1px solid var(--line);color:var(--quiet);font-size:11px}.settings-body select,#createAccountDialog select{border:1px solid var(--line);padding:10px 12px;border-radius:7px;background:var(--panel);color:var(--ink);max-width:200px;font:inherit;font-size:12px;min-height:44px}.theme-options{display:flex;gap:8px;flex-wrap:wrap}.theme-options button{display:flex;align-items:center;gap:8px;border:1px solid var(--line);padding:0 12px;font-size:12px}.theme-options i{height:12px;width:12px;border-radius:50%}.theme-options .selected{border-color:var(--rose);background:var(--wash)}.danger-text{color:#a32438}.dark .danger-text{color:#ff9aab}.admin-title{display:flex;align-items:center;justify-content:space-between}.admin-title button{font-size:12px;color:var(--quiet);padding:0 10px}.admin-note{font-size:11px;padding:12px;background:var(--wash);line-height:1.6;border-radius:7px;margin:14px 0}.admin-account{display:flex;align-items:center;gap:12px;padding:17px 0;border-bottom:1px solid var(--line)}.admin-account>div{flex:1;min-width:0}.admin-account strong{font-size:13px}.admin-account button{padding:0 10px;font-size:12px;background:var(--wash)}.status-pill{font-size:10px;color:var(--quiet);background:var(--side);padding:6px 8px;border-radius:6px;white-space:nowrap}.status-pill.pending{color:var(--rose);background:var(--wash)}@media(max-width:720px){.settings-window{width:calc(100% - 20px);max-height:94dvh}.settings-window>.dialog-head{padding:17px}.settings-layout{display:block}.settings-nav{width:100%;display:flex;overflow-x:auto;gap:4px;padding:8px 10px;border-right:0;border-bottom:1px solid var(--line)}.settings-nav button{width:auto;white-space:nowrap;padding:0 10px;margin:0}.settings-nav .admin-link{border:0;margin:0;padding:0 10px;min-height:44px;border-radius:8px}.settings-body{padding:22px 18px;min-height:390px}.settings-preview{padding:12px 18px}.admin-account{gap:7px}.admin-account small{font-size:10px}.dialog-row{gap:12px}.settings-body select{max-width:145px}.settings-body .dialog-row strong{font-size:12px}}
|
||||||
|
</style></head><body>
|
||||||
|
<svg hidden><defs><symbol id="plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol><symbol id="search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></symbol><symbol id="folder" viewBox="0 0 24 24"><path d="M3 7V5h6l2 2h10v13H3Z"/></symbol><symbol id="pin" viewBox="0 0 24 24"><path d="m9 3 6 0-1 7 4 4H6l4-4ZM12 14v7"/></symbol><symbol id="more" viewBox="0 0 24 24"><circle cx="5" cy="12" r=".8"/><circle cx="12" cy="12" r=".8"/><circle cx="19" cy="12" r=".8"/></symbol><symbol id="chevron" viewBox="0 0 24 24"><path d="m8 10 4 4 4-4"/></symbol><symbol id="attach" viewBox="0 0 24 24"><path d="m8 13 7-7a3 3 0 0 1 4 4L9 20a5 5 0 0 1-7-7L13 2"/></symbol><symbol id="arrow" viewBox="0 0 24 24"><path d="M12 19V5m-6 6 6-6 6 6"/></symbol><symbol id="lock" viewBox="0 0 24 24"><rect x="6" y="10" width="12" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4"/></symbol><symbol id="copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4H4v12h4"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11v2a7 7 0 0 0 14 0v-2M12 20v2"/></symbol><symbol id="close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol><symbol id="branch" viewBox="0 0 24 24"><circle cx="6" cy="4" r="2"/><circle cx="6" cy="20" r="2"/><circle cx="18" cy="6" r="2"/><path d="M6 6v12m0-7h6a6 6 0 0 0 6-3"/></symbol><symbol id="activity" viewBox="0 0 24 24"><path d="M3 12h4l3-7 4 14 3-7h4"/></symbol></defs></svg>
|
||||||
|
<div class="app"><button class="drawer-scrim" aria-label="Close navigation" onclick="drawer(false)"></button><aside class="sidebar" aria-label="Main navigation"><div class="brand"><span class="brand-square" aria-hidden="true"></span>Hope</div><button class="new" id="newChat"><svg><use href="#plus"/></svg>New conversation</button><button class="search" onclick="openDialog('searchDialog')"><svg><use href="#search"/></svg>Search conversations<kbd>⌘ K</kbd></button><nav><div class="group"><div class="group-head">Workspaces<button class="icon" aria-label="Create workspace" onclick="openDialog('workspaceDialog')"><svg style="width:15px"><use href="#plus"/></svg></button></div><button class="navrow" onclick="openDialog('workspaceDialog')"><svg><use href="#folder"/></svg>Personal<span class="count">8</span></button><button class="navrow" onclick="openDialog('workspaceDialog')"><svg><use href="#folder"/></svg>Writing<span class="count">3</span></button></div><div class="group"><div class="group-head">Pinned</div><button class="navrow active" id="sampleChat"><svg><use href="#pin"/></svg>A little room to reset</button><button class="navrow" onclick="toast('Sample conversation · preview only')">Ideas worth keeping</button></div><div class="group"><div class="group-head">Recent</div><button class="navrow" onclick="toast('Sample conversation · preview only')">Planning a slower weekend</button><button class="navrow" onclick="toast('Sample conversation · preview only')">A note to future me</button><button class="navrow" onclick="toast('Sample conversation · preview only')">The things I want to make</button></div></nav><div class="sidebar-bottom"><button class="navrow" onclick="openDialog('libraryDialog')"><svg><use href="#folder"/></svg>Library</button><button class="navrow" onclick="openDialog('activityDialog')"><svg><use href="#activity"/></svg>Activity</button><button class="profile" onclick="openDialog('settingsDialog')"><span class="avatar">S</span><span>Shane<small>Personal account</small></span><svg><use href="#chevron"/></svg></button></div></aside>
|
||||||
|
<main class="main" id="main"><header class="topbar"><button class="icon mobile-menu" aria-label="Open navigation" onclick="drawer(true)"><svg><use href="#menu"/></svg></button><div class="heading"><strong id="title">A little room to reset</strong><small>Personal <span>/</span> Conversation</small></div><div class="topbar-right"><span class="badge"><svg><use href="#lock"/></svg>Only you</span><button class="icon theme-top" aria-label="Switch light or dark appearance" onclick="theme()"><svg><use href="#sun"/></svg></button><button class="icon" aria-label="Conversation actions" popovertarget="actions"><svg><use href="#more"/></svg></button></div></header>
|
||||||
|
<div class="content" id="content"><section class="thread" id="thread"><div class="date">Today</div><div class="user-message">Help me make a little room for myself this week.</div><article class="assistant"><div class="assistant-heading"><span class="mini-square" aria-hidden="true"></span>Hope</div><div class="prose"><p>Let’s make it feel lighter, not turn it into another project.</p><p>You don’t need a perfect reset. Just a few small places in the week that belong to you.</p><h2>Start with three small things</h2><ul><li><strong>One quiet morning.</strong> Keep the first twenty minutes free of messages. Coffee, a window, whatever feels like yours.</li><li><strong>One thing you can let go of.</strong> Find a commitment you made out of habit, not because it matters to you.</li><li><strong>One thing to look forward to.</strong> Put something small on the calendar before the week fills itself.</li></ul><p>What’s taking up the most space right now—your schedule, or what’s on your mind?</p></div><div class="message-actions"><button class="icon" aria-label="Copy response" onclick="toast('Response copied · preview')"><svg><use href="#copy"/></svg></button><button class="icon" aria-label="Branch conversation" onclick="toast('New branch · preview')"><svg><use href="#branch"/></svg></button><button class="icon" aria-label="More response actions" onclick="toast('Response actions · preview')"><svg><use href="#more"/></svg></button><span class="time">Just now</span></div></article></section>
|
||||||
|
<section class="empty" id="empty" hidden><span class="brand-square" aria-hidden="true"></span><h1>A little space for you.</h1><p>Think out loud, work something through, or make something new.<br>You can type, or talk to Hope.</p><div class="prompts"><button onclick="suggest('Help me think something through.')">Think something through</button><button onclick="suggest('Help me make a plan.')">Make a plan</button><button onclick="suggest('Let’s explore an idea.')">Explore an idea</button></div></section>
|
||||||
|
<section class="voice-session live" id="voiceSession" hidden aria-labelledby="voiceHeading"><div class="eyeline">Realtime conversation · interaction preview</div><div class="persona" id="persona" aria-hidden="true"></div><h2 id="voiceHeading">I’m listening.</h2><p class="sub" id="voiceSub">Just talk. You can interrupt me naturally.</p><div class="voice-toolbar"><button id="mute" aria-pressed="false"><svg><use href="#mic"/></svg><span>Mute</span></button><button onclick="openDialog('voiceDialog')">Ava <svg style="width:14px"><use href="#chevron"/></svg></button><button class="end" id="endVoice"><svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>End</button></div><div class="voice-transcript"><p><strong>You · sample transcript</strong>My schedule, mostly. Everything feels a little crowded.</p><p><strong>Hope · sample transcript</strong>Then let’s find one thing you can take off your plate. What could wait until next week?</p></div><button style="margin-top:18px;font-size:12px;color:var(--rose)" id="demoState">Preview Hope speaking</button></section></div>
|
||||||
|
<div class="composer-area"><div class="session-strip" id="sessionStrip" hidden><span class="live-dot"></span><span id="stripText">Voice conversation active · preview</span><button id="backText">Back to text</button></div><form class="composer" id="composer"><label for="message" hidden>Message Hope</label><textarea id="message" rows="2" placeholder="What’s on your mind?"></textarea><div class="composer-tools"><button type="button" class="icon" aria-label="Attach a file" onclick="openDialog('attachmentDialog')"><svg><use href="#plus"/></svg></button><button type="button" class="mode-button" id="research" aria-pressed="false">Research<svg><use href="#chevron"/></svg></button><button type="button" class="tool-label" onclick="openDialog('modelDialog')">Auto<svg style="width:12px"><use href="#chevron"/></svg></button><div class="right-tools"><button class="voice-button" type="button" id="startVoice" aria-label="Start realtime voice conversation"><span class="voice-symbol" aria-hidden="true"></span><span id="voiceLabel">Talk to Hope</span></button><button type="submit" class="icon send-button" id="send" aria-label="Send message" disabled><svg><use href="#arrow"/></svg></button></div></div></form><div class="composer-note"><span class="hint"><svg><use href="#lock"/></svg>Your conversation, private to you.</span><span class="keyhint">Enter to send · Shift + Enter for a new line</span></div></div></main></div><div class="demo-label">DESIGN PREVIEW · No microphone or backend connection</div>
|
||||||
|
<div id="actions" popover class="menu-pop"><button onclick="toast('Conversation pinned · preview')">Pin conversation</button><button onclick="openDialog('renameDialog');document.getElementById('actions').hidePopover()">Rename</button><button onclick="toast('Move to workspace · preview')">Move to workspace</button><button onclick="toast('Download conversation · preview')">Export conversation</button><button onclick="toast('Archive conversation · preview')">Archive</button><button onclick="toast('Delete requires confirmation in production')">Delete…</button></div>
|
||||||
|
<dialog id="settingsDialog" class="settings-window" aria-labelledby="settingsTitle"><div class="dialog-head"><div><h2 id="settingsTitle">Settings</h2><p class="settings-subtitle">Your Hope, the way you like it.</p></div><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close settings"><svg><use href="#close"/></svg></button></div><div class="settings-layout"><nav class="settings-nav" aria-label="Settings sections"><button class="selected" onclick="settingsPage('profile',this)">Account</button><button onclick="settingsPage('appearance',this)">Appearance</button><button onclick="settingsPage('preferences',this)">Voice & model</button><button onclick="settingsPage('privacy',this)">Memory & data</button><button onclick="settingsPage('security',this)">Security</button><button id="adminTab" class="admin-link" onclick="settingsPage('admin',this)">Administration <span>Admin</span></button></nav><div class="settings-body"><section data-settings="profile"><h3>Your account</h3><p>Personal settings belong here, separate from your conversations.</p><div class="account-heading"><span class="avatar">S</span><div><strong>Shane</strong><small>@shane · Administrator · sample account</small></div></div><label class="field-label" for="displayName">Display name</label><input id="displayName" class="search-input" value="Shane"><div class="dialog-row"><span>Username</span><span>shane</span></div><button class="settings-primary" onclick="toast('Profile saved in this preview only')">Save changes</button><div class="dialog-row"><div><strong>Sign out</strong><small>End this session on this device.</small></div><button onclick="confirmPreview('Sign out of Hope?', 'Production will end this device’s session and clear private local state. This preview will not sign you out.')">Sign out</button></div></section><section data-settings="appearance" hidden><h3>Appearance</h3><p>One visual identity across text, realtime voice, and your devices.</p><div class="dialog-row"><span>Appearance</span><select aria-label="Appearance" onchange="document.body.classList.toggle('dark',this.value==='dark'||this.value==='system'&&matchMedia('(prefers-color-scheme:dark)').matches)"><option>Light</option><option value="dark">Dark</option><option value="system">System</option></select></div><h4>Color theme</h4><div class="theme-options"><button class="selected" onclick="previewPalette('opal',this)"><i style="background:#a64270"></i>Opal Pink</button><button onclick="previewPalette('graphite',this)"><i style="background:#505969"></i>Graphite</button><button onclick="previewPalette('ocean',this)"><i style="background:#27658c"></i>Ocean</button><button onclick="previewPalette('sage',this)"><i style="background:#3c704d"></i>Sage</button><button onclick="previewPalette('lavender',this)"><i style="background:#70529c"></i>Lavender</button></div></section><section data-settings="preferences" hidden><h3>Voice & model</h3><p>Voice is a live conversation—not a dictation feature.</p><div class="dialog-row"><div><strong>Hope’s voice</strong><small>Applies to spoken responses.</small></div><select aria-label="Preferred voice"><option>Ava · sample</option><option>Andrew · sample</option></select></div><div class="dialog-row"><div><strong>Model</strong><small>Production lists your available models.</small></div><select aria-label="Preferred model"><option>Account default · sample</option></select></div><div class="dialog-row"><span>Thinking</span><select aria-label="Thinking level"><option>None</option><option>Low</option><option selected>Medium</option><option>High</option></select></div><p>Model and thinking changes apply to the next request, without interrupting an active conversation. Voice options here are illustrative.</p><button class="settings-primary" onclick="toast('Preferences saved in this preview only')">Save preferences</button></section><section data-settings="privacy" hidden><h3>Memory & data</h3><p>You control retained personal context and files. Workspaces organize conversations; they do not isolate tool access.</p><div class="dialog-row"><div><strong>Saved memory</strong><small>Review what Hope remembers.</small></div><button onclick="toast('Memory editor opens here after implementation')">Review</button></div><div class="dialog-row"><div><strong>Personal context</strong><small>Review your user profile notes.</small></div><button onclick="toast('Personal context editor · preview')">Review</button></div><div class="dialog-row"><span>Uploaded files & artifacts</span><button onclick="openDialog('libraryDialog')">Open Library</button></div><div class="dialog-row"><div><strong>Clear saved memory</strong><small>Requires explicit confirmation.</small></div><button class="danger-text" onclick="confirmPreview('Clear saved memory?', 'This does not delete conversations, files, or all Hermes history. An active session may retain cached context. No data is changed in this preview.')">Clear…</button></div><p>Temporary no-memory chatsLine truncated
|
||||||
|
<dialog id="createAccountDialog" aria-labelledby="createTitle"><div class="dialog-head"><h2 id="createTitle">Create account</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close create account">×</button></div><form id="createPreviewAccount"><label class="field-label" for="newUser">Username</label><input class="search-input" id="newUser" required maxlength="64" autocomplete="off"><label class="field-label" for="newName">Display name</label><input class="search-input" id="newName" required maxlength="120"><label class="field-label" for="newRole">Role</label><select id="newRole"><option>User</option><option>Administrator</option></select><label class="field-label" for="newPassword">Initial password</label><input class="search-input" id="newPassword" type="password" minlength="12" required autocomplete="new-password"><p>Use a fictional password for this preview. Nothing is transmitted or persisted. Production provisions a separate private assistant.</p><button class="settings-primary" type="submit">Create sample account</button></form></dialog>
|
||||||
|
<dialog id="manageAccountDialog" aria-labelledby="manageTitle"><div class="dialog-head"><h2 id="manageTitle">Manage account</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close manage account">×</button></div><div class="dialog-row"><span>Role</span><span id="managedRole"></span></div><p>Role is visible at creation. Changing an existing role requires an explicitly supported server contract; no fake role editor is offered here.</p><div class="dialog-row"><span>Private assistant</span><span>Owned by this account</span></div><button class="navrow" onclick="toast('Provisioning retry · simulated only')">Retry failed provisioning</button><button class="navrow" onclick="confirmPreview('Reset this account’s password?', 'Production requires a new password and revokes existing sessions. This preview does not alter credentials.')">Reset password…</button><button class="navrow danger-text" onclick="confirmPreview('Delete this account?', 'Production must explicitly confirm account deletion and its retention policy. No account, container, or private files are deleted here.')">Delete account…</button></dialog>
|
||||||
|
<dialog id="confirmDialog" aria-labelledby="confirmTitle"><h2 id="confirmTitle"></h2><p id="confirmBody"></p><div style="display:flex;justify-content:flex-end;gap:8px"><button onclick="this.closest('dialog').close()">Cancel</button><button class="settings-primary" onclick="this.closest('dialog').close();toast('Confirmed in preview only · no live changes')">Confirm preview</button></div></dialog>
|
||||||
|
<dialog id="searchDialog"><div class="dialog-head"><h2>Find a conversation</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close search">×</button></div><input class="search-input" placeholder="Search titles and messages" aria-label="Search titles and messages"><button class="navrow" onclick="showSample();this.closest('dialog').close()">A little room to reset</button><p>Sample search results · no account data is accessed.</p></dialog>
|
||||||
|
<dialog id="workspaceDialog"><div class="dialog-head"><h2>Your workspaces</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close workspaces">×</button></div><div class="dialog-row"><span>Personal</span><span>8 conversations</span></div><div class="dialog-row"><span>Writing</span><span>3 conversations</span></div><p>Group conversations and set instructions here. Workspace editing belongs in this view—not beneath every sidebar item.</p></dialog>
|
||||||
|
<dialog id="libraryDialog"><div class="dialog-head"><h2>Library</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close library">×</button></div><div class="dialog-row"><span>Saved artifacts</span><span>2</span></div><div class="dialog-row"><span>Uploaded files</span><span>4</span></div><p>Files and saved work live together. They don’t compete with your conversation history.</p></dialog>
|
||||||
|
<dialog id="activityDialog"><div class="dialog-head"><h2>Activity</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close activity">×</button></div><div class="dialog-row"><span>Weekly reflection</span><span>Paused · sample</span></div><p>Scheduled tasks and assistant activity. No tasks run from this preview.</p></dialog>
|
||||||
|
<dialog id="attachmentDialog"><div class="dialog-head"><h2>Add to the conversation</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close attachments">×</button></div><button class="navrow" onclick="toast('File picker · preview only')"><svg><use href="#attach"/></svg>Upload a file or photo</button><button class="navrow" onclick="openDialog('libraryDialog')"><svg><use href="#folder"/></svg>Choose from your library</button><p>Documents and images appear as removable attachment chips above the composer controls.</p></dialog>
|
||||||
|
<dialog id="voiceDialog"><div class="dialog-head"><h2>Hope’s voice</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close voice selection">×</button></div><button class="navrow active" onclick="toast('Ava selected · preview')">Ava · Warm and natural ✓</button><button class="navrow" onclick="toast('Voice selection · preview')">Andrew · Calm and clear</button><p>Your voice choice applies to the conversation. This preview plays no audio.</p></dialog>
|
||||||
|
<dialog id="modelDialog"><div class="dialog-head"><h2>Model & thinking</h2><button class="icon" onclick="this.closest('dialog').close()" aria-label="Close model selection">×</button></div><div class="dialog-row"><span>Model</span><span>Auto · sample</span></div><div class="dialog-row"><span>Thinking</span><span>Standard</span></div><p>Production will show the account’s actual available models, not invented choices.</p></dialog>
|
||||||
|
<dialog id="renameDialog"><h2>Rename conversation</h2><form method="dialog"><input class="search-input" id="renameValue" aria-label="Conversation name" value="A little room to reset"><div style="display:flex;justify-content:flex-end;gap:8px;margin-top:15px"><button>Cancel</button><button id="renameSave" style="padding:0 15px;background:var(--wash)">Save</button></div></form></dialog>
|
||||||
|
<div class="toast" role="status" id="toast" hidden></div>
|
||||||
|
<script>
|
||||||
|
const $=id=>document.getElementById(id);let voice=false,voiceVisible=false,speaking=false,previous='thread',toastTimer;document.querySelectorAll('.voice-symbol').forEach(el=>{for(let i=0;i<25;i++)el.append(document.createElement('i'))});for(let i=0;i<169;i++){const d=document.createElement('i');d.style.setProperty('--n',i%13);$('persona').append(d)}
|
||||||
|
function toast(text){$('toast').textContent=text;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').hidden=true,2600)}function openDialog(id){drawer(false);$(id).showModal()}function theme(){document.body.classList.toggle('dark')}function drawer(on){document.body.classList.toggle('drawer-open',on);$('main').inert=on;document.querySelector('.mobile-menu').setAttribute('aria-expanded',on)}function display(id){['thread','empty','voiceSession'].forEach(x=>$(x).hidden=x!==id);if(id==='voiceSession'){$(previous).hidden=false;$('content').prepend($('voiceSession'))} $('content').scrollTop=0}function showSample(){if(voice)endVoice();previous='thread';display('thread');$('title').textContent='A little room to reset';drawer(false)}function suggest(text){$('message').value=text;$('message').dispatchEvent(new Event('input'));$('message').focus()}
|
||||||
|
$('newChat').onclick=()=>{if(voice)endVoice();previous='empty';display('empty');$('title').textContent='New conversation';$('message').value='';$('send').disabled=true;drawer(false);$('message').focus()};$('sampleChat').onclick=showSample;
|
||||||
|
$('message').oninput=()=>{$('send').disabled=voice||!$('message').value.trim()};$('message').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();if(!$('send').disabled)$('composer').requestSubmit()}};$('composer').onsubmit=e=>{e.preventDefault();const text=$('message').value.trim();if(!text)return;if(voice){toast('End voice to send. Your typed draft is kept.');return}if(previous==='empty'){$('thread').replaceChildren();previous='thread';$('title').textContent=text.slice(0,35)}display('thread');const msg=document.createElement('div');msg.className='user-message';msg.textContent=text;msg.style.marginTop='24px';$('thread').append(msg);const reply=document.createElement('div');reply.className='assistant prose';reply.textContent='This is an interface preview. Your message stays here locally; no model or backend is connected.';$('thread').append(reply);$('message').value='';$('send').disabled=true;$('content').scrollTop=$('content').scrollHeight};
|
||||||
|
$('startVoice').onclick=()=>{voice=true;voiceVisible=true;display('voiceSession');$('send').disabled=true;$('sessionStrip').hidden=false;$('voiceLabel').textContent='Return to voice';$('startVoice').setAttribute('aria-label','Return to realtime voice conversation');$('voiceHeading').focus();toast('Voice interaction preview · microphone is not accessed')};function endVoice(){voice=false;voiceVisible=false;display(previous);$('send').disabled=!$('message').value.trim();$('backText').textContent='Back to text';$('sessionStrip').hidden=true;$('voiceLabel').textContent='Talk to Hope';$('startVoice').setAttribute('aria-label','Start realtime voice conversation');$('startVoice').focus()}$('endVoice').onclick=endVoice;$('backText').onclick=()=>{voiceVisible=!voiceVisible;display(voiceVisible?'voiceSession':previous);$('backText').textContent=voiceVisible?'Back to text':'Show voice';$('stripText').textContent='Voice remains active · preview';$('message').focus()};$('mute').onclick=()=>{const muted=$('mute').getAttribute('aria-pressed')!=='true';$('mute').setAttribute('aria-pressed',muted);$('mute').querySelector('span').textContent=muted?'Unmute':'Mute';$('voiceHeading').textContent=muted?'Microphone paused.':speaking?'I’m with you.':'I’m listening.';$('voiceSub').textContent=muted?'You’re still connected. Unmute whenever you’re ready.':'Just talk. You can interrupt me naturally.'};$('demoState').onclick=()=>{speaking=!speaking;$('voiceHeading').textContent=speaking?'I’m with you.':'I’m listening.';$('voiceSub').textContent=speaking?'Hope is speaking. You can interrupt anytime.':'Just talk. You can interrupt me naturally.';$('demoState').textContent=speaking?'Preview listening':'Preview Hope speaking'};$('research').onclick=()=>{const on=$('research').getAttribute('aria-pressed')!=='true';$('research').setAttribute('aria-pressed',on);$('research').style.color=on?'var(--rose)':'var(--quiet)';toast(on?'Research mode · preview':'Standard conversation · preview')};$('renameSave').onclick=()=>{$('title').textContent=$('renameValue').value||'Untitled conversation'};document.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='k'){e.preventDefault();openDialog('searchDialog')}if(e.key==='Escape')drawer(false)});
|
||||||
|
|
||||||
|
function settingsPage(name,button){document.querySelectorAll('[data-settings]').forEach(el=>el.hidden=el.dataset.settings!==name);document.querySelectorAll('.settings-nav button').forEach(el=>el.classList.toggle('selected',el===button));}
|
||||||
|
function confirmPreview(title,body){$('confirmTitle').textContent=title;$('confirmBody').textContent=body;openDialog('confirmDialog')}
|
||||||
|
function manageAccount(name,role){$('manageTitle').textContent='Manage '+name;$('managedRole').textContent=role;openDialog('manageAccountDialog')}
|
||||||
|
function previewRole(){const user=!$('adminTab').hidden;$('adminTab').hidden=user;if(user){settingsPage('profile',document.querySelector('.settings-nav button'));toast('User preview · administration hidden. Reopen this HTML to restore admin preview.')}}
|
||||||
|
function previewPalette(name,button){const colors={opal:'#a64270',graphite:'#505969',ocean:'#27658c',sage:'#3c704d',lavender:'#70529c'};document.documentElement.style.setProperty('--rose',colors[name]);document.querySelectorAll('.theme-options button').forEach(el=>el.classList.toggle('selected',el===button));toast('Theme accent preview · '+name)}
|
||||||
|
$('createPreviewAccount').onsubmit=e=>{e.preventDefault();const name=$('newName').value,user=$('newUser').value,role=$('newRole').value;const row=document.createElement('div');row.className='admin-account';const info=document.createElement('div'),strong=document.createElement('strong'),small=document.createElement('small');strong.textContent=name;small.textContent='@'+user+' · '+role;info.append(strong,small);const status=document.createElement('span');status.className='status-pill pending';status.textContent='Sample only';const manage=document.createElement('button');manage.textContent='Manage';manage.onclick=()=>manageAccount(name,role);row.append(info,status,manage);$('adminAccounts').append(row);$('newPassword').value='';$('createAccountDialog').close();toast('Sample account added · no provisioning or saved password')};
|
||||||
|
</script></body></html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"defaultPalette": "opal",
|
||||||
|
"defaultAppearance": "system",
|
||||||
|
"primitives": {"white": "#FFFFFF", "alertLight": [0.675, 0.208, 0.282], "alertDark": [1.0, 0.604, 0.675]},
|
||||||
|
"roles": {
|
||||||
|
"ground": "Screen background",
|
||||||
|
"surface": "Opaque controls, grouped rows and dock",
|
||||||
|
"personaSurface": "Opaque square persona stage",
|
||||||
|
"ink": "Primary text",
|
||||||
|
"quiet": "Supporting text, placeholders and inactive icons; never reduce opacity for body copy",
|
||||||
|
"accent": "Primary actions and current selection; Nearlight.rosewood compatibility",
|
||||||
|
"onAccent": "Primary action label",
|
||||||
|
"alert": "Error text and destructive actions; independent of palette",
|
||||||
|
"onAlert": "Label on solid destructive action",
|
||||||
|
"focus": "Keyboard focus ring against surrounding surfaces",
|
||||||
|
"controlBorder": "Essential control boundary; not the decorative separator"
|
||||||
|
},
|
||||||
|
"sharedRoles": {
|
||||||
|
"light": {"alert": "alertLight", "onAlert": "white"},
|
||||||
|
"dark": {"alert": "alertDark", "onAlert": "$ground"}
|
||||||
|
},
|
||||||
|
"derivedRoles": {
|
||||||
|
"separator": {"foreground": "ink", "alpha": 0.14, "background": "surface", "purpose": "Decorative separator only; not an essential boundary"},
|
||||||
|
"selectedWash": {"foreground": "accent", "alpha": 0.10, "background": "surface", "purpose": "Selected background; pair with selected semantics/checkmark"},
|
||||||
|
"errorWash": {"foreground": "alert", "alpha": 0.08, "background": "surface", "purpose": "Error background; pair with icon, explicit message and recovery"}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"actionBackground": "accent", "actionForeground": "onAccent",
|
||||||
|
"inputBackground": "surface", "inputForeground": "ink", "inputPlaceholder": "quiet",
|
||||||
|
"inputBorder": "controlBorder", "inputFocus": "focus", "personaBackground": "personaSurface",
|
||||||
|
"errorBackground": "errorWash", "errorForeground": "alert"
|
||||||
|
},
|
||||||
|
"palettes": {
|
||||||
|
"opal": {
|
||||||
|
"label": "Opal Pink",
|
||||||
|
"light": {"ground": "#FFFDFD", "surface": "#FFF7FA", "personaSurface": "#FFF2F7", "ink": "#302B40", "quiet": "#706074", "accent": "#A64270", "onAccent": "#FFFFFF", "focus": "#A64270", "controlBorder": "#706074"},
|
||||||
|
"dark": {"ground": "#201820", "surface": "#302330", "personaSurface": "#3B2836", "ink": "#FFF2F7", "quiet": "#CBB6C7", "accent": "#F0A8C8", "onAccent": "#201820", "focus": "#F0A8C8", "controlBorder": "#CBB6C7"}
|
||||||
|
},
|
||||||
|
"graphite": {
|
||||||
|
"label": "Graphite",
|
||||||
|
"light": {"ground": "#FDFDFE", "surface": "#F3F4F6", "personaSurface": "#EDEEF2", "ink": "#272A33", "quiet": "#5E626E", "accent": "#505969", "onAccent": "#FFFFFF", "focus": "#505969", "controlBorder": "#5E626E"},
|
||||||
|
"dark": {"ground": "#191B20", "surface": "#25282F", "personaSurface": "#30343D", "ink": "#F3F4F7", "quiet": "#BCC1CD", "accent": "#BFC9DC", "onAccent": "#191B20", "focus": "#BFC9DC", "controlBorder": "#BCC1CD"}
|
||||||
|
},
|
||||||
|
"ocean": {
|
||||||
|
"label": "Ocean",
|
||||||
|
"light": {"ground": "#FCFEFF", "surface": "#F0F7FB", "personaSurface": "#E7F2F9", "ink": "#233442", "quiet": "#536878", "accent": "#27658C", "onAccent": "#FFFFFF", "focus": "#27658C", "controlBorder": "#536878"},
|
||||||
|
"dark": {"ground": "#15212B", "surface": "#20313F", "personaSurface": "#283D4D", "ink": "#EDF7FF", "quiet": "#B3CBDE", "accent": "#97CEEE", "onAccent": "#15212B", "focus": "#97CEEE", "controlBorder": "#B3CBDE"}
|
||||||
|
},
|
||||||
|
"sage": {
|
||||||
|
"label": "Sage",
|
||||||
|
"light": {"ground": "#FCFEFC", "surface": "#F0F6F1", "personaSurface": "#E7F0E9", "ink": "#29382D", "quiet": "#596B5D", "accent": "#3C704D", "onAccent": "#FFFFFF", "focus": "#3C704D", "controlBorder": "#596B5D"},
|
||||||
|
"dark": {"ground": "#19231C", "surface": "#26342A", "personaSurface": "#304236", "ink": "#F0F8F1", "quiet": "#B9CFBE", "accent": "#A5D4AF", "onAccent": "#19231C", "focus": "#A5D4AF", "controlBorder": "#B9CFBE"}
|
||||||
|
},
|
||||||
|
"lavender": {
|
||||||
|
"label": "Lavender",
|
||||||
|
"light": {"ground": "#FEFDFF", "surface": "#F6F2FC", "personaSurface": "#EEE8F8", "ink": "#342D43", "quiet": "#685E7B", "accent": "#70529C", "onAccent": "#FFFFFF", "focus": "#70529C", "controlBorder": "#685E7B"},
|
||||||
|
"dark": {"ground": "#201B29", "surface": "#2E273C", "personaSurface": "#3B324D", "ink": "#F7F1FF", "quiet": "#C8BADF", "accent": "#CFB4F4", "onAccent": "#201B29", "focus": "#CFB4F4", "controlBorder": "#C8BADF"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Approved Hope web interface overhaul
|
||||||
|
|
||||||
|
The user approved the standalone HTML proposal, including personal settings and administration, with “do it.” This document records the implementation boundary and evidence.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
- Square 48px Lightwell realtime action inside the composer; direct intentional connection initiation, not dictation and not a portal to another Start button.
|
||||||
|
- Inline voice console with actual connection, agent and microphone state, mute/end, selected-conversation ownership, and retained editable typed draft. Text send stays disabled while voice owns the conversation.
|
||||||
|
- Compact header and conversation-action overflow; workspace editing behind progressive controls; sidebar Library, Activity, and Account & settings.
|
||||||
|
- Library mounts existing files/artifact controls. Activity mounts actual scheduled tasks. Memory controls are mounted in personal settings without duplicating owners or handlers.
|
||||||
|
- Real personal settings: readonly account identity, saved appearance, actual voice catalog, server-managed model/thinking, memory, security guidance, current-device sign-out.
|
||||||
|
- Real administrator account list, creation, password reset, deletion, and retry for failed provisioning. No invented profile writes, existing-role editor, device-session list, connector management or temporary-chat privacy.
|
||||||
|
- Existing cookie auth, role enforcement and backend contracts preserved. Account change closes dialogs and clears private UI. Appearance loads/saves are account-epoch guarded.
|
||||||
|
- Shared semantic theme surfaces fix the legacy light-theme voice transcript contrast defect; reduced-motion matrix avoids live layout size mutations.
|
||||||
|
|
||||||
|
## Qualification before deployment
|
||||||
|
- Integrated browser/theme/package/token/rollback selection: 45 passed, 304 subtests.
|
||||||
|
- Final conversation/settings recheck after appearance-save epoch guard: 12 passed.
|
||||||
|
- Responsive captures verified 1440/390/320 widths: no horizontal overflow, composer action exactly 48×48.
|
||||||
|
- Mocked SDK lifecycle covers direct start, real-readiness gating, mute/unmute, cid binding, stop and microphone rejection. This is not acoustic evidence.
|
||||||
|
- Account/backend isolation regression selection: 25 passed.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
Initial clean committed archive e59c712 was qualified live, then final administrator-table readability polish shipped from clean archive 19df338. Separate NAS staging paths preserve the dirty NAS checkout. Final voice image sha256:e926091e67d032c541dec2efad5753e0e2cbf934cf54ecafabf3129c1af3ac1e. Existing control API image retained. Final deployment produced healthy services with protected quiesced account-state backup and retained rollback containers suffix 20261003T173115Z (initial release rollback suffix 20261003T172419Z). Final settings/conversation offline recheck: 12 passed. External Hope Music is outside this release.
|
||||||
|
|
||||||
|
## Live verification
|
||||||
|
Live qualification scripts are explicit, credential-protected and use disposable conversations. Personal settings/admin verification is read-only: no account or memory changes. Text verification exercises stream completion, overflow Pin, reload and cleanup. Synthetic microphone verification exercises Azure STT → Hermes → Azure TTS → browser audio, not physical acoustic quality. Collected live release verification passed: all personal settings, actual admin list and mobile settings navigation; text response/overflow Pin/reload; direct composer-start synthetic speech with Azure user transcription, expected Hermes answer, playing LiveKit TTS track, 32,067 received audio bytes, zero browser errors, and successful disposable voice-conversation cleanup. No live accounts or memory were mutated by settings verification. Repeated verification on final deployed archive 19df338 also passed all three scripts: settings/admin/mobile, text/Pin/reload, and direct square-composer speech with 23,564 received audio bytes, playing audio, user/assistant transcripts, zero errors and successful disposable-conversation cleanup.
|
||||||
|
|
||||||
|
## Remaining boundaries
|
||||||
|
This release changes the web interface; it does not install a native redesign. Physical microphone/speaker quality and device-specific mobile keyboard behavior remain separate human/device qualification. Native prior signed app installation is not evidence of this web overhaul. Unsupported capabilities remain unavailable rather than simulated in production.
|
||||||
@@ -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,65 @@
|
|||||||
|
# Advanced Hope chat surfaces
|
||||||
|
|
||||||
|
Implementation contract, not a deployment receipt. All routes authenticate via existing current_user (bearer or cookie with mutation Origin checks). Owner is derived server-side. Missing/cross-account/deleted objects return404; revoked sessions401. Hermes alone runs tools/reasoning. Provider credentials, URLs, raw job configuration and disk paths are never public.
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
- POST `/api/attachments`: raw bytes, MIME `Content-Type`, `X-Filename` display name. Returns201 `{attachment}`. No multipart wrapper.
|
||||||
|
- GET `/api/attachments`: `{attachments:[],next_cursor:null}` (bounded first200).
|
||||||
|
- GET `/api/attachments/{id}`: `{attachment}`.
|
||||||
|
- GET `/api/attachments/{id}/content`: private bytes, download disposition, no-store, nosniff.
|
||||||
|
- DELETE `/api/attachments/{id}`: `{ok:true}`. Removes stored bytes and tombstones metadata.
|
||||||
|
|
||||||
|
Attachment exactly `{id,filename,mime,size,extraction_status,created_at}`. IDs UUID hex32, Unix timestamps. Extraction statuses ready|truncated|image|unavailable|encrypted|failed. Supported MIME text/plain,text/markdown,application/pdf,image/png,image/jpeg,image/webp. Defaults8MiB per upload,64MiB per account,512MiB global disk; MIME mismatch415, invalid filename422, limit413. No HTML/SVG, arbitrary paths or remote fetch. UTF8 text extracted, PDFs via pypdf (encrypted/failure explicitly blocked as provider input). Text capped100000 characters. Images are inline image_url payloads only for explicitly qualified selected model; otherwise409. Signature validation does not imply antivirus or full image decoder validation.
|
||||||
|
|
||||||
|
Core integration `FeatureStore.provider_content(owner, attachment_ids, vision=False)` is async. At most8 distinct IDs. Never provider-input another owner's upload. Images use OpenAI message-content shape, not guessed Responses schema. Core supplies qualified vision flag. Local store persists in injected Accounts DB with FK plus private flat files. Account deletion denies access immediately and retains disk data by policy; no implicit erase.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- GET `/api/artifacts`: `{artifacts:[],next_cursor:null}` (bounded first200). List entries metadata only, omitting content; select GET detail to load content.
|
||||||
|
- POST `/api/artifacts` JSON `{title,kind?:"markdown",content?:""}` →201 `{artifact}`.
|
||||||
|
- GET `/api/artifacts/{id}?revision=N`: `{artifact}`, historical content optional.
|
||||||
|
- GET `/api/artifacts/{id}/versions`: `{versions:[{revision,created_at}],next_cursor:null}`, descending revision.
|
||||||
|
- PATCH `/api/artifacts/{id}` JSON `{revision,title?,kind?,content?}` → `{artifact}`; missing/stale revision409.
|
||||||
|
- GET `/api/artifacts/{id}/export?revision=N`: UTF8 download, text/plain with .md for markdown, otherwise .txt. No executable inline HTML, PDF/DOCX generation claims.
|
||||||
|
- DELETE `/api/artifacts/{id}` → `{ok:true}`; historical versions inaccessible after tombstone.
|
||||||
|
|
||||||
|
Artifact `{id,title,kind,revision,content,created_at,updated_at}`. kind text|markdown|code. Title1–160 chars, default512KiB UTF8 content,200 artifacts including retained tombstones/account,100 versions/artifact. Cumulative retained version UTF8 bytes max32MiB/account and256MiB globally. Versions are not silently trimmed: explicit version limit/storage quota returns413, including deleted retained versions. Revision starts1, increments each edit. Historical versions currently retain content and time but title/kind reflect current metadata (not historical title).
|
||||||
|
|
||||||
|
## Scheduled tasks
|
||||||
|
|
||||||
|
Qualified gateway exact mapping `/api/jobs`; no second scheduler. Provider operations authenticated with account's private Hermes key. Public delivery is forced local; client cannot select providers, scripts, base URLs, external recipients or tools.
|
||||||
|
|
||||||
|
- GET `/api/tasks` → `{tasks:[],next_cursor:null}`; gateway query include_disabled=true.
|
||||||
|
- POST `/api/tasks` `{name,schedule,prompt,repeat?,paused?}` →201 `{task}`. Schedule is a Hermes schedule string, not a fabricated UI cron dialect. name1–200 chars, schedule1–200 chars, prompt1–5000 chars, repeat positive integer, paused boolean.
|
||||||
|
- PATCH `/api/tasks/{id}` `{name?,schedule?,prompt?,repeat?,enabled?}` → `{task}`. No optimistic revision supported by pinned Hermes jobs API.
|
||||||
|
- DELETE `/api/tasks/{id}` → `{ok:true}`.
|
||||||
|
- POST `/api/tasks/{id}/pause|resume|run` → `{task}`. Run triggers Hermes execution, not a Hope agent loop.
|
||||||
|
|
||||||
|
Task projection whitelist `{id,name?,schedule?,prompt?,enabled?,repeat?,created_at?,last_run_at?,next_run_at?}`. IDs hex12. Only id is guaranteed; optional fields preserve exact gateway type, clients must tolerate absent timestamps. No task-output-history API has been qualified. Invalid remote scheduling errors sanitized; unsupported capability501, unavailable503, upstream failure502.
|
||||||
|
|
||||||
|
## Memory controls
|
||||||
|
|
||||||
|
No gateway memory API exists. Trusted control adapter accesses only retained account `home/memories/MEMORY.md` and `USER.md`, never arbitrary file paths or separately authenticated WebUI.
|
||||||
|
|
||||||
|
- GET `/api/memory` → `{sections:[{section:"memory",content,revision},{section:"user",content,revision}]}`.
|
||||||
|
- PUT `/api/memory/{section}` `{content,revision}` → `{section,content,revision}`.
|
||||||
|
|
||||||
|
Revision SHA256 hex content digest. Empty content explicitly clears that section. UTF8 max64KiB/section. Unknown section404, stale revision409, unavailable storage503, unqualified adapter501. Updates persist files; already-running Hermes sessions may retain cached memory until a later run/session reload. No claim of live memory-cache invalidation. Do not confuse clearing files with deleting conversation history or all data. Protected fixed directories and no-follow file opens; runtime has independent filesystem writes, so revision is best-effort against concurrent Hermes edits, not cross-process transaction locking.
|
||||||
|
|
||||||
|
## Honest statuses and sync
|
||||||
|
|
||||||
|
- GET `/api/connectors` → `{connectors:[],available:false,reason,toolsets:[{name,enabled,configured}]}`. Read-only native tool configuration statuses from exact authenticated `/v1/toolsets`. No qualified connector credential/settings write API; do not present fabricated working OAuth/integration switches. Raw credentials/configuration are never returned.
|
||||||
|
- GET `/api/research` → `{available:bool,mode:"hermes_tools"|null,reason:string|null}`. Enabled only when the actual account manifest advertises enabled/configured `web` with both `web_search` and `web_extract`. Capability lookup failure is unavailable, never optimistic true. Native research means one ordinary Hermes run with source-verification/citation instruction, not a second Hope reasoning loop or special upstream research endpoint.
|
||||||
|
- Core message extension target POST `/api/conversations/{id}/messages` adds `mode?:"normal"|"research"`. Core must persist mode in idempotency payload and run settings; research calls `FeatureProvider.research_instruction(owner)` and supplies the returned static evidence/citations instruction to that same native Hermes run. Unqualified tools409. This module does not itself edit core message acceptance.
|
||||||
|
- GET `/api/features/sync?after=0` → `{changes:[{sequence,kind:"attachment"|"artifact",id,deleted,created_at}],cursor}`; max200 per response, owner-filtered tombstones. This cursor is independent of core conversation sync.
|
||||||
|
|
||||||
|
Core merges `local_capabilities(store)` and account-qualified `FeatureProvider.feature_capabilities(owner)` into its one `/api/capabilities`. Local helper reports image/tasks/memory false by default; provider qualifies jobs, managed auto vision, and available fixed memory storage. Constructor `FeatureStore(accounts, root_path, limits=None)`; router `create_feature_router(store, provider=None)`; default `FeatureProvider(accounts,http_client=None,state_dir=None)` has `aclose()` which app shutdown must call. Memory root defaults HOPE_HERMES_DATA_DIR matching provisioner. Research source verified pinned `749220ef` gateway handler `_handle_toolsets` and exact web toolset expansion. No live/deploy qualification performed here.
|
||||||
|
|
||||||
|
## Private conversation exports
|
||||||
|
|
||||||
|
`create_export_router()` from `control.chat_exports` uses injected `app.state.chat_store`.
|
||||||
|
|
||||||
|
- GET `/api/conversations/{id}/export?format=json|markdown` → authenticated attachment download. Defaults json. JSON `{version:1,conversation,messages:[]}` includes entire chronological message history, not a paginated first page. Only existing public projections, no private Hermes IDs, tool reasoning/events, account credentials or URLs. Markdown preserves original text/citations, exports as text/plain `.md` attachment rather than executable HTML.
|
||||||
|
- Full export is bounded16MiB encoded output and10000 messages; oversized413, unsupported format422, cross-account/deleted404, revoked401. Session revalidated before final response. Attachment IDs preserved but private upload bytes not embedded. No public share links, remote upload or public artifacts.
|
||||||
|
- Existing artifact export remains `/api/artifacts/{id}/export?revision=N`; exports selected exact stored content. Native model-generated artifacts require core capture/edit linkage, not an invented Hermes file API.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Generated from design/hope-palette.v1.json v1.0.0; SHA256 bb76134be477916d776cda4259f45096fc2dcfcf873918b8703a0b716391a350.
|
||||||
|
// DO NOT EDIT. python3 scripts/generate_hope_themes.py
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct HopeRGB: Sendable {
|
||||||
|
let red: Double
|
||||||
|
let green: Double
|
||||||
|
let blue: Double
|
||||||
|
init(_ red: Double, _ green: Double, _ blue: Double) {
|
||||||
|
self.red = red; self.green = green; self.blue = blue
|
||||||
|
}
|
||||||
|
var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HopeAppearance: String, CaseIterable, Sendable {
|
||||||
|
case system, light, dark
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HopePalette: String, CaseIterable, Sendable {
|
||||||
|
case opal
|
||||||
|
case graphite
|
||||||
|
case ocean
|
||||||
|
case sage
|
||||||
|
case lavender
|
||||||
|
static let defaultPalette: HopePalette = .opal
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .opal: return "Opal Pink"
|
||||||
|
case .graphite: return "Graphite"
|
||||||
|
case .ocean: return "Ocean"
|
||||||
|
case .sage: return "Sage"
|
||||||
|
case .lavender: return "Lavender"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HopeThemeRole: String, CaseIterable, Sendable {
|
||||||
|
case ground
|
||||||
|
case surface
|
||||||
|
case personaSurface
|
||||||
|
case ink
|
||||||
|
case quiet
|
||||||
|
case accent
|
||||||
|
case onAccent
|
||||||
|
case alert
|
||||||
|
case onAlert
|
||||||
|
case focus
|
||||||
|
case controlBorder
|
||||||
|
case separator
|
||||||
|
case selectedWash
|
||||||
|
case errorWash
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HopeTheme {
|
||||||
|
static let version = "1.0.0"
|
||||||
|
static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {
|
||||||
|
switch palette {
|
||||||
|
case .opal:
|
||||||
|
switch role {
|
||||||
|
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 0.9921568627450981, 0.9921568627450981)
|
||||||
|
case .surface: return dark ? HopeRGB(0.18823529411764706, 0.13725490196078433, 0.18823529411764706) : HopeRGB(1, 0.9686274509803922, 0.9803921568627451)
|
||||||
|
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.1568627450980392, 0.21176470588235294) : HopeRGB(1, 0.9490196078431372, 0.9686274509803922)
|
||||||
|
case .ink: return dark ? HopeRGB(1, 0.9490196078431372, 0.9686274509803922) : HopeRGB(0.18823529411764706, 0.16862745098039217, 0.25098039215686274)
|
||||||
|
case .quiet: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
|
||||||
|
case .accent: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
|
||||||
|
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
|
||||||
|
case .focus: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
|
||||||
|
case .controlBorder: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
|
||||||
|
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
|
||||||
|
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
|
||||||
|
case .separator: return dark ? HopeRGB(0.3018823529411765, 0.2509019607843137, 0.2974901960784314) : HopeRGB(0.8863529411764706, 0.8566274509803922, 0.8782745098039215)
|
||||||
|
case .selectedWash: return dark ? HopeRGB(0.2635294117647059, 0.1894117647058824, 0.24784313725490195) : HopeRGB(0.9650980392156863, 0.8976470588235295, 0.9262745098039216)
|
||||||
|
case .errorWash: return dark ? HopeRGB(0.2531764705882353, 0.17459450980392158, 0.2271764705882353) : HopeRGB(0.9740000000000001, 0.9077772549019608, 0.9245207843137255)
|
||||||
|
}
|
||||||
|
case .graphite:
|
||||||
|
switch role {
|
||||||
|
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(0.9921568627450981, 0.9921568627450981, 0.996078431372549)
|
||||||
|
case .surface: return dark ? HopeRGB(0.1450980392156863, 0.1568627450980392, 0.1843137254901961) : HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9647058823529412)
|
||||||
|
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.20392156862745098, 0.23921568627450981) : HopeRGB(0.9294117647058824, 0.9333333333333333, 0.9490196078431372)
|
||||||
|
case .ink: return dark ? HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9686274509803922) : HopeRGB(0.15294117647058825, 0.16470588235294117, 0.2)
|
||||||
|
case .quiet: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
|
||||||
|
case .accent: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
|
||||||
|
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
|
||||||
|
case .focus: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
|
||||||
|
case .controlBorder: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
|
||||||
|
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
|
||||||
|
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
|
||||||
|
case .separator: return dark ? HopeRGB(0.2581960784313726, 0.2688627450980392, 0.29411764705882354) : HopeRGB(0.8409411764705882, 0.8459607843137256, 0.8576470588235294)
|
||||||
|
case .selectedWash: return dark ? HopeRGB(0.2054901960784314, 0.22, 0.2521568627450981) : HopeRGB(0.8890196078431373, 0.8960784313725491, 0.9094117647058824)
|
||||||
|
case .errorWash: return dark ? HopeRGB(0.2134901960784314, 0.19263372549019608, 0.22356862745098044) : HopeRGB(0.9307058823529412, 0.8969537254901961, 0.9100894117647059)
|
||||||
|
}
|
||||||
|
case .ocean:
|
||||||
|
switch role {
|
||||||
|
case .ground: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(0.9882352941176471, 0.996078431372549, 1)
|
||||||
|
case .surface: return dark ? HopeRGB(0.12549019607843137, 0.19215686274509805, 0.24705882352941178) : HopeRGB(0.9411764705882353, 0.9686274509803922, 0.984313725490196)
|
||||||
|
case .personaSurface: return dark ? HopeRGB(0.1568627450980392, 0.23921568627450981, 0.30196078431372547) : HopeRGB(0.9058823529411765, 0.9490196078431372, 0.9764705882352941)
|
||||||
|
case .ink: return dark ? HopeRGB(0.9294117647058824, 0.9686274509803922, 1) : HopeRGB(0.13725490196078433, 0.20392156862745098, 0.25882352941176473)
|
||||||
|
case .quiet: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
|
||||||
|
case .accent: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
|
||||||
|
case .onAccent: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
|
||||||
|
case .focus: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
|
||||||
|
case .controlBorder: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
|
||||||
|
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
|
||||||
|
case .onAlert: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
|
||||||
|
case .separator: return dark ? HopeRGB(0.2380392156862745, 0.30086274509803923, 0.35247058823529415) : HopeRGB(0.828627450980392, 0.8615686274509804, 0.8827450980392156)
|
||||||
|
case .selectedWash: return dark ? HopeRGB(0.17215686274509806, 0.25372549019607843, 0.31568627450980397) : HopeRGB(0.8623529411764705, 0.9113725490196078, 0.9407843137254902)
|
||||||
|
case .errorWash: return dark ? HopeRGB(0.19545098039215686, 0.22510431372549022, 0.28129411764705886) : HopeRGB(0.9198823529411766, 0.9077772549019608, 0.9281286274509805)
|
||||||
|
}
|
||||||
|
case .sage:
|
||||||
|
switch role {
|
||||||
|
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(0.9882352941176471, 0.996078431372549, 0.9882352941176471)
|
||||||
|
case .surface: return dark ? HopeRGB(0.14901960784313725, 0.20392156862745098, 0.16470588235294117) : HopeRGB(0.9411764705882353, 0.9647058823529412, 0.9450980392156862)
|
||||||
|
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.25882352941176473, 0.21176470588235294) : HopeRGB(0.9058823529411765, 0.9411764705882353, 0.9137254901960784)
|
||||||
|
case .ink: return dark ? HopeRGB(0.9411764705882353, 0.9725490196078431, 0.9450980392156862) : HopeRGB(0.1607843137254902, 0.2196078431372549, 0.17647058823529413)
|
||||||
|
case .quiet: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
|
||||||
|
case .accent: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
|
||||||
|
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
|
||||||
|
case .focus: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
|
||||||
|
case .controlBorder: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
|
||||||
|
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
|
||||||
|
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
|
||||||
|
case .separator: return dark ? HopeRGB(0.25992156862745097, 0.3115294117647059, 0.2739607843137255) : HopeRGB(0.8319215686274509, 0.8603921568627451, 0.8374901960784313)
|
||||||
|
case .selectedWash: return dark ? HopeRGB(0.19882352941176473, 0.26666666666666666, 0.2168627450980392) : HopeRGB(0.8705882352941177, 0.9121568627450981, 0.8807843137254902)
|
||||||
|
case .errorWash: return dark ? HopeRGB(0.21709803921568627, 0.2359278431372549, 0.2055294117647059) : HopeRGB(0.9198823529411766, 0.9041694117647059, 0.8920501960784314)
|
||||||
|
}
|
||||||
|
case .lavender:
|
||||||
|
switch role {
|
||||||
|
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(0.996078431372549, 0.9921568627450981, 1)
|
||||||
|
case .surface: return dark ? HopeRGB(0.1803921568627451, 0.15294117647058825, 0.23529411764705882) : HopeRGB(0.9647058823529412, 0.9490196078431372, 0.9882352941176471)
|
||||||
|
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.19607843137254902, 0.30196078431372547) : HopeRGB(0.9333333333333333, 0.9098039215686274, 0.9725490196078431)
|
||||||
|
case .ink: return dark ? HopeRGB(0.9686274509803922, 0.9450980392156862, 1) : HopeRGB(0.20392156862745098, 0.17647058823529413, 0.2627450980392157)
|
||||||
|
case .quiet: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
|
||||||
|
case .accent: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
|
||||||
|
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
|
||||||
|
case .focus: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
|
||||||
|
case .controlBorder: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
|
||||||
|
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
|
||||||
|
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
|
||||||
|
case .separator: return dark ? HopeRGB(0.29074509803921567, 0.263843137254902, 0.3423529411764706) : HopeRGB(0.8581960784313726, 0.8408627450980392, 0.8866666666666667)
|
||||||
|
case .selectedWash: return dark ? HopeRGB(0.24352941176470588, 0.20823529411764707, 0.3074509803921569) : HopeRGB(0.9121568627450981, 0.8862745098039215, 0.9505882352941177)
|
||||||
|
case .errorWash: return dark ? HopeRGB(0.24596078431372548, 0.18902588235294118, 0.27047058823529413) : HopeRGB(0.941529411764706, 0.8897380392156863, 0.9317364705882354)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,
|
||||||
|
appearance: HopeAppearance = .system) -> Color {
|
||||||
|
let light = rgb(role, palette: palette, dark: false).uiColor
|
||||||
|
let dark = rgb(role, palette: palette, dark: true).uiColor
|
||||||
|
switch appearance {
|
||||||
|
case .light: return Color(uiColor: light)
|
||||||
|
case .dark: return Color(uiColor: dark)
|
||||||
|
case .system:
|
||||||
|
return Color(uiColor: UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark ? dark : light
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -7,6 +7,31 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 16k;
|
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
|
# Public certificate-only onboarding profile lives in the persistent cert
|
||||||
# mount. Never expose that directory: it also contains the private key.
|
# mount. Never expose that directory: it also contains the private key.
|
||||||
location = /hope-server.mobileconfig {
|
location = /hope-server.mobileconfig {
|
||||||
@@ -31,7 +56,9 @@ server {
|
|||||||
proxy_pass http://127.0.0.1:8092/api/;
|
proxy_pass http://127.0.0.1:8092/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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/ {
|
location /internal/ {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# Backup receipts include environment secrets. Protect them at creation, not only
|
||||||
|
# after chmod, so a failed copy/inspect never leaves a world-readable partial file.
|
||||||
|
umask 077
|
||||||
# Run on Unraid after building both images. Never source secret values into logs.
|
# Run on Unraid after building both images. Never source secret values into logs.
|
||||||
ROOT=/mnt/user/appdata/hope-voice
|
ROOT=/mnt/user/appdata/hope-voice
|
||||||
ENV_FILE="$ROOT/runtime.env"
|
ENV_FILE="$ROOT/runtime.env"
|
||||||
@@ -66,9 +69,29 @@ for name in voice hope-control; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Accounts are quiesced before SQLite/WAL and product uploads are copied.
|
||||||
|
# Migrations are additive; rollback containers retain the new database. The
|
||||||
|
# snapshot is separately retained for operator-approved data restoration, never
|
||||||
|
# silently overwrite messages created after deployment.
|
||||||
|
tar -C "$ROOT" -czf "$BACKUP/account-state.tgz" accounts
|
||||||
|
chmod 600 "$BACKUP/account-state.tgz"
|
||||||
|
cp "$ROOT/runtime.env" "$BACKUP/runtime.env"
|
||||||
|
cp "$ROOT/voice.env" "$BACKUP/voice.env"
|
||||||
|
cp "$ROOT/livekit.yaml" "$BACKUP/livekit.yaml"
|
||||||
|
chmod 600 "$BACKUP/runtime.env" "$BACKUP/voice.env" "$BACKUP/livekit.yaml"
|
||||||
|
|
||||||
|
# Per-account Hermes homes remain mounted in place. No container/data migration
|
||||||
|
# may delete these roots. Record the exact old identities without revealing envs.
|
||||||
|
for name in voice hope-control; do
|
||||||
|
if [ -n "${ORIGINAL[$name]}" ]; then
|
||||||
|
docker inspect --format '{{.Id}}' "${ORIGINAL[$name]}" > "$BACKUP/$name-container-id.txt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
docker run -d --name hope-control --network host --restart unless-stopped \
|
docker run -d --name hope-control --network host --restart unless-stopped \
|
||||||
--env-file "$ENV_FILE" \
|
--env-file "$ENV_FILE" \
|
||||||
-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \
|
-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 \
|
-e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \
|
||||||
-v "$ROOT/accounts:/var/lib/hope" \
|
-v "$ROOT/accounts:/var/lib/hope" \
|
||||||
-v "$ROOT/hermes:$ROOT/hermes" \
|
-v "$ROOT/hermes:$ROOT/hermes" \
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate deterministic web/Swift theme artifacts; no third-party dependencies."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = ROOT / "design/hope-palette.v1.json"
|
||||||
|
OUTPUTS = {"css": ROOT / "web/hope-themes.css", "swift": ROOT / "generated/HopeTheme.generated.swift"}
|
||||||
|
|
||||||
|
|
||||||
|
def rgb(value):
|
||||||
|
"""Normalized sRGB; decimal channels preserve the native alert exactly."""
|
||||||
|
if isinstance(value, str) and re.fullmatch(r"#[0-9A-F]{6}", value):
|
||||||
|
return tuple(int(value[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
||||||
|
if isinstance(value, list) and len(value) == 3 and all(
|
||||||
|
isinstance(c, (int, float)) and not isinstance(c, bool) and 0 <= c <= 1 for c in value
|
||||||
|
):
|
||||||
|
return tuple(value)
|
||||||
|
raise ValueError(f"Invalid opaque sRGB value: {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(spec, palette, appearance):
|
||||||
|
colors = {role: rgb(value) for role, value in spec["palettes"][palette][appearance].items()}
|
||||||
|
for role, reference in spec["sharedRoles"][appearance].items():
|
||||||
|
colors[role] = colors[reference[1:]] if reference.startswith("$") else rgb(spec["primitives"][reference])
|
||||||
|
for role, derivation in spec["derivedRoles"].items():
|
||||||
|
fg, bg, alpha = colors[derivation["foreground"]], colors[derivation["background"]], derivation["alpha"]
|
||||||
|
colors[role] = tuple(f * alpha + b * (1 - alpha) for f, b in zip(fg, bg))
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
def luminance(color):
|
||||||
|
channels = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in color]
|
||||||
|
return sum(c * weight for c, weight in zip(channels, (0.2126, 0.7152, 0.0722)))
|
||||||
|
|
||||||
|
|
||||||
|
def contrast(foreground, background):
|
||||||
|
a, b = sorted((luminance(foreground), luminance(background)))
|
||||||
|
return (b + 0.05) / (a + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_checks(spec):
|
||||||
|
results = []
|
||||||
|
for palette in spec["palettes"]:
|
||||||
|
for appearance in ("light", "dark"):
|
||||||
|
colors = resolve(spec, palette, appearance)
|
||||||
|
pairs = [(fg, bg, 4.5) for fg in ("ink", "quiet", "accent", "alert")
|
||||||
|
for bg in ("ground", "surface", "personaSurface")]
|
||||||
|
pairs += [("ink", "selectedWash", 4.5), ("quiet", "selectedWash", 4.5),
|
||||||
|
("alert", "errorWash", 4.5), ("onAccent", "accent", 4.5),
|
||||||
|
("onAlert", "alert", 4.5)]
|
||||||
|
pairs += [(fg, bg, 3.0) for fg in ("focus", "controlBorder")
|
||||||
|
for bg in ("ground", "surface", "personaSurface")]
|
||||||
|
for fg, bg, minimum in pairs:
|
||||||
|
results.append({"palette": palette, "appearance": appearance, "foreground": fg,
|
||||||
|
"background": bg, "ratio": contrast(colors[fg], colors[bg]), "minimum": minimum})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def validate(spec):
|
||||||
|
if spec["schemaVersion"] != 1 or not re.fullmatch(r"\d+\.\d+\.\d+", spec["version"]):
|
||||||
|
raise ValueError("Unsupported schema or invalid semantic version")
|
||||||
|
if spec["defaultPalette"] not in spec["palettes"] or spec["defaultAppearance"] != "system":
|
||||||
|
raise ValueError("Invalid theme defaults")
|
||||||
|
required = set(spec["roles"])
|
||||||
|
for palette, data in spec["palettes"].items():
|
||||||
|
if not re.fullmatch(r"[a-z][a-z0-9-]*", palette) or not data["label"]:
|
||||||
|
raise ValueError("Invalid palette identifier/label")
|
||||||
|
for appearance in ("light", "dark"):
|
||||||
|
if set(data[appearance]) | set(spec["sharedRoles"][appearance]) != required:
|
||||||
|
raise ValueError(f"Incomplete semantic roles: {palette}/{appearance}")
|
||||||
|
resolve(spec, palette, appearance)
|
||||||
|
for derivation in spec["derivedRoles"].values():
|
||||||
|
if not 0 <= derivation["alpha"] <= 1:
|
||||||
|
raise ValueError("Invalid derived opacity")
|
||||||
|
if not set(spec["components"].values()) <= required | set(spec["derivedRoles"]):
|
||||||
|
raise ValueError("Unknown component role")
|
||||||
|
failures = [row for row in contrast_checks(spec) if row["ratio"] < row["minimum"]]
|
||||||
|
if failures:
|
||||||
|
raise ValueError("Contrast verification failed: " + json.dumps(failures))
|
||||||
|
|
||||||
|
|
||||||
|
def kebab(value):
|
||||||
|
return re.sub(r"[A-Z]", lambda match: "-" + match[0].lower(), value)
|
||||||
|
|
||||||
|
|
||||||
|
def number(value):
|
||||||
|
return repr(float(value)) if not float(value).is_integer() else str(int(value))
|
||||||
|
|
||||||
|
|
||||||
|
def css_color(color):
|
||||||
|
return "rgb(" + " ".join(number(c * 255) for c in color) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def css_block(spec, palette, appearance, selector):
|
||||||
|
lines = [selector + " {", f" color-scheme: {appearance};"]
|
||||||
|
for role, color in resolve(spec, palette, appearance).items():
|
||||||
|
lines.append(f" --hope-primitive-{kebab(role)}: {css_color(color)};")
|
||||||
|
lines.append(f" --hope-{kebab(role)}: var(--hope-primitive-{kebab(role)});")
|
||||||
|
# Opal's native API is retained as an alias, not a new meaning for other palettes.
|
||||||
|
lines += [" --hope-rosewood: var(--hope-accent);", " --hope-on-rosewood: var(--hope-on-accent);"]
|
||||||
|
for component, role in spec["components"].items():
|
||||||
|
lines.append(f" --hope-{kebab(component)}: var(--hope-{kebab(role)});")
|
||||||
|
return "\n".join(lines + ["}"])
|
||||||
|
|
||||||
|
|
||||||
|
def render_css(spec, digest):
|
||||||
|
chunks = [f"/* Generated from design/hope-palette.v1.json v{spec['version']}; SHA256 {digest}.\n * DO NOT EDIT. python3 scripts/generate_hope_themes.py\n * Set data-palette and data-appearance on <html>; missing/unknown values fall back to Opal/system.\n */"]
|
||||||
|
default = spec["defaultPalette"]
|
||||||
|
# Root is also the fallback for stale/unknown persisted palette or appearance IDs.
|
||||||
|
chunks.append(css_block(spec, default, "light", ":root"))
|
||||||
|
for palette in spec["palettes"]:
|
||||||
|
chunks.append(css_block(spec, palette, "light", f':root[data-palette="{palette}"]'))
|
||||||
|
system_blocks = [css_block(spec, default, "dark", ':root:not([data-appearance="light"]):not([data-appearance="dark"])')]
|
||||||
|
for palette in spec["palettes"]:
|
||||||
|
system_blocks.append(css_block(spec, palette, "dark", f':root[data-palette="{palette}"]:not([data-appearance="light"]):not([data-appearance="dark"])'))
|
||||||
|
chunks.append("@media (prefers-color-scheme: dark) {\n" + "\n".join(system_blocks) + "\n}")
|
||||||
|
for appearance in ("light", "dark"):
|
||||||
|
chunks.append(css_block(spec, default, appearance, f':root[data-appearance="{appearance}"]'))
|
||||||
|
for palette in spec["palettes"]:
|
||||||
|
chunks.append(css_block(spec, palette, appearance, f':root[data-palette="{palette}"][data-appearance="{appearance}"]'))
|
||||||
|
return "\n\n".join(chunks) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def swift_color(color):
|
||||||
|
return "HopeRGB(" + ", ".join(number(c) for c in color) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def render_swift(spec, digest):
|
||||||
|
lines = [f"// Generated from design/hope-palette.v1.json v{spec['version']}; SHA256 {digest}.",
|
||||||
|
"// DO NOT EDIT. python3 scripts/generate_hope_themes.py", "import SwiftUI", "import UIKit", "",
|
||||||
|
"struct HopeRGB: Sendable {", " let red: Double", " let green: Double", " let blue: Double",
|
||||||
|
" init(_ red: Double, _ green: Double, _ blue: Double) {",
|
||||||
|
" self.red = red; self.green = green; self.blue = blue", " }",
|
||||||
|
" var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }", "}", "",
|
||||||
|
"enum HopeAppearance: String, CaseIterable, Sendable {", " case system, light, dark", "}", "",
|
||||||
|
"enum HopePalette: String, CaseIterable, Sendable {"]
|
||||||
|
for palette in spec["palettes"]:
|
||||||
|
lines.append(f" case {palette}")
|
||||||
|
lines += [f" static let defaultPalette: HopePalette = .{spec['defaultPalette']}", " var label: String {", " switch self {"]
|
||||||
|
for palette, data in spec["palettes"].items():
|
||||||
|
lines.append(f" case .{palette}: return {json.dumps(data['label'])}")
|
||||||
|
lines += [" }", " }", "}", "", "enum HopeThemeRole: String, CaseIterable, Sendable {"]
|
||||||
|
for role in list(spec["roles"]) + list(spec["derivedRoles"]):
|
||||||
|
lines.append(f" case {role}")
|
||||||
|
lines += ["}", "", "enum HopeTheme {", f" static let version = {json.dumps(spec['version'])}",
|
||||||
|
" static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {", " switch palette {"]
|
||||||
|
for palette in spec["palettes"]:
|
||||||
|
lines += [f" case .{palette}:", " switch role {"]
|
||||||
|
light, dark = resolve(spec, palette, "light"), resolve(spec, palette, "dark")
|
||||||
|
for role in light:
|
||||||
|
lines.append(f" case .{role}: return dark ? {swift_color(dark[role])} : {swift_color(light[role])}")
|
||||||
|
lines += [" }"]
|
||||||
|
lines += [" }", " }", "", " static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,", " appearance: HopeAppearance = .system) -> Color {",
|
||||||
|
" let light = rgb(role, palette: palette, dark: false).uiColor",
|
||||||
|
" let dark = rgb(role, palette: palette, dark: true).uiColor",
|
||||||
|
" switch appearance {", " case .light: return Color(uiColor: light)", " case .dark: return Color(uiColor: dark)",
|
||||||
|
" case .system:", " return Color(uiColor: UIColor { traits in", " traits.userInterfaceStyle == .dark ? dark : light", " })", " }", " }", "}"]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def artifacts(spec, source_bytes):
|
||||||
|
validate(spec)
|
||||||
|
digest = hashlib.sha256(source_bytes).hexdigest()
|
||||||
|
return {"css": render_css(spec, digest), "swift": render_swift(spec, digest)}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check", action="store_true", help="Check committed outputs without rewriting")
|
||||||
|
parser.add_argument("--contrast", action="store_true", help="Print every verified contrast pair")
|
||||||
|
args = parser.parse_args()
|
||||||
|
source = SPEC.read_bytes()
|
||||||
|
spec = json.loads(source)
|
||||||
|
outputs = artifacts(spec, source)
|
||||||
|
if args.contrast:
|
||||||
|
for row in contrast_checks(spec):
|
||||||
|
print(f"{row['palette']}/{row['appearance']} {row['foreground']}/{row['background']}: {row['ratio']:.3f}:1 >= {row['minimum']}:1")
|
||||||
|
stale = []
|
||||||
|
for key, path in OUTPUTS.items():
|
||||||
|
if args.check:
|
||||||
|
if not path.exists() or path.read_text() != outputs[key]:
|
||||||
|
stale.append(str(path.relative_to(ROOT)))
|
||||||
|
else:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(outputs[key])
|
||||||
|
if stale:
|
||||||
|
parser.exit(1, "Stale theme artifacts: " + ", ".join(stale) + "\n")
|
||||||
|
minimum = min(row['ratio'] for row in contrast_checks(spec) if row['minimum'] == 4.5)
|
||||||
|
print(f"Hope themes v{spec['version']}: {len(spec['palettes'])} palettes, light/dark; minimum text contrast {minimum:.3f}:1; artifacts {'verified' if args.check else 'generated'}.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Explicit live browser qualification, preserving existing account preferences."""
|
||||||
|
import json,secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright,expect
|
||||||
|
values=json.loads(Path('.runtime/deployment.json').read_text())
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser=p.chromium.launch(headless=True)
|
||||||
|
context=browser.new_context(ignore_https_errors=True,viewport={'width':1440,'height':1000})
|
||||||
|
page=context.new_page();errors=[]
|
||||||
|
page.on('pageerror',lambda e:errors.append(str(e)))
|
||||||
|
page.goto('https://192.168.86.2:9178/',wait_until='networkidle')
|
||||||
|
page.locator('#loginUsername').fill('admin');page.locator('#loginPassword').fill(values['admin_password']);page.locator('#loginSubmit').click()
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled(timeout=30000)
|
||||||
|
page.locator('#newConversation').click()
|
||||||
|
page.locator('#primaryInput').fill('Reply with exactly BROWSER_CHAT_OK. Do not use tools.')
|
||||||
|
page.locator('#primarySend').click()
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('BROWSER_CHAT_OK',timeout=180000)
|
||||||
|
expect(page.locator('#cancelRun')).to_be_hidden(timeout=180000)
|
||||||
|
cid=page.evaluate('HopeChat.conversationId')
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Hope')
|
||||||
|
page.locator('#conversationMenu').click()
|
||||||
|
page.locator('#pinConversation').click()
|
||||||
|
expect(page.locator('#pinConversation')).to_contain_text('Unpin')
|
||||||
|
page.reload(wait_until='networkidle')
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled(timeout=30000)
|
||||||
|
row=page.locator('#pinnedConversations button').filter(has_text='Reply with exactly BROWSER_CHAT_OK').last
|
||||||
|
expect(row).to_be_visible(timeout=30000)
|
||||||
|
row.click()
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('BROWSER_CHAT_OK',timeout=30000)
|
||||||
|
page.screenshot(path='.runtime/chat-deployed-desktop.png',full_page=True)
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
||||||
|
assert not errors,errors
|
||||||
|
result=page.evaluate('''async cid=>{await HopeAuth.request('/api/conversations/'+cid,{method:'DELETE'});return true}''',cid)
|
||||||
|
assert result
|
||||||
|
context.close();browser.close()
|
||||||
|
print('live_browser_text_stream_pin_reload=PASS; console_errors=0')
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Explicit advanced live receipt. Protected credentials; bounded disposable data.
|
||||||
|
|
||||||
|
Never changes existing memory/settings, runs scheduled tasks, or prints credentials.
|
||||||
|
Research is one ordinary Hermes run. Deletes only objects created by this script.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def png():
|
||||||
|
def chunk(kind, data):
|
||||||
|
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
||||||
|
pixels = b''.join(b'\x00' + b'\xff\x00\x00' * 16 + b'\x00\x00\xff' * 16 for _ in range(16))
|
||||||
|
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 32, 16, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(pixels)) + chunk(b'IEND', b'')
|
||||||
|
|
||||||
|
|
||||||
|
def pdf(text):
|
||||||
|
objects = [b'<< /Type /Catalog /Pages 2 0 R >>', b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||||
|
b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>']
|
||||||
|
stream = ('BT /F1 12 Tf 20 100 Td (' + text + ') Tj ET').encode('ascii')
|
||||||
|
objects.append(b'<< /Length ' + str(len(stream)).encode() + b' >>\nstream\n' + stream + b'\nendstream')
|
||||||
|
output = bytearray(b'%PDF-1.4\n'); offsets = [0]
|
||||||
|
for number, obj in enumerate(objects, 1):
|
||||||
|
offsets.append(len(output)); output.extend(str(number).encode() + b' 0 obj\n' + obj + b'\nendobj\n')
|
||||||
|
xref = len(output)
|
||||||
|
output.extend(b'xref\n0 6\n0000000000 65535 f \n')
|
||||||
|
for offset in offsets[1:]: output.extend(f'{offset:010d} 00000 n \n'.encode())
|
||||||
|
output.extend(f'trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode())
|
||||||
|
return bytes(output)
|
||||||
|
|
||||||
|
|
||||||
|
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-features-live-receipt.json')
|
||||||
|
args = parser.parse_args()
|
||||||
|
source = Path(args.credentials)
|
||||||
|
if source.is_symlink() or source.stat().st_mode & 0o077:
|
||||||
|
raise RuntimeError('Credentials must be private regular file')
|
||||||
|
credentials = json.loads(source.read_text())
|
||||||
|
stamp = secrets.token_hex(6)
|
||||||
|
receipt = {'url': args.url, 'started_at': time.time(), 'checks': {}, 'cleanup_pending': []}
|
||||||
|
attachments, conversations, tasks = [], [], []
|
||||||
|
failure = None
|
||||||
|
async with httpx.AsyncClient(base_url=args.url, verify=False, timeout=180, follow_redirects=False) as client:
|
||||||
|
async def request(method, path, **kwargs):
|
||||||
|
response = await client.request(method, path, **kwargs)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise RuntimeError(f'{method} {path.split("?")[0]} status {response.status_code}')
|
||||||
|
return response
|
||||||
|
async def upload(data, mime, name):
|
||||||
|
item = (await request('POST', '/api/attachments', content=data, headers={'Content-Type': mime, 'X-Filename': name})).json()['attachment']
|
||||||
|
attachments.append(item['id']); return item
|
||||||
|
async def turn(content, ids=(), mode=None):
|
||||||
|
conversation = (await request('POST', '/api/conversations', json={'title': 'Advanced qualification ' + stamp})).json()['conversation']
|
||||||
|
conversations.append(conversation['id'])
|
||||||
|
body = {'content': content, 'attachment_ids': list(ids), 'idempotency_key': secrets.token_hex(16)}
|
||||||
|
if mode: body['mode'] = mode
|
||||||
|
accepted = (await request('POST', '/api/conversations/' + conversation['id'] + '/messages', json=body)).json()
|
||||||
|
rid = accepted['run']['id']
|
||||||
|
async with client.stream('GET', '/api/runs/' + rid + '/events') as response:
|
||||||
|
if response.status_code != 200: raise RuntimeError('Run events unavailable')
|
||||||
|
async for _ in response.aiter_lines(): pass
|
||||||
|
detail = (await request('GET', '/api/runs/' + rid)).json()
|
||||||
|
if detail['run']['status'] != 'completed': raise RuntimeError('Native Hermes run did not complete')
|
||||||
|
return '\n'.join(message['content'] for message in detail['messages'] if message['role'] == 'assistant' and message['status'] == 'completed')
|
||||||
|
try:
|
||||||
|
login = (await request('POST', '/api/auth/login', json={'username': 'admin', 'password': credentials['admin_password']})).json()
|
||||||
|
client.cookies.clear(); client.headers['Authorization'] = 'Bearer ' + login['token']
|
||||||
|
caps = (await request('GET', '/api/capabilities')).json()['features']
|
||||||
|
receipt['capabilities'] = {key: caps.get(key) for key in ('attachments', 'pdf_extraction', 'image_input', 'scheduled_tasks', 'memory_controls', 'research', 'connectors')}
|
||||||
|
text_code, pdf_code = 'TXT' + stamp, 'PDF' + stamp
|
||||||
|
text = await upload(('Qualification token: ' + text_code).encode(), 'text/plain', 'qualification.txt')
|
||||||
|
assert text['extraction_status'] == 'ready'
|
||||||
|
assert text_code in await turn('Read the attached text and return its qualification token only. Do not use tools.', [text['id']])
|
||||||
|
receipt['checks']['text_attachment_native_reply'] = True
|
||||||
|
document = await upload(pdf('Qualification token ' + pdf_code), 'application/pdf', 'qualification.pdf')
|
||||||
|
assert document['extraction_status'] == 'ready'
|
||||||
|
assert pdf_code in await turn('Read the attached document and return its qualification token only. Do not use tools.', [document['id']])
|
||||||
|
receipt['checks']['pdf_extraction_native_reply'] = True
|
||||||
|
image = await upload(png(), 'image/png', 'qualification.png')
|
||||||
|
answer = await turn('Name the two panel colors left to right in this attached image. Answer two colors only. Do not use tools.', [image['id']])
|
||||||
|
assert 'red' in answer.lower() and 'blue' in answer.lower() and answer.lower().index('red') < answer.lower().index('blue')
|
||||||
|
receipt['checks']['image_native_vision_reply'] = True
|
||||||
|
memory = (await request('GET', '/api/memory')).json()
|
||||||
|
assert {item['section'] for item in memory['sections']} == {'memory', 'user'}
|
||||||
|
assert all(len(item['revision']) == 64 for item in memory['sections'])
|
||||||
|
receipt['checks']['memory_read_only'] = True
|
||||||
|
del memory # Never include existing private memory content in receipt.
|
||||||
|
task = (await request('POST', '/api/tasks', json={'name': 'Qualification ' + stamp, 'schedule': '2099-01-01T00:00:00Z',
|
||||||
|
'prompt': 'Qualification placeholder; do not execute before scheduled time.', 'paused': True, 'repeat': 1})).json()['task']
|
||||||
|
tasks.append(task['id'])
|
||||||
|
for action in ('resume', 'pause'):
|
||||||
|
await request('POST', '/api/tasks/' + task['id'] + '/' + action)
|
||||||
|
await request('PATCH', '/api/tasks/' + task['id'], json={'name': 'Qualification edited ' + stamp, 'enabled': False})
|
||||||
|
listed = (await request('GET', '/api/tasks')).json()['tasks']
|
||||||
|
assert any(item['id'] == task['id'] for item in listed)
|
||||||
|
await request('DELETE', '/api/tasks/' + task['id']); tasks.remove(task['id'])
|
||||||
|
receipt['checks']['task_create_pause_resume_edit_delete_no_run'] = True
|
||||||
|
research = (await request('GET', '/api/research')).json()
|
||||||
|
assert research['available'] and research['mode'] == 'hermes_tools'
|
||||||
|
answer = await turn('Use web research only to verify what organization publishes the Python programming language official documentation. '
|
||||||
|
'Consult https://docs.python.org/ and cite that source URL. Do not write files, run shell commands, schedule jobs or modify memory.', mode='research')
|
||||||
|
assert 'https://docs.python.org' in answer
|
||||||
|
receipt['checks']['native_research_cited_reply'] = True
|
||||||
|
except Exception as error:
|
||||||
|
# Never serialize server bodies/provider exception details.
|
||||||
|
failure = type(error).__name__ + ': ' + str(error) if isinstance(error, (AssertionError, RuntimeError)) else type(error).__name__
|
||||||
|
receipt['failure'] = failure
|
||||||
|
finally:
|
||||||
|
for kind, ids in (('tasks', tasks), ('attachments', attachments), ('conversations', conversations)):
|
||||||
|
for identifier in ids:
|
||||||
|
response = await client.delete('/api/' + kind + '/' + identifier)
|
||||||
|
if response.status_code not in (200, 404):
|
||||||
|
receipt['cleanup_pending'].append({'kind': kind, 'id': identifier, 'status': response.status_code})
|
||||||
|
if 'Authorization' in client.headers:
|
||||||
|
await client.post('/api/auth/logout')
|
||||||
|
receipt['finished_at'] = time.time()
|
||||||
|
target = Path(args.receipt); target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.is_symlink(): raise RuntimeError('Receipt path cannot be a symlink')
|
||||||
|
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, 'w') as stream: json.dump(receipt, stream, indent=2)
|
||||||
|
print('Advanced live checks:', ', '.join(receipt['checks']))
|
||||||
|
if failure:
|
||||||
|
print('Qualification incomplete:', failure)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': asyncio.run(main())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only deployed settings/admin browser qualification; no account mutations."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
values=json.loads(Path('.runtime/deployment.json').read_text())
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser=p.chromium.launch(headless=True)
|
||||||
|
context=browser.new_context(ignore_https_errors=True,viewport={'width':1440,'height':1000})
|
||||||
|
page=context.new_page();errors=[]
|
||||||
|
page.on('pageerror',lambda e:errors.append(str(e)))
|
||||||
|
page.goto('https://192.168.86.2:9178/',wait_until='networkidle')
|
||||||
|
page.locator('#loginUsername').fill('admin');page.locator('#loginPassword').fill(values['admin_password']);page.locator('#loginSubmit').click()
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled(timeout=30000)
|
||||||
|
page.locator('#accountMenuBtn').click();expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
|
expect(page.locator('#settingsAccountRole')).to_contain_text('Administrator')
|
||||||
|
page.locator('[data-settings-target=appearance]').click();expect(page.locator('#paletteSelect')).to_be_enabled(timeout=15000)
|
||||||
|
page.locator('[data-settings-target=preferences]').click();expect(page.locator('#modelSelect')).to_be_enabled(timeout=30000)
|
||||||
|
expect(page.locator('#voiceList [role=radio]').first).to_be_visible(timeout=30000)
|
||||||
|
page.locator('[data-settings-target=privacy]').click();expect(page.locator('#settingsMemoryStatus')).to_contain_text('saved memory',timeout=30000)
|
||||||
|
page.locator('[data-settings-target=security]').click();expect(page.locator('[data-settings-panel=security]')).to_be_visible()
|
||||||
|
page.locator('#adminBtn').click();expect(page.locator('#usersBody tr').first).to_be_visible(timeout=15000)
|
||||||
|
page.screenshot(path='.runtime/hope-approved-admin-live.png',full_page=True)
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
||||||
|
page.locator('#settingsClose').click();page.set_viewport_size({'width':390,'height':844})
|
||||||
|
page.locator('#drawerOpen').click();page.locator('#accountMenuBtn').click();expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
||||||
|
page.screenshot(path='.runtime/hope-approved-settings-mobile-live.png',full_page=True)
|
||||||
|
page.locator('#settingsClose').click();assert not errors,errors
|
||||||
|
page.evaluate("HopeAuth.request('/api/auth/logout',{method:'POST'})")
|
||||||
|
context.close();browser.close()
|
||||||
|
print('live_settings_account_appearance_voice_model_memory_security_admin_mobile=PASS; console_errors=0; no_mutations')
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Explicit live fake-microphone qualification. Never physical microphone evidence.
|
||||||
|
|
||||||
|
Reads ignored protected deployment credentials; emits only safe qualification facts.
|
||||||
|
Requires local synthetic PCM fixture and deployed Hope. No deployment changes.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--url', default='https://192.168.86.2:9178')
|
||||||
|
parser.add_argument('--credentials', default='.runtime/deployment.json')
|
||||||
|
parser.add_argument('--wav', default='.runtime/voice-qualification/microphone.wav')
|
||||||
|
parser.add_argument('--timeout', type=int, default=180)
|
||||||
|
args = parser.parse_args()
|
||||||
|
values = json.loads(Path(args.credentials).read_text())
|
||||||
|
fixture = Path(args.wav).resolve(strict=True)
|
||||||
|
receipt = {'mode': 'synthetic_browser_microphone_not_physical', 'connected': False,
|
||||||
|
'user_transcript': False, 'assistant_transcript': False,
|
||||||
|
'audio_track': False, 'audio_bytes': 0, 'errors': 0}
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True, args=[
|
||||||
|
'--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream',
|
||||||
|
'--use-file-for-fake-audio-capture=' + str(fixture),
|
||||||
|
'--autoplay-policy=no-user-gesture-required'])
|
||||||
|
context = browser.new_context(ignore_https_errors=True, permissions=['microphone'],
|
||||||
|
viewport={'width': 1440, 'height': 1000})
|
||||||
|
page = context.new_page()
|
||||||
|
page.add_init_script("window.__qualificationVoiceID=null;window.addEventListener('hope:voice-state',event=>{if(event.detail?.conversation_id)window.__qualificationVoiceID=event.detail.conversation_id;});")
|
||||||
|
page.on('pageerror', lambda error: receipt.update(errors=receipt['errors'] + 1))
|
||||||
|
# Read-only receiver stats: exposes no tokens, credentials or private payloads.
|
||||||
|
page.add_init_script('''window.__voiceStats={bytes:0,packets:0};
|
||||||
|
const original=RTCPeerConnection.prototype.getStats;
|
||||||
|
const connections=[]; const Native=window.RTCPeerConnection;
|
||||||
|
window.RTCPeerConnection=new Proxy(Native,{construct(target,args){
|
||||||
|
const connection=Reflect.construct(target,args); connections.push(connection); return connection;}});
|
||||||
|
setInterval(async()=>{for(const connection of connections){try{
|
||||||
|
const report=await original.call(connection);
|
||||||
|
report.forEach(item=>{if(item.type==='inbound-rtp'&&(item.kind==='audio'||item.mediaType==='audio')){
|
||||||
|
window.__voiceStats.bytes=Math.max(window.__voiceStats.bytes,item.bytesReceived||0);
|
||||||
|
window.__voiceStats.packets=Math.max(window.__voiceStats.packets,item.packetsReceived||0);}});
|
||||||
|
}catch{}}},500);''')
|
||||||
|
try:
|
||||||
|
page.goto(args.url.rstrip('/') + '/', wait_until='networkidle')
|
||||||
|
expect(page.locator('#loginSubmit')).to_be_enabled(timeout=30000)
|
||||||
|
page.locator('#loginUsername').fill('admin')
|
||||||
|
page.locator('#loginPassword').fill(values['admin_password'])
|
||||||
|
page.locator('#loginSubmit').click()
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled(timeout=30000)
|
||||||
|
# Prefer restored prominent voice entry, tolerate current compatibility ID.
|
||||||
|
for selector in ['#talkToHope', '#talkVoice', '#openVoice']:
|
||||||
|
if page.locator(selector).count() and page.locator(selector).is_visible():
|
||||||
|
page.locator(selector).click()
|
||||||
|
break
|
||||||
|
# Approved composer action starts directly. Legacy releases require
|
||||||
|
# a second Start click; never double-start a direct voice session.
|
||||||
|
direct = page.evaluate('!!window.HopeVoice')
|
||||||
|
if not direct:
|
||||||
|
expect(page.locator('#voiceModeBtn')).to_be_visible(timeout=10000)
|
||||||
|
page.locator('#voiceModeBtn').click()
|
||||||
|
expect(page.locator('#startBtn')).to_be_enabled(timeout=10000)
|
||||||
|
page.locator('#startBtn').click()
|
||||||
|
deadline = time.monotonic() + args.timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
status = page.locator('#status').inner_text()
|
||||||
|
body = page.locator('#messages').inner_text() if page.locator('#messages').count() else ''
|
||||||
|
receipt['connected'] |= 'Microphone on' in status or 'Secure room' in status
|
||||||
|
# Synthetic question and expected answer facts, not transcript dumps.
|
||||||
|
user_text = ' '.join(page.locator('#messages .message-row.user').all_inner_texts()).lower()
|
||||||
|
receipt['user_transcript'] |= bool(re.search(r'(?:two|2)\s*(?:plus|\+)\s*(?:two|2)', user_text))
|
||||||
|
assistant_text = ' '.join(page.locator('#messages .message-row.agent').all_inner_texts()).lower()
|
||||||
|
receipt['assistant_transcript'] |= 'four' in assistant_text or '4' in assistant_text
|
||||||
|
receipt['audio_track'] |= page.locator('audio[id^="audio-"]').count() > 0
|
||||||
|
stats = page.evaluate('window.__voiceStats')
|
||||||
|
receipt['audio_bytes'] = stats['bytes']
|
||||||
|
if receipt['assistant_transcript'] and receipt['audio_bytes'] > 0:
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
receipt['audio_playing'] = page.evaluate('''Array.from(document.querySelectorAll('audio[id^="audio-"]')).some(el=>!el.paused&&el.srcObject&&el.srcObject.getAudioTracks().some(track=>track.readyState==='live'))''')
|
||||||
|
output = Path('.runtime/voice-qualification/live-receipt.json')
|
||||||
|
output.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||||
|
print(json.dumps(receipt, sort_keys=True))
|
||||||
|
assert receipt['connected'], 'synthetic_voice_connection_failed'
|
||||||
|
assert receipt['user_transcript'], 'synthetic_voice_stt_not_observed'
|
||||||
|
assert receipt['assistant_transcript'], 'synthetic_voice_answer_not_observed'
|
||||||
|
assert receipt['audio_track'] and receipt['audio_bytes'] > 0, 'remote_tts_audio_not_observed'
|
||||||
|
finally:
|
||||||
|
if page.locator('#stopBtn').count() and page.locator('#stopBtn').is_visible() and page.locator('#stopBtn').is_enabled():
|
||||||
|
page.locator('#stopBtn').click()
|
||||||
|
# Delete only the ID captured for this newly created qualification
|
||||||
|
# voice conversation. Never enumerate or remove other account chats.
|
||||||
|
try:
|
||||||
|
cleanup = page.evaluate('''async()=>{let removed=false;const cid=window.__qualificationVoiceID;
|
||||||
|
if(cid){try{const result=await HopeAuth.request('/api/conversations/'+encodeURIComponent(cid),{method:'DELETE'});removed=result.ok===true;}catch{}}
|
||||||
|
try{await HopeAuth.request('/api/auth/logout',{method:'POST'});}catch{}
|
||||||
|
return {created_conversation_seen:!!cid,removed};}''')
|
||||||
|
print(json.dumps({'qualification_cleanup': cleanup}, sort_keys=True))
|
||||||
|
except Exception:
|
||||||
|
print('qualification_cleanup_unavailable')
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Offline durable API qualification using injected Hermes runs adapter."""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from control.app import Settings, create_app
|
||||||
|
|
||||||
|
PASSWORD = 'offline-chat-password'
|
||||||
|
|
||||||
|
class Provisioner:
|
||||||
|
async def ensure(self, user):
|
||||||
|
return {'container_name': user['container_name'], 'hermes_url': 'http://127.0.0.1:12345', 'hermes_api_key': 'secret-never-public'}
|
||||||
|
async def ready(self, user):
|
||||||
|
return True
|
||||||
|
async def remove(self, user):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeRuns:
|
||||||
|
available = True
|
||||||
|
approvals = False
|
||||||
|
def __init__(self):
|
||||||
|
self.started = []
|
||||||
|
self.stopped = []
|
||||||
|
async def start(self, owner, rid, payload):
|
||||||
|
self.started.append((owner, rid, payload))
|
||||||
|
return {'run_id': 'upstream-' + rid, 'status': 'started'}
|
||||||
|
async def status(self, owner, upstream):
|
||||||
|
return {'status': 'running'}
|
||||||
|
async def events(self, owner, upstream):
|
||||||
|
yield {'event': 'reasoning.delta', 'delta': 'hidden-secret'}
|
||||||
|
yield {'event': 'tool.completed', 'result': 'tool-secret', 'error': 'credential'}
|
||||||
|
yield {'event': 'message.delta', 'delta': 'Hello'}
|
||||||
|
yield {'event': 'run.completed', 'output': 'Hello world'}
|
||||||
|
async def stop(self, owner, upstream):
|
||||||
|
self.stopped.append(upstream)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path):
|
||||||
|
adapter = FakeRuns()
|
||||||
|
app = create_app(Settings(db_path=str(tmp_path/'chat.sqlite'), admin_password=PASSWORD, allowed_origins=('https://testserver',), reconcile_interval=3600), Provisioner(), chat_adapter=adapter)
|
||||||
|
with TestClient(app, base_url='https://testserver') as client:
|
||||||
|
result = client.post('/api/auth/login', headers={'Origin':'https://testserver'}, json={'username':'admin','password':PASSWORD})
|
||||||
|
headers = {'Authorization':'Bearer '+result.json()['token']}
|
||||||
|
yield client, app, adapter, headers
|
||||||
|
|
||||||
|
def wait_run(client, headers, rid):
|
||||||
|
deadline = time.monotonic()+3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
value = client.get('/api/runs/'+rid, headers=headers).json()
|
||||||
|
if value['run']['status'] in ('completed','failed','cancelled'):
|
||||||
|
return value
|
||||||
|
raise AssertionError('Run did not finish')
|
||||||
|
|
||||||
|
def test_metadata_crud_revision_and_sync(env):
|
||||||
|
client, app, adapter, headers = env
|
||||||
|
client.cookies.clear()
|
||||||
|
assert client.get('/api/conversations').status_code == 401
|
||||||
|
workspace = client.post('/api/workspaces', headers=headers, json={'name':'Work'}).json()['workspace']
|
||||||
|
conversation = client.post('/api/conversations', headers=headers, json={'title':'Durable','workspace_id':workspace['id']}).json()['conversation']
|
||||||
|
cid = conversation['id']
|
||||||
|
patch = {'revision':conversation['revision'],'pinned':True}
|
||||||
|
assert client.patch('/api/conversations/'+cid, headers=headers, json=patch).status_code == 200
|
||||||
|
assert client.patch('/api/conversations/'+cid, headers=headers, json=patch).status_code == 409
|
||||||
|
assert client.delete('/api/workspaces/'+workspace['id'], headers=headers).status_code == 200
|
||||||
|
assert client.get('/api/conversations/'+cid, headers=headers).json()['conversation']['workspace_id'] is None
|
||||||
|
assert client.delete('/api/conversations/'+cid, headers=headers).status_code == 200
|
||||||
|
assert client.get('/api/conversations/'+cid, headers=headers).status_code == 404
|
||||||
|
assert client.get('/api/sync', headers=headers).json()['changes']
|
||||||
|
|
||||||
|
def test_idempotent_send_final_output_and_redacted_replay(env):
|
||||||
|
client, app, adapter, headers = env
|
||||||
|
cid = client.post('/api/conversations', headers=headers, json={}).json()['conversation']['id']
|
||||||
|
url = '/api/conversations/'+cid+'/messages'
|
||||||
|
value = {'content':'Hi','idempotency_key':'same-key'}
|
||||||
|
response = client.post(url, headers=headers, json=value)
|
||||||
|
assert response.status_code == 202, response.text
|
||||||
|
rid = response.json()['run']['id']
|
||||||
|
final = wait_run(client, headers, rid)
|
||||||
|
assert final['run']['status'] == 'completed', final
|
||||||
|
assert any(m['content']=='Hello world' for m in final['messages'])
|
||||||
|
repeat = client.post(url, headers=headers, json=value)
|
||||||
|
assert repeat.json()['run']['id'] == rid
|
||||||
|
assert client.post(url, headers=headers, json={**value,'content':'changed'}).status_code == 409
|
||||||
|
assert len(adapter.started) == 1
|
||||||
|
payload = adapter.started[0][2]
|
||||||
|
assert payload['input'] == 'Hi'
|
||||||
|
assert 'conversation_history' not in payload
|
||||||
|
stream = client.get('/api/runs/'+rid+'/events', headers=headers)
|
||||||
|
assert stream.status_code == 200
|
||||||
|
assert 'event: done' in stream.text
|
||||||
|
assert 'hidden-secret' not in stream.text and 'tool-secret' not in stream.text
|
||||||
|
assert 'secret-never-public' not in stream.text
|
||||||
|
# Capacity limits never prevent retrieval of an already accepted operation.
|
||||||
|
async def fill():
|
||||||
|
app.state.chat_runs.tasks.update({str(i): asyncio.create_task(asyncio.sleep(100)) for i in range(256)})
|
||||||
|
client.portal.call(fill)
|
||||||
|
replay = client.post(url, headers=headers, json=value)
|
||||||
|
assert replay.status_code == 202
|
||||||
|
assert replay.json()['run']['id'] == rid
|
||||||
|
|
||||||
|
def test_body_validation_and_honest_capabilities(env):
|
||||||
|
client, app, adapter, headers = env
|
||||||
|
caps = client.get('/api/capabilities', headers=headers).json()
|
||||||
|
assert caps['features']['text_chat'] is True
|
||||||
|
assert caps['features']['attachments'] is True
|
||||||
|
assert client.post('/api/conversations', headers=headers, json={'user_id':'fabricated'}).status_code == 400
|
||||||
|
assert client.put('/api/preferences', headers=headers, json={'revision':True,'palette':'opal','appearance':'dark'}).status_code == 400
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Real ASGI API, independent cookie/bearer clients, entirely offline adapters."""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import functools
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from control.app import Settings, create_app
|
||||||
|
|
||||||
|
PASSWORD = 'offline-cross-client-password'
|
||||||
|
ORIGIN = 'https://offline.test'
|
||||||
|
INTERNAL = {'X-Control-Secret': 'offline-control-secret-not-live-1234567890'}
|
||||||
|
|
||||||
|
|
||||||
|
def offline_async(function):
|
||||||
|
@functools.wraps(function)
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
return asyncio.run(function(*args, **kwargs))
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
class Provisioner:
|
||||||
|
async def ensure(self, user):
|
||||||
|
return {'container_name': user['container_name'], 'hermes_url': 'http://127.0.0.1:12345',
|
||||||
|
'hermes_api_key': 'offline-placeholder'}
|
||||||
|
async def ready(self, user):
|
||||||
|
return True
|
||||||
|
async def remove(self, user):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Features:
|
||||||
|
async def feature_capabilities(self, owner):
|
||||||
|
return {'image_input': True, 'scheduled_tasks': False, 'memory_controls': False}
|
||||||
|
|
||||||
|
|
||||||
|
class Runs:
|
||||||
|
available = True
|
||||||
|
approvals = False
|
||||||
|
def __init__(self):
|
||||||
|
self.started = []
|
||||||
|
self.stopped = []
|
||||||
|
async def start(self, owner, rid, payload):
|
||||||
|
self.started.append((owner, rid, payload))
|
||||||
|
return {'run_id': 'offline-' + rid}
|
||||||
|
async def status(self, owner, upstream):
|
||||||
|
return {'status': 'running'}
|
||||||
|
async def events(self, owner, upstream):
|
||||||
|
yield {'event': 'message.delta', 'delta': 'Offline answer'}
|
||||||
|
yield {'event': 'run.completed', 'output': 'Offline answer'}
|
||||||
|
async def stop(self, owner, upstream):
|
||||||
|
self.stopped.append(upstream)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def clients(tmp_path):
|
||||||
|
adapter = Runs()
|
||||||
|
app = create_app(Settings(db_path=str(tmp_path/'offline.sqlite'), admin_password=PASSWORD,
|
||||||
|
allowed_origins=(ORIGIN,), service_secret=INTERNAL['X-Control-Secret'],
|
||||||
|
reconcile_interval=3600), Provisioner(), chat_adapter=adapter,
|
||||||
|
feature_provider=Features())
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=ORIGIN) as browser, \
|
||||||
|
httpx.AsyncClient(transport=transport, base_url=ORIGIN) as native, \
|
||||||
|
httpx.AsyncClient(transport=transport, base_url=ORIGIN) as anonymous:
|
||||||
|
login = await browser.post('/api/auth/login', headers={'Origin': ORIGIN},
|
||||||
|
json={'username': 'admin', 'password': PASSWORD})
|
||||||
|
assert login.status_code == 200
|
||||||
|
user = login.json()['user']
|
||||||
|
login2 = await native.post('/api/auth/login', json={'username': 'admin', 'password': PASSWORD})
|
||||||
|
assert login2.status_code == 200
|
||||||
|
native_token = login2.json()['token']
|
||||||
|
native.cookies.clear()
|
||||||
|
native.headers['Authorization'] = 'Bearer ' + native_token
|
||||||
|
browser.headers['Origin'] = ORIGIN
|
||||||
|
# Provisioning adapter is fake and completes locally; no socket is opened.
|
||||||
|
for _ in range(200):
|
||||||
|
if app.state.accounts.get(user['id'])['status'] == 'ready':
|
||||||
|
break
|
||||||
|
await asyncio.sleep(.001)
|
||||||
|
assert app.state.accounts.get(user['id'])['status'] == 'ready'
|
||||||
|
yield app, adapter, browser, native, anonymous, user, native_token
|
||||||
|
|
||||||
|
|
||||||
|
async def completed(client, rid):
|
||||||
|
for _ in range(200):
|
||||||
|
response = await client.get('/api/runs/' + rid)
|
||||||
|
assert response.status_code == 200
|
||||||
|
if response.json()['run']['status'] in ('completed', 'failed', 'cancelled'):
|
||||||
|
return response.json()
|
||||||
|
await asyncio.sleep(.005)
|
||||||
|
raise AssertionError('Offline run did not finish')
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_browser_native_resume_pin_preferences_pages_and_event_cursor(tmp_path):
|
||||||
|
async with clients(tmp_path) as (app, adapter, browser, native, anonymous, user, token):
|
||||||
|
assert (await anonymous.get('/api/conversations')).status_code == 401
|
||||||
|
response = await browser.post('/api/conversations', json={'title': 'Shared devices'})
|
||||||
|
assert response.status_code == 200
|
||||||
|
conversation = response.json()['conversation']; cid = conversation['id']
|
||||||
|
pinned = await native.patch('/api/conversations/' + cid,
|
||||||
|
json={'revision': conversation['revision'], 'pinned': True})
|
||||||
|
assert pinned.status_code == 200
|
||||||
|
listed = (await browser.get('/api/conversations')).json()['conversations']
|
||||||
|
assert listed[0]['id'] == cid and listed[0]['pinned'] is True
|
||||||
|
saved = await browser.put('/api/preferences', json={'revision': 0, 'palette': 'sage', 'appearance': 'dark'})
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert (await native.get('/api/preferences')).json() == saved.json()
|
||||||
|
operation = await browser.post('/api/conversations/' + cid + '/messages',
|
||||||
|
json={'content': 'From browser', 'idempotency_key': 'browser-turn'})
|
||||||
|
assert operation.status_code == 202
|
||||||
|
rid = operation.json()['run']['id']
|
||||||
|
assert (await completed(native, rid))['run']['status'] == 'completed'
|
||||||
|
# A separate bearer-authenticated device retries the uncertain same turn.
|
||||||
|
replay = await native.post('/api/conversations/' + cid + '/messages',
|
||||||
|
json={'content': 'From browser', 'idempotency_key': 'browser-turn'})
|
||||||
|
assert replay.status_code == 202 and replay.json()['run']['id'] == rid
|
||||||
|
assert len(adapter.started) == 1
|
||||||
|
recent = (await native.get('/api/conversations/' + cid, params={'limit': 1})).json()
|
||||||
|
assert recent['messages'][0]['role'] == 'assistant'
|
||||||
|
older = (await browser.get('/api/conversations/' + cid,
|
||||||
|
params={'limit': 1, 'cursor': recent['next_cursor']})).json()
|
||||||
|
assert older['messages'][0]['content'] == 'From browser'
|
||||||
|
event_stream = await native.get('/api/runs/' + rid + '/events')
|
||||||
|
assert event_stream.status_code == 200 and 'event: done' in event_stream.text
|
||||||
|
ids = [int(line[4:]) for line in event_stream.text.splitlines() if line.startswith('id: ')]
|
||||||
|
resumed = await browser.get('/api/runs/' + rid + '/events', headers={'Last-Event-ID': str(ids[-1])})
|
||||||
|
assert resumed.status_code == 200 and 'data:' not in resumed.text
|
||||||
|
changes = (await native.get('/api/sync')).json()['changes']
|
||||||
|
assert any(item['kind'] == 'preferences' for item in changes)
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_cookie_csrf_and_independent_session_revocation(tmp_path):
|
||||||
|
async with clients(tmp_path) as (app, adapter, browser, native, anonymous, user, token):
|
||||||
|
browser.headers.pop('Origin')
|
||||||
|
assert (await browser.post('/api/conversations', json={'title': 'No origin'})).status_code == 403
|
||||||
|
assert (await browser.post('/api/conversations', headers={'Origin': 'https://evil.test'},
|
||||||
|
json={'title': 'Wrong origin'})).status_code == 403
|
||||||
|
browser.headers['Origin'] = ORIGIN
|
||||||
|
assert (await native.post('/api/conversations', json={'title': 'Native no origin'})).status_code == 200
|
||||||
|
assert (await browser.post('/api/auth/logout')).status_code == 200
|
||||||
|
assert (await browser.get('/api/conversations')).status_code == 401
|
||||||
|
assert (await native.get('/api/conversations')).status_code == 200
|
||||||
|
reset = await native.post('/api/admin/users/' + user['id'] + '/password',
|
||||||
|
json={'password': 'offline-replacement-password'})
|
||||||
|
assert reset.status_code == 200
|
||||||
|
assert (await native.get('/api/conversations')).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_text_attachment_real_admission_provider_payload_and_cross_device_read(tmp_path):
|
||||||
|
async with clients(tmp_path) as (app, adapter, browser, native, anonymous, user, token):
|
||||||
|
upload = await browser.post('/api/attachments', content=b'Offline attachment marker',
|
||||||
|
headers={'Content-Type': 'text/plain', 'X-Filename': 'note.txt'})
|
||||||
|
assert upload.status_code == 201
|
||||||
|
attachment = upload.json()['attachment']
|
||||||
|
cid = (await native.post('/api/conversations', json={'title': 'Attachment'})).json()['conversation']['id']
|
||||||
|
operation = await native.post('/api/conversations/' + cid + '/messages',
|
||||||
|
json={'content': 'Summarize', 'attachment_ids': [attachment['id']],
|
||||||
|
'idempotency_key': 'attachment-turn'})
|
||||||
|
assert operation.status_code == 202, operation.text
|
||||||
|
assert (await completed(browser, operation.json()['run']['id']))['run']['status'] == 'completed'
|
||||||
|
payload = adapter.started[0][2]
|
||||||
|
assert payload['input'][0]['role'] == 'user'
|
||||||
|
assert any('Offline attachment marker' in part.get('text', '')
|
||||||
|
for part in payload['input'][0]['content'])
|
||||||
|
content = await native.get('/api/attachments/' + attachment['id'] + '/content')
|
||||||
|
assert content.content == b'Offline attachment marker'
|
||||||
|
assert content.headers['x-content-type-options'] == 'nosniff'
|
||||||
|
transcript = (await browser.get('/api/conversations/' + cid)).json()
|
||||||
|
assert transcript['messages'][0]['attachments'], 'Saved message must retain attachment selection'
|
||||||
|
png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jG1sAAAAASUVORK5CYII=')
|
||||||
|
image = await native.post('/api/attachments', content=png,
|
||||||
|
headers={'Content-Type': 'image/png', 'X-Filename': 'pixel.png'})
|
||||||
|
assert image.status_code == 201
|
||||||
|
image_turn = await browser.post('/api/conversations/' + cid + '/messages',
|
||||||
|
json={'content': 'Describe image', 'attachment_ids': [image.json()['attachment']['id']],
|
||||||
|
'idempotency_key': 'image-turn'})
|
||||||
|
assert image_turn.status_code == 202, image_turn.text
|
||||||
|
assert (await completed(native, image_turn.json()['run']['id']))['run']['status'] == 'completed'
|
||||||
|
image_parts = adapter.started[-1][2]['input'][0]['content']
|
||||||
|
assert any(part.get('image_url', {}).get('url') == 'data:image/png;base64,' + base64.b64encode(png).decode()
|
||||||
|
for part in image_parts)
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_room_grant_is_bound_to_original_session_and_conversation(tmp_path):
|
||||||
|
async with clients(tmp_path) as (app, adapter, browser, native, anonymous, user, token):
|
||||||
|
cid = (await browser.post('/api/conversations', json={'title': 'Voice resume'})).json()['conversation']['id']
|
||||||
|
room = 'offline-old-room'
|
||||||
|
grant = await anonymous.post('/internal/rooms', headers=INTERNAL,
|
||||||
|
json={'user_id': user['id'], 'room_name': room, 'session_token': token,
|
||||||
|
'conversation_id': cid})
|
||||||
|
assert grant.status_code == 201
|
||||||
|
assert (await anonymous.get('/internal/rooms/' + room, headers=INTERNAL)).json()['conversation_id'] == cid
|
||||||
|
voice = await anonymous.post('/internal/chat/rooms/' + room + '/messages', headers=INTERNAL,
|
||||||
|
json={'content': 'Voice same conversation', 'modality': 'voice',
|
||||||
|
'idempotency_key': 'voice-turn'})
|
||||||
|
assert voice.status_code == 202
|
||||||
|
await completed(browser, voice.json()['run']['id'])
|
||||||
|
assert 'Voice same conversation' in str((await browser.get('/api/conversations/' + cid)).json()['messages'])
|
||||||
|
assert (await native.post('/api/auth/logout')).status_code == 200
|
||||||
|
assert (await browser.get('/api/conversations/' + cid)).status_code == 200
|
||||||
|
assert (await anonymous.get('/internal/rooms/' + room, headers=INTERNAL)).status_code == 404
|
||||||
|
late = await anonymous.post('/internal/chat/rooms/' + room + '/messages', headers=INTERNAL,
|
||||||
|
json={'content': 'Stale grant', 'modality': 'voice', 'idempotency_key': 'stale'})
|
||||||
|
assert late.status_code == 404
|
||||||
|
# Logging in again cannot revive the old grant.
|
||||||
|
native.headers.pop('Authorization')
|
||||||
|
login = await native.post('/api/auth/login', json={'username': 'admin', 'password': PASSWORD})
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert (await anonymous.get('/internal/rooms/' + room, headers=INTERNAL)).status_code == 404
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Offline owned exports; no Hermes/LiveKit/deployment calls."""
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from control.accounts import Accounts
|
||||||
|
from control.app import Settings
|
||||||
|
from control.chat_exports import create_export_router, export_conversation
|
||||||
|
from control.chat_store import ChatStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path):
|
||||||
|
accounts = Accounts(str(tmp_path / 'accounts.db'))
|
||||||
|
users = [accounts.create(name, name, 'user', 'correct-horse-password') for name in ('alice', 'bob')]
|
||||||
|
tokens = [accounts.issue_session(u['id'], 3600, u['password_hash'])[0] for u in users]
|
||||||
|
store = ChatStore(accounts)
|
||||||
|
conversation = store.create_conversation(users[0]['id'], title='Private')
|
||||||
|
store.add_message(users[0]['id'], conversation['id'], 'user', 'Original question')
|
||||||
|
store.add_message(users[0]['id'], conversation['id'], 'assistant', '<script>untrusted</script> [Source](https://example.test)')
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.accounts = accounts
|
||||||
|
app.state.settings = Settings()
|
||||||
|
app.state.chat_store = store
|
||||||
|
app.include_router(create_export_router())
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield accounts, store, client, users, tokens, conversation
|
||||||
|
accounts.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_projection_and_safe_markdown(env):
|
||||||
|
accounts, store, client, users, tokens, conversation = env
|
||||||
|
headers = {'Authorization': 'Bearer ' + tokens[0]}
|
||||||
|
url = '/api/conversations/' + conversation['id'] + '/export'
|
||||||
|
response = client.get(url, headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
document = response.json()
|
||||||
|
assert len(document['messages']) == 2
|
||||||
|
assert document['messages'][0]['content'] == 'Original question'
|
||||||
|
assert 'hermes_session_id' not in response.text
|
||||||
|
assert response.headers['cache-control'] == 'no-store'
|
||||||
|
assert response.headers['content-disposition'].startswith('attachment;')
|
||||||
|
markdown = client.get(url + '?format=markdown', headers=headers)
|
||||||
|
assert markdown.status_code == 200
|
||||||
|
assert markdown.headers['content-type'].startswith('text/plain')
|
||||||
|
assert '[Source](https://example.test)' in markdown.text
|
||||||
|
assert '<script>untrusted</script>' in markdown.text
|
||||||
|
assert client.get(url + '?format=html', headers=headers).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_owned_revoked_deleted_and_bounded(env):
|
||||||
|
accounts, store, client, users, tokens, conversation = env
|
||||||
|
url = '/api/conversations/' + conversation['id'] + '/export'
|
||||||
|
assert client.get(url).status_code == 401
|
||||||
|
assert client.get(url, headers={'Authorization': 'Bearer ' + tokens[1]}).status_code == 404
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
export_conversation(store, users[0]['id'], conversation['id'], byte_limit=4)
|
||||||
|
assert error.value.status_code == 413
|
||||||
|
accounts.revoke(tokens[0])
|
||||||
|
assert client.get(url, headers={'Authorization': 'Bearer ' + tokens[0]}).status_code == 401
|
||||||
|
store.delete_conversation(users[0]['id'], conversation['id'])
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
export_conversation(store, users[0]['id'], conversation['id'])
|
||||||
|
assert error.value.status_code == 404
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"""Offline advanced API isolation, quotas, revocation and version tests."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from control.accounts import Accounts
|
||||||
|
from control.app import Settings
|
||||||
|
from control.chat_features import FeatureLimits, FeatureProvider, FeatureStore, create_feature_router, local_capabilities
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path):
|
||||||
|
accounts = Accounts(str(tmp_path / 'accounts.db'))
|
||||||
|
users = [accounts.create(name, name, 'user', 'correct-horse-password') for name in ('alice', 'bob')]
|
||||||
|
for user in users:
|
||||||
|
accounts.update(user['id'], status='ready')
|
||||||
|
tokens = [accounts.issue_session(u['id'], 3600, u['password_hash'])[0] for u in users]
|
||||||
|
limits = FeatureLimits(upload_bytes=128, account_bytes=180, disk_bytes=256, artifact_bytes=128, artifact_count=3, artifact_versions=3)
|
||||||
|
store = FeatureStore(accounts, tmp_path / 'private', limits)
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.accounts = accounts
|
||||||
|
app.state.settings = Settings(allowed_origins=('https://testserver',))
|
||||||
|
app.include_router(create_feature_router(store))
|
||||||
|
with TestClient(app, base_url='https://testserver') as client:
|
||||||
|
yield SimpleNamespace(accounts=accounts, store=store, client=client, users=users, tokens=tokens, headers=[{'Authorization': 'Bearer ' + t} for t in tokens])
|
||||||
|
accounts.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upload(env, data=b'hello', mime='text/plain', filename='note.txt', account=0):
|
||||||
|
return env.client.post('/api/attachments', content=data, headers={**env.headers[account], 'Content-Type': mime, 'X-Filename': filename})
|
||||||
|
|
||||||
|
|
||||||
|
def artifact(env):
|
||||||
|
result = env.client.post('/api/artifacts', headers=env.headers[0], json={'title': 'Draft', 'kind': 'markdown', 'content': '# Hello'})
|
||||||
|
assert result.status_code == 201, result.text
|
||||||
|
return result.json()['artifact']
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_download_private_and_owned(env):
|
||||||
|
result = upload(env)
|
||||||
|
assert result.status_code == 201
|
||||||
|
item = result.json()['attachment']
|
||||||
|
assert set(item) == {'id', 'filename', 'mime', 'size', 'extraction_status', 'created_at'}
|
||||||
|
assert item['extraction_status'] == 'ready'
|
||||||
|
path = env.store.root / item['id']
|
||||||
|
assert os.stat(path).st_mode & 0o777 == 0o600
|
||||||
|
assert os.stat(env.store.root).st_mode & 0o777 == 0o700
|
||||||
|
url = '/api/attachments/' + item['id']
|
||||||
|
response = env.client.get(url + '/content', headers=env.headers[0])
|
||||||
|
assert response.content == b'hello'
|
||||||
|
assert response.headers['cache-control'] == 'no-store'
|
||||||
|
for method, suffix in (('get', ''), ('get', '/content'), ('delete', '')):
|
||||||
|
assert getattr(env.client, method)(url + suffix, headers=env.headers[1]).status_code == 404
|
||||||
|
assert env.client.get('/api/attachments', headers=env.headers[1]).json()['attachments'] == []
|
||||||
|
assert env.client.get(url).status_code == 401
|
||||||
|
content = asyncio.run(env.store.provider_content(env.users[0]['id'], [item['id']]))
|
||||||
|
assert content == [{'type': 'text', 'text': 'Attachment: note.txt\nhello'}]
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(env.store.provider_content(env.users[1]['id'], [item['id']]))
|
||||||
|
assert error.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('data,mime,filename,status', [
|
||||||
|
(b'hello', 'text/html', 'a.html', 415),
|
||||||
|
(b'hello', 'image/png', 'a.png', 415),
|
||||||
|
(b'hello', 'application/pdf', 'a.pdf', 415),
|
||||||
|
(b'RIFFbad', 'image/webp', 'a.webp', 415),
|
||||||
|
(b'\xff', 'text/plain', 'a.txt', 415),
|
||||||
|
(b'\x00', 'text/plain', 'a.txt', 415),
|
||||||
|
(b'hello', 'text/plain', '../escape', 422),
|
||||||
|
(b'hello', 'text/plain', 'dir\\escape', 422),
|
||||||
|
(b'x' * 129, 'text/plain', 'a.txt', 413),
|
||||||
|
(b'', 'text/plain', 'a.txt', 413),
|
||||||
|
])
|
||||||
|
def test_mime_bytes_paths(env, data, mime, filename, status):
|
||||||
|
assert upload(env, data, mime, filename).status_code == status
|
||||||
|
assert list(env.store.root.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_quotas_delete_tombstones_and_reuse(env):
|
||||||
|
a = upload(env, b'x' * 100).json()['attachment']
|
||||||
|
assert upload(env, b'x' * 90).status_code == 413
|
||||||
|
assert upload(env, b'y' * 128, account=1).status_code == 201
|
||||||
|
assert upload(env, b'z' * 29, account=1).status_code == 413
|
||||||
|
assert env.client.delete('/api/attachments/' + a['id'], headers=env.headers[0]).status_code == 200
|
||||||
|
assert not (env.store.root / a['id']).exists()
|
||||||
|
assert env.client.get('/api/attachments/' + a['id'], headers=env.headers[0]).status_code == 404
|
||||||
|
changes = env.client.get('/api/features/sync', headers=env.headers[0]).json()
|
||||||
|
assert [c['deleted'] for c in changes['changes']] == [False, True]
|
||||||
|
assert len(env.client.get('/api/features/sync', headers=env.headers[1]).json()['changes']) == 1
|
||||||
|
assert upload(env, b'x' * 100).status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlink_denial(env, tmp_path):
|
||||||
|
item = upload(env).json()['attachment']
|
||||||
|
path = env.store.root / item['id']
|
||||||
|
outside = tmp_path / 'outside'
|
||||||
|
outside.write_bytes(b'private')
|
||||||
|
path.unlink()
|
||||||
|
path.symlink_to(outside)
|
||||||
|
assert env.client.get('/api/attachments/' + item['id'] + '/content', headers=env.headers[0]).status_code == 404
|
||||||
|
assert env.client.delete('/api/attachments/' + item['id'], headers=env.headers[0]).status_code == 404
|
||||||
|
assert outside.read_bytes() == b'private'
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_payload_requires_explicit_vision(env):
|
||||||
|
image = b'\x89PNG\r\n\x1a\n' + b'fake-offline-image'
|
||||||
|
item = upload(env, image, 'image/png', 'a.png').json()['attachment']
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(env.store.provider_content(env.users[0]['id'], [item['id']]))
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
parts = asyncio.run(env.store.provider_content(env.users[0]['id'], [item['id']], vision=True))
|
||||||
|
assert parts[0]['image_url']['url'].startswith('data:image/png;base64,')
|
||||||
|
assert not local_capabilities(env.store)['image_input']
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_version_conflict_export_and_tombstones(env):
|
||||||
|
item = artifact(env)
|
||||||
|
url = '/api/artifacts/' + item['id']
|
||||||
|
assert env.client.patch(url, headers=env.headers[0], json={'revision': 0, 'content': 'No'}).status_code == 409
|
||||||
|
response = env.client.patch(url, headers=env.headers[0], json={'revision': 1, 'content': 'Second'})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['artifact']['revision'] == 2
|
||||||
|
assert env.client.get(url + '?revision=1', headers=env.headers[0]).json()['artifact']['content'] == '# Hello'
|
||||||
|
assert env.client.get(url + '/export?revision=1', headers=env.headers[0]).content == b'# Hello'
|
||||||
|
assert env.client.get(url + '/export', headers=env.headers[0]).content == b'Second'
|
||||||
|
for method, suffix in (('get', ''), ('get', '/export'), ('delete', '')):
|
||||||
|
assert getattr(env.client, method)(url + suffix, headers=env.headers[1]).status_code == 404
|
||||||
|
assert env.client.patch(url, headers=env.headers[1], json={'revision': 2, 'content': 'theft'}).status_code == 404
|
||||||
|
assert env.client.delete(url, headers=env.headers[0]).status_code == 200
|
||||||
|
assert env.client.get(url + '?revision=1', headers=env.headers[0]).status_code == 404
|
||||||
|
assert env.client.get('/api/features/sync', headers=env.headers[0]).json()['changes'][-1]['deleted']
|
||||||
|
|
||||||
|
|
||||||
|
def test_revocation_and_delete_fk(env):
|
||||||
|
attachment = upload(env).json()['attachment']
|
||||||
|
item = artifact(env)
|
||||||
|
env.accounts.revoke(env.tokens[0])
|
||||||
|
assert env.client.get('/api/attachments/' + attachment['id'] + '/content', headers=env.headers[0]).status_code == 401
|
||||||
|
assert env.client.get('/api/artifacts/' + item['id'], headers=env.headers[0]).status_code == 401
|
||||||
|
env.accounts.begin_delete(env.users[0]['id'])
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(env.store.provider_content(env.users[0]['id'], [attachment['id']]))
|
||||||
|
assert error.value.status_code == 401
|
||||||
|
env.accounts.finish_delete(env.users[0]['id'])
|
||||||
|
assert env.accounts.db.execute('SELECT COUNT(*) FROM hope_attachments').fetchone()[0] == 0
|
||||||
|
assert env.accounts.db.execute('SELECT COUNT(*) FROM hope_artifact_versions').fetchone()[0] == 0
|
||||||
|
# Retained private disk is deliberate; account deletion is not guarded erase.
|
||||||
|
assert (env.store.root / attachment['id']).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookie_csrf_limits_and_artifact_versions(env):
|
||||||
|
env.client.cookies.set('hope_session', env.tokens[0])
|
||||||
|
assert env.client.post('/api/artifacts', json={'title': 'x'}).status_code == 403
|
||||||
|
item = artifact(env)
|
||||||
|
url = '/api/artifacts/' + item['id']
|
||||||
|
assert env.client.patch(url, headers=env.headers[0], json={'revision': 1, 'content': 'x' * 129}).status_code == 413
|
||||||
|
assert env.client.patch(url, headers=env.headers[0], json={'revision': 1}).status_code == 200
|
||||||
|
assert env.client.patch(url, headers=env.headers[0], json={'revision': 2}).status_code == 200
|
||||||
|
assert env.client.patch(url, headers=env.headers[0], json={'revision': 3}).status_code == 413
|
||||||
|
assert env.client.post('/api/artifacts', headers=env.headers[0], json={'title': 'x', 'user_id': env.users[1]['id']}).status_code == 422
|
||||||
|
assert env.client.get('/api/features/sync?after=-1', headers=env.headers[0]).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFeatures:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.qualified = True
|
||||||
|
|
||||||
|
async def feature_capabilities(self, owner):
|
||||||
|
return {'scheduled_tasks': self.qualified}
|
||||||
|
|
||||||
|
async def feature_request(self, owner, method, path, *, json=None, params=None):
|
||||||
|
self.calls.append((owner, method, path, json, params))
|
||||||
|
job = {'id': 'abcdef123456', 'name': 'Reminder', 'schedule': 'every 2h', 'prompt': 'Hello', 'enabled': True,
|
||||||
|
'provider': 'secret', 'base_url': 'secret', 'api_key': 'secret', 'script': 'secret'}
|
||||||
|
if method == 'GET':
|
||||||
|
return {'jobs': [job]}
|
||||||
|
if method == 'DELETE':
|
||||||
|
return {'ok': True}
|
||||||
|
return {'job': job}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_exact_gateway_mapping_no_secret_projection(env):
|
||||||
|
fake = FakeFeatures()
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.accounts = env.accounts
|
||||||
|
app.state.settings = Settings()
|
||||||
|
app.include_router(create_feature_router(env.store, fake))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
result = client.get('/api/tasks', headers=env.headers[0])
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert 'secret' not in result.text
|
||||||
|
assert fake.calls[-1] == (env.users[0]['id'], 'GET', '/api/jobs', None, {'include_disabled': 'true'})
|
||||||
|
body = {'name': 'Reminder', 'schedule': 'every 2h', 'prompt': 'Hello', 'repeat': 2}
|
||||||
|
result = client.post('/api/tasks', headers=env.headers[0], json=body)
|
||||||
|
assert result.status_code == 201
|
||||||
|
assert fake.calls[-1][3] == {**body, 'deliver': 'local'}
|
||||||
|
assert client.post('/api/tasks', headers=env.headers[0], json={**body, 'deliver': 'external'}).status_code == 422
|
||||||
|
assert client.patch('/api/tasks/abcdef123456', headers=env.headers[0], json={'enabled': False}).status_code == 200
|
||||||
|
for action in ('pause', 'resume', 'run'):
|
||||||
|
assert client.post('/api/tasks/abcdef123456/' + action, headers=env.headers[1]).status_code == 200
|
||||||
|
assert fake.calls[-1][0] == env.users[1]['id']
|
||||||
|
assert fake.calls[-1][2] == '/api/jobs/abcdef123456/' + action
|
||||||
|
assert client.delete('/api/tasks/abcdef123456', headers=env.headers[0]).status_code == 200
|
||||||
|
assert client.post('/api/tasks/bad/run', headers=env.headers[0]).status_code == 404
|
||||||
|
fake.qualified = False
|
||||||
|
assert client.get('/api/tasks', headers=env.headers[0]).status_code == 501
|
||||||
|
assert client.get('/api/connectors', headers=env.headers[0]).json()['available'] is False
|
||||||
|
assert client.get('/api/research', headers=env.headers[0]).json()['available'] is False
|
||||||
|
assert client.get('/api/memory', headers=env.headers[0]).status_code == 501
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_provider_auth_fixed_paths_memory_conflict(env, tmp_path):
|
||||||
|
import httpx
|
||||||
|
requests = []
|
||||||
|
def handle(request):
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(200, json={'jobs': []})
|
||||||
|
http = httpx.AsyncClient(transport=httpx.MockTransport(handle))
|
||||||
|
provider = FeatureProvider(env.accounts, http_client=http, state_dir=tmp_path / 'hermes')
|
||||||
|
env.accounts.update(env.users[0]['id'], hermes_url='http://127.0.0.1:20000', hermes_api_key='private')
|
||||||
|
memory = provider.state_dir / env.users[0]['id'] / 'home' / 'memories'
|
||||||
|
memory.mkdir(parents=True)
|
||||||
|
(memory / 'MEMORY.md').write_text('old')
|
||||||
|
result = asyncio.run(provider.read_memory(env.users[0]['id']))
|
||||||
|
revision = result['memory']['revision']
|
||||||
|
updated = asyncio.run(provider.write_memory(env.users[0]['id'], 'memory', 'new', revision))
|
||||||
|
assert updated['revision'] != revision
|
||||||
|
assert (memory / 'MEMORY.md').read_text() == 'new'
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.write_memory(env.users[0]['id'], 'memory', 'stale', revision))
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
asyncio.run(provider.feature_request(env.users[0]['id'], 'GET', '/api/jobs'))
|
||||||
|
assert requests[-1].headers['authorization'] == 'Bearer private'
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.feature_request(env.users[0]['id'], 'GET', '/api/config'))
|
||||||
|
assert error.value.status_code == 501
|
||||||
|
outside = tmp_path / 'private-file'
|
||||||
|
outside.write_text('never touch')
|
||||||
|
(memory / 'USER.md').symlink_to(outside)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.read_memory(env.users[0]['id']))
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
assert outside.read_text() == 'never touch'
|
||||||
|
env.accounts.begin_delete(env.users[0]['id'])
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.feature_request(env.users[0]['id'], 'GET', '/api/jobs'))
|
||||||
|
assert error.value.status_code == 401
|
||||||
|
asyncio.run(http.aclose())
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_special_file_and_hardlink_rejected(env, tmp_path):
|
||||||
|
provider = FeatureProvider(env.accounts, state_dir=tmp_path / 'hermes')
|
||||||
|
env.accounts.update(env.users[0]['id'], hermes_url='http://127.0.0.1:20000', hermes_api_key='private')
|
||||||
|
directory = provider.state_dir / env.users[0]['id'] / 'home' / 'memories'
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
target = directory / 'MEMORY.md'
|
||||||
|
os.mkfifo(target)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.read_memory(env.users[0]['id']))
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
target.unlink()
|
||||||
|
outside = tmp_path / 'outside-memory'
|
||||||
|
outside.write_text('untouched')
|
||||||
|
os.link(outside, target)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.write_memory(env.users[0]['id'], 'memory', 'bad', 'x'))
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
assert outside.read_text() == 'untouched'
|
||||||
|
asyncio.run(provider.aclose())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cumulative_artifact_quota_and_metadata_list(env):
|
||||||
|
from dataclasses import replace
|
||||||
|
env.store.limits = replace(env.store.limits, account_artifact_bytes=12, global_artifact_bytes=20)
|
||||||
|
item = artifact(env) # Seven bytes.
|
||||||
|
listing = env.client.get('/api/artifacts', headers=env.headers[0]).json()['artifacts']
|
||||||
|
assert 'content' not in listing[0]
|
||||||
|
assert env.client.patch('/api/artifacts/' + item['id'], headers=env.headers[0], json={'revision': 1, 'content': '123456'}).status_code == 413
|
||||||
|
assert env.client.patch('/api/artifacts/' + item['id'], headers=env.headers[0], json={'revision': 1, 'content': '12345'}).status_code == 200
|
||||||
|
versions = env.client.get('/api/artifacts/' + item['id'] + '/versions', headers=env.headers[0]).json()['versions']
|
||||||
|
assert [v['revision'] for v in versions] == [2, 1]
|
||||||
|
assert env.client.get('/api/artifacts/' + item['id'] + '/versions', headers=env.headers[1]).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_subprocess_limits_and_admission(env, monkeypatch):
|
||||||
|
import control.chat_features as module
|
||||||
|
monkeypatch.setattr(module.importlib.util, 'find_spec', lambda name: object())
|
||||||
|
calls = []
|
||||||
|
def run(args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
assert 'RLIMIT_AS' in args[3] and 'RLIMIT_CPU' in args[3]
|
||||||
|
assert kwargs['timeout'] == 6
|
||||||
|
return SimpleNamespace(stdout=b'["extracted", "ready"]')
|
||||||
|
monkeypatch.setattr(module.subprocess, 'run', run)
|
||||||
|
response = upload(env, b'%PDF-offline', 'application/pdf', 'file.pdf')
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()['attachment']['extraction_status'] == 'ready'
|
||||||
|
assert len(calls) == 1
|
||||||
|
env.store.upload_admission.acquire()
|
||||||
|
env.store.upload_admission.acquire()
|
||||||
|
try:
|
||||||
|
assert upload(env, b'%PDF-offline', 'application/pdf', 'file.pdf').status_code == 429
|
||||||
|
assert len(calls) == 1
|
||||||
|
finally:
|
||||||
|
env.store.upload_admission.release()
|
||||||
|
env.store.upload_admission.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_exact_tool_manifest_and_status_projection(env):
|
||||||
|
import httpx
|
||||||
|
manifest = {'object': 'list', 'platform': 'api_server', 'data': [
|
||||||
|
{'name': 'web', 'enabled': True, 'configured': True, 'tools': ['web_search', 'web_extract'], 'secret': 'never expose'}]}
|
||||||
|
requests = []
|
||||||
|
def handle(request):
|
||||||
|
requests.append(request)
|
||||||
|
assert request.url.path == '/v1/toolsets'
|
||||||
|
return httpx.Response(200, json=manifest)
|
||||||
|
http = httpx.AsyncClient(transport=httpx.MockTransport(handle))
|
||||||
|
env.accounts.update(env.users[0]['id'], hermes_url='http://127.0.0.1:20000', hermes_api_key='private')
|
||||||
|
provider = FeatureProvider(env.accounts, http_client=http)
|
||||||
|
assert asyncio.run(provider.research_status(env.users[0]['id'])) == {'available': True, 'mode': 'hermes_tools', 'reason': None}
|
||||||
|
instruction = asyncio.run(provider.research_instruction(env.users[0]['id']))
|
||||||
|
assert 'same Hermes run' in instruction and 'cite source URLs' in instruction
|
||||||
|
assert requests[-1].headers['authorization'] == 'Bearer private'
|
||||||
|
assert 'secret' not in str(asyncio.run(provider.toolset_status(env.users[0]['id'])))
|
||||||
|
manifest['data'][0]['configured'] = False
|
||||||
|
assert not asyncio.run(provider.research_status(env.users[0]['id']))['available']
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(provider.research_instruction(env.users[0]['id']))
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
manifest['data'][0]['configured'] = True
|
||||||
|
manifest['data'][0]['tools'] = ['web_search']
|
||||||
|
assert not asyncio.run(provider.research_status(env.users[0]['id']))['available']
|
||||||
|
asyncio.run(http.aclose())
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Offline Chromium qualification for advanced chat controls; no service calls."""
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
MOCK = r'''() => {
|
||||||
|
window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false;
|
||||||
|
window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true,
|
||||||
|
attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608};
|
||||||
|
window.artifact = {id:'a'.repeat(32),title:'Draft <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'};
|
||||||
|
window.task = {id:'b'.repeat(12),name:'Daily note',schedule:'0 9 * * *',prompt:'Review notes',enabled:true};
|
||||||
|
window.HopeAuth = {user:{id:'owner-a',status:'ready'},onChange(fn){listeners.push(fn);fn(this.user,true);},
|
||||||
|
async request(path,options={}) {
|
||||||
|
calls.push({path,method:options.method||'GET',body:typeof options.body==='string'?JSON.parse(options.body):null,
|
||||||
|
binary:options.body instanceof File,headers:options.headers});
|
||||||
|
if (path==='/api/capabilities') return {features:capabilities};
|
||||||
|
if (path==='/api/attachments') {
|
||||||
|
if(options.method==='GET') return {attachments:[{id:'c'.repeat(32),filename:'notes.txt',size:5,extraction_status:'ready'}]};
|
||||||
|
if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError'))));
|
||||||
|
return {attachment:{id:'c'.repeat(32),filename:options.body.name,extraction_status:'ready'}};
|
||||||
|
}
|
||||||
|
if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};}
|
||||||
|
if(path.startsWith('/api/artifacts/')) {
|
||||||
|
if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};}
|
||||||
|
return {artifact};
|
||||||
|
}
|
||||||
|
if(path==='/api/tasks') return options.method==='POST'?{task}:{tasks:[task]};
|
||||||
|
if(path.startsWith('/api/tasks/')) return {task};
|
||||||
|
if(path==='/api/memory') return {sections:[{section:'memory',content:'Saved facts',revision:'m1'},{section:'user',content:'Profile',revision:'u1'}]};
|
||||||
|
if(path.startsWith('/api/memory/')) {if(conflict) throw new Error('Memory revision conflict');return {...JSON.parse(options.body),revision:'m2'};}
|
||||||
|
return {ok:true};
|
||||||
|
}};
|
||||||
|
window.changeAccount = user => {HopeAuth.user=user;listeners.forEach(fn=>fn(user,true));};
|
||||||
|
window.createdURLs=[];window.revokedURLs=[];
|
||||||
|
URL.createObjectURL=()=>{const url='blob:preview-'+createdURLs.length;createdURLs.push(url);return url;};
|
||||||
|
URL.revokeObjectURL=url=>revokedURLs.push(url);
|
||||||
|
}'''
|
||||||
|
|
||||||
|
|
||||||
|
class ChatFeaturesWebTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
raise unittest.SkipTest('Playwright required for offline browser qualification')
|
||||||
|
cls.playwright = sync_playwright().start()
|
||||||
|
cls.browser = cls.playwright.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.browser.close()
|
||||||
|
cls.playwright.stop()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.page = self.browser.new_page()
|
||||||
|
self.page.set_content('<html><body><aside id="chatSidebar"></aside><form id="primaryComposer"><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
|
||||||
|
self.page.evaluate(MOCK)
|
||||||
|
self.page.add_script_tag(path=str(ROOT / 'web/features.js'))
|
||||||
|
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.page.close()
|
||||||
|
|
||||||
|
def open_panel(self, name):
|
||||||
|
self.page.get_by_text(name, exact=True).click()
|
||||||
|
|
||||||
|
def test_upload_raw_bytes_and_ack_only_clear(self):
|
||||||
|
self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.assertFalse(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
|
||||||
|
call = self.page.evaluate('calls.find(c=>c.path==="/api/attachments")')
|
||||||
|
self.assertTrue(call['binary'])
|
||||||
|
self.assertEqual(call['headers'], {'Content-Type':'text/plain','X-Filename':'notes.txt'})
|
||||||
|
self.page.evaluate('window.dispatchEvent(new CustomEvent("hope:conversation-selected",{detail:{conversation_id:"first"}}))')
|
||||||
|
self.assertEqual(len(self.page.evaluate('HopeFeatures.attachmentIDs()')), 1)
|
||||||
|
self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend([])')
|
||||||
|
self.assertEqual(len(self.page.evaluate('HopeFeatures.attachmentIDs()')), 1)
|
||||||
|
self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend()')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
|
||||||
|
def test_retained_upload_management_delete_is_explicit(self):
|
||||||
|
self.open_panel('Files')
|
||||||
|
self.page.get_by_text('Delete notes.txt',exact=True).click()
|
||||||
|
self.assertIn("old chat attachment references won't work", self.page.locator('#hopeFeatures').inner_text())
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="DELETE").length'), 0)
|
||||||
|
self.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content'))
|
||||||
|
self.page.get_by_role('button',name='Confirm delete notes.txt').click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.startsWith("/api/attachments/")&&c.method==="DELETE")')
|
||||||
|
|
||||||
|
def test_cancel_retry_and_conversation_change(self):
|
||||||
|
self.page.evaluate('pendingUpload=true')
|
||||||
|
self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
|
||||||
|
self.page.get_by_role('button',name='Cancel upload').click()
|
||||||
|
self.page.get_by_role('button',name='Retry upload').wait_for()
|
||||||
|
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
|
||||||
|
self.page.evaluate('pendingUpload=false')
|
||||||
|
self.page.get_by_role('button',name='Retry upload').click()
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.page.evaluate('window.dispatchEvent(new CustomEvent("hope:conversation-selected",{detail:{conversation_id:"one"}}));window.dispatchEvent(new CustomEvent("hope:conversation-selected",{detail:{conversation_id:"two"}}))')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
|
||||||
|
def test_capabilities_validation_and_logout_cleanup(self):
|
||||||
|
self.page.evaluate('capabilities.image_input=false;HopeFeatures.refresh()')
|
||||||
|
self.page.wait_for_function('document.getElementById("featureAvailability").textContent.includes("Image input")')
|
||||||
|
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"image.png",{type:"image/png"})])')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 0)
|
||||||
|
self.page.evaluate('capabilities.image_input=true;HopeFeatures.refresh()')
|
||||||
|
self.page.wait_for_function('document.querySelector("input[type=file]").accept.includes("image/png")')
|
||||||
|
self.page.locator('input[type=file]').set_input_files({'name':'image.png','mimeType':'image/png','buffer':b'fake image for mocked endpoint'})
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.page.evaluate('changeAccount(null)')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
self.assertEqual(self.page.evaluate('createdURLs'), self.page.evaluate('revokedURLs'))
|
||||||
|
self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
|
||||||
|
|
||||||
|
def test_artifact_safe_preview_versions_conflict_and_export(self):
|
||||||
|
self.open_panel('Artifacts')
|
||||||
|
self.page.get_by_role('button',name='Edit Draft',exact=False).click()
|
||||||
|
self.page.get_by_label('Content',exact=True).fill('<img src=x onerror=alert(1)> changed')
|
||||||
|
self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> changed')
|
||||||
|
self.assertEqual(self.page.locator('#hopeFeatures script, #hopeFeatures img').count(), 0)
|
||||||
|
self.page.evaluate('conflict=true')
|
||||||
|
self.page.get_by_role('button',name='Save new version').click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
|
||||||
|
self.assertIn('changed', self.page.get_by_label('Content',exact=True).input_value())
|
||||||
|
self.page.evaluate('conflict=false')
|
||||||
|
self.page.get_by_role('button',name='Save new version').click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("version 3")')
|
||||||
|
self.page.get_by_label('Saved version').select_option('1')
|
||||||
|
self.page.get_by_role('button',name='View saved version').click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.endsWith("?revision=1"))')
|
||||||
|
self.assertIn('/export', self.page.get_by_role('link',name='Export latest saved version').get_attribute('href'))
|
||||||
|
self.assertTrue(self.page.get_by_role('link',name='Export selected saved version').get_attribute('href').endswith('/export?revision=1'))
|
||||||
|
patch = self.page.evaluate('calls.find(c=>c.method==="PATCH")')
|
||||||
|
self.assertEqual(patch['body']['revision'], 2)
|
||||||
|
|
||||||
|
def test_task_crud_action_shapes(self):
|
||||||
|
self.open_panel('Scheduled tasks')
|
||||||
|
self.page.get_by_role('button',name='Pause',exact=True).click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.endsWith("/pause"))')
|
||||||
|
self.page.get_by_role('button',name='Run now').click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.endsWith("/run"))')
|
||||||
|
self.page.get_by_role('button',name='New scheduled task').click()
|
||||||
|
self.page.get_by_label('Task name').fill('Reminder')
|
||||||
|
self.page.get_by_label('Schedule',exact=True).fill('0 9 * * *')
|
||||||
|
self.page.get_by_label('Prompt',exact=True).fill('Summarize notes')
|
||||||
|
self.page.get_by_role('button',name='Create task',exact=True).click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path==="/api/tasks"&&c.method==="POST")')
|
||||||
|
data = self.page.evaluate('calls.find(c=>c.path==="/api/tasks"&&c.method==="POST").body')
|
||||||
|
self.assertEqual(data, {'name':'Reminder','schedule':'0 9 * * *','prompt':'Summarize notes','paused':True})
|
||||||
|
|
||||||
|
def test_drag_drop_validation_and_disabled_feature_panels(self):
|
||||||
|
self.page.evaluate('''() => {const transfer=new DataTransfer();transfer.items.add(new File(['notes'],'drop.txt',{type:'text/plain'}));document.getElementById('primaryComposer').dispatchEvent(new DragEvent('drop',{dataTransfer:transfer,bubbles:true,cancelable:true}));}''')
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"bad.svg",{type:"image/svg+xml"}),new File([],"empty.txt",{type:"text/plain"})])')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 1)
|
||||||
|
self.page.evaluate('capabilities.scheduled_tasks=false;capabilities.memory_controls=false;HopeFeatures.refresh()')
|
||||||
|
self.page.wait_for_function('document.getElementById("featureAvailability").textContent.includes("Scheduled tasks")')
|
||||||
|
self.assertTrue(self.page.get_by_text('Scheduled tasks',exact=True).is_hidden())
|
||||||
|
self.assertTrue(self.page.get_by_text('Memory',exact=True).is_hidden())
|
||||||
|
|
||||||
|
def test_task_edit_resume_delete(self):
|
||||||
|
self.page.evaluate('task.enabled=false')
|
||||||
|
self.open_panel('Scheduled tasks')
|
||||||
|
self.page.get_by_role('button',name='Resume',exact=True).click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.endsWith("/resume"))')
|
||||||
|
self.page.get_by_role('button',name='Edit task',exact=True).click()
|
||||||
|
self.page.get_by_label('Task name').fill('Renamed task')
|
||||||
|
self.page.get_by_role('button',name='Save task',exact=True).click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.startsWith("/api/tasks/")&&c.method==="PATCH")')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.find(c=>c.method==="PATCH").body.name'), 'Renamed task')
|
||||||
|
self.page.get_by_text('Delete task',exact=True).click()
|
||||||
|
self.page.get_by_role('button',name='Confirm delete task').click()
|
||||||
|
self.page.wait_for_function('calls.some(c=>c.path.startsWith("/api/tasks/")&&c.method==="DELETE")')
|
||||||
|
|
||||||
|
def test_memory_revision_conflict_retains_edits_and_explicit_clear(self):
|
||||||
|
self.open_panel('Memory')
|
||||||
|
content = self.page.get_by_label('Assistant memory',exact=True)
|
||||||
|
content.fill('Updated facts')
|
||||||
|
self.page.evaluate('conflict=true')
|
||||||
|
self.page.get_by_role('button',name='Save assistant memory').click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
|
||||||
|
self.assertEqual(content.input_value(), 'Updated facts')
|
||||||
|
self.page.evaluate('conflict=false')
|
||||||
|
self.page.get_by_role('button',name='Save assistant memory').click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("saved")')
|
||||||
|
self.page.get_by_text('Clear assistant memory',exact=True).click()
|
||||||
|
self.page.get_by_role('button',name='Confirm clear assistant memory').click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("cleared")')
|
||||||
|
last = self.page.evaluate('calls.filter(c=>c.path==="/api/memory/memory").at(-1).body')
|
||||||
|
self.assertEqual(last, {'content':'','revision':'m2'})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Offline source packaging guards; no image build, service or Docker calls."""
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class AssetReferences(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.paths = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs = dict(attrs)
|
||||||
|
path = attrs.get('src') if tag == 'script' else attrs.get('href') if tag == 'link' and attrs.get('rel') == 'stylesheet' else None
|
||||||
|
if path and not path.startswith(('http:', 'https:', '//', 'data:')):
|
||||||
|
self.paths.append(path.split('?')[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_speech_image_packages_coordinator_and_all_entrypoint_assets():
|
||||||
|
dockerfile = (ROOT/'Dockerfile').read_text()
|
||||||
|
for source, destination in (
|
||||||
|
('agent/agent.py', '/opt/voice-agent/agent.py'),
|
||||||
|
('agent/hermes_llm.py', '/opt/voice-agent/hermes_llm.py'),
|
||||||
|
('agent/conversation_llm.py', '/opt/voice-agent/conversation_llm.py'),
|
||||||
|
):
|
||||||
|
assert (ROOT/source).is_file()
|
||||||
|
assert re.search(r'^COPY\s+'+re.escape(source)+r'\s+'+re.escape(destination)+r'\s*$', dockerfile, re.M), source
|
||||||
|
references = AssetReferences()
|
||||||
|
references.feed((ROOT/'web/index.html').read_text())
|
||||||
|
assert {'chat.js', 'features.js', 'hope-themes.css'} <= set(references.paths)
|
||||||
|
for path in references.paths:
|
||||||
|
assert (ROOT/'web'/path).is_file(), path
|
||||||
|
assert re.search(r'^COPY\s+web/'+re.escape(path)+r'\s+/var/www/voice/\s*$', dockerfile, re.M), path
|
||||||
|
assert 'chmod -R a+rX /var/www/voice' in dockerfile
|
||||||
|
assert 'chmod 644 /opt/voice-agent/*.py /opt/voice/*.py' in dockerfile
|
||||||
|
assert '.venv-tests' in (ROOT/'.dockerignore').read_text().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_packages_chat_imports_pdf_and_mounted_private_roots():
|
||||||
|
dockerfile = (ROOT/'control.Dockerfile').read_text()
|
||||||
|
assert re.search(r'^COPY\s+control\s+/app/control\s*$', dockerfile, re.M)
|
||||||
|
assert re.search(r'\bpypdf==6\.\d+\.\d+', dockerfile)
|
||||||
|
for module in ('chat_api', 'chat_store', 'chat_runs', 'chat_features', 'chat_exports'):
|
||||||
|
assert (ROOT/'control'/(module+'.py')).is_file()
|
||||||
|
deploy = (ROOT/'scripts/deploy-unraid.sh').read_text()
|
||||||
|
assert '-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3' in deploy
|
||||||
|
assert '-e HOPE_HERMES_DATA_DIR="$ROOT/hermes"' in deploy
|
||||||
|
assert '-v "$ROOT/accounts:/var/lib/hope"' in deploy
|
||||||
|
assert '-v "$ROOT/hermes:$ROOT/hermes"' in deploy
|
||||||
|
app = (ROOT/'control/app.py').read_text()
|
||||||
|
assert "Path(settings.db_path).absolute().parent / 'chat-features'" in app
|
||||||
|
|
||||||
|
|
||||||
|
def test_nginx_preserves_strict_auth_and_allows_declared_advanced_bodies():
|
||||||
|
nginx = (ROOT/'nginx.conf').read_text()
|
||||||
|
assert re.search(r'client_max_body_size\s+16k;', nginx)
|
||||||
|
for route, limit in ((r'/api/attachments', '20m'), (r'~ ^/api/(artifacts|memory)(/|$)', '1m'), (r'/api/tasks', '64k')):
|
||||||
|
block = re.search(r'location\s+'+re.escape(route)+r'\s*\{([^}]+)\}', nginx)
|
||||||
|
assert block is not None, route
|
||||||
|
assert f'client_max_body_size {limit};' in block[1]
|
||||||
|
assert 'proxy_pass http://127.0.0.1:8092;' in block[1]
|
||||||
|
assert re.search(r'location\s+/internal/\s*\{\s*return 404;', nginx)
|
||||||
|
api = re.search(r'location\s+/api/\s*\{([^}]+)\}', nginx)[1]
|
||||||
|
assert 'proxy_buffering off;' in api and 'proxy_read_timeout 700s;' in api
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_qualification_gates_images_and_deployment_without_live_conftest():
|
||||||
|
workflow = (ROOT/'.gitea/workflows/rebuild.yml').read_text()
|
||||||
|
assert workflow.index('Qualify offline') < workflow.index('Build images') < workflow.index('Deploy using')
|
||||||
|
assert '--noconftest -p no:cacheprovider' in workflow
|
||||||
|
for name in ('test_chat_packaging.py', 'test_chat_exports.py', 'test_chat_cross_client.py', 'test_chat_security_review.py', 'test_deploy_rollback.py'):
|
||||||
|
assert 'tests/unit/'+name in workflow
|
||||||
|
assert 'pytest-asyncio -r control/requirements.txt' in workflow
|
||||||
|
assert 'scripts/generate_hope_themes.py --check' in workflow
|
||||||
|
assert '-v "$PWD:/src:ro"' in workflow
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Trusted transport protocol tests; HTTP mocked with no network."""
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
|
||||||
|
def async_test(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return asyncio.run(func(*args, **kwargs))
|
||||||
|
return wrapper
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
from control.accounts import Accounts
|
||||||
|
from control.chat_runs import HermesRuns
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_native_runs_transport_exact_idempotency_and_session(tmp_path):
|
||||||
|
accounts = Accounts(str(tmp_path/'accounts.db'))
|
||||||
|
user = accounts.create('test-user','Test','admin','offline-password')
|
||||||
|
accounts.update(user['id'], status='ready', hermes_url='http://127.0.0.1:20000', hermes_api_key='private-key')
|
||||||
|
seen = []
|
||||||
|
def handler(request):
|
||||||
|
seen.append(request)
|
||||||
|
if request.url.path.endswith('/events'):
|
||||||
|
return httpx.Response(200, text='data: {"event":"message.delta","delta":"Hi"}\n\ndata: {"event":"run.completed","output":"Hi"}\n\n')
|
||||||
|
return httpx.Response(202, json={'run_id':'native-run','status':'started'})
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = HermesRuns(accounts, client)
|
||||||
|
payload = {'input':'Hi','session_id':'stable-session'}
|
||||||
|
assert (await adapter.start(user['id'],'hope-run',payload))['run_id'] == 'native-run'
|
||||||
|
assert seen[0].url.path == '/v1/runs'
|
||||||
|
assert seen[0].headers['authorization'] == 'Bearer private-key'
|
||||||
|
assert seen[0].headers['idempotency-key'] == 'hope-run'
|
||||||
|
assert json.loads(seen[0].content) == payload
|
||||||
|
events = [e async for e in adapter.events(user['id'],'native-run')]
|
||||||
|
assert [e['event'] for e in events] == ['message.delta','run.completed']
|
||||||
|
await adapter.stop(user['id'],'native-run')
|
||||||
|
assert seen[-1].url.path == '/v1/runs/native-run/stop'
|
||||||
|
await adapter.approve(user['id'],'native-run','approval-1','approve')
|
||||||
|
assert json.loads(seen[-1].content) == {'request_id':'approval-1','choice':'once'}
|
||||||
|
accounts.close()
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_transport_rejects_deleted_account_and_bad_trusted_url(tmp_path):
|
||||||
|
accounts = Accounts(str(tmp_path/'accounts.db'))
|
||||||
|
user = accounts.create('test-user','Test','admin','offline-password')
|
||||||
|
accounts.update(user['id'], status='ready', hermes_url='http://evil:password@localhost:2', hermes_api_key='private-key')
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda request: pytest.fail('Network requested'))) as client:
|
||||||
|
adapter = HermesRuns(accounts, client)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await adapter.start(user['id'],'rid',{'input':'Hi'})
|
||||||
|
accounts.close()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Offline storage-limit stop intent regression."""
|
||||||
|
import asyncio
|
||||||
|
from control.accounts import Accounts, token_hash
|
||||||
|
from control.chat_store import ChatStore
|
||||||
|
from control.chat_runs import ChatRuns
|
||||||
|
|
||||||
|
def test_event_capacity_failure_persists_stop_and_reserved_terminal(tmp_path):
|
||||||
|
async def scenario():
|
||||||
|
accounts = Accounts(str(tmp_path/'limit.db'))
|
||||||
|
owner = accounts.create('test','Test','admin','offline-test-password')
|
||||||
|
store = ChatStore(accounts)
|
||||||
|
cid = store.create_conversation(owner['id'])['id']
|
||||||
|
rid = store.send(owner['id'],cid,'Hi','key')['run']['id']
|
||||||
|
store.update_run(owner['id'],rid,'running')
|
||||||
|
runs = ChatRuns(accounts,store,adapter=object())
|
||||||
|
runs.metadata(owner['id'],rid,{'conversation_id':cid,'payload':{},'upstream_id':'upstream','assistant_id':None})
|
||||||
|
original = store.append_event
|
||||||
|
def limited(owner,rid,event,data):
|
||||||
|
if event == 'progress':
|
||||||
|
raise ValueError('storage limit')
|
||||||
|
return original(owner,rid,event,data)
|
||||||
|
store.append_event = limited
|
||||||
|
try:
|
||||||
|
await runs.emit(owner['id'],rid,'progress',{'status':'working'})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
metadata = runs.metadata(owner['id'],rid)
|
||||||
|
assert metadata['stop_requested'] and metadata['storage_failed']
|
||||||
|
assert await runs.consume_status(owner['id'],rid,metadata,{'status':'cancelled'})
|
||||||
|
assert store.get_run(owner['id'],rid)['run']['status'] == 'failed'
|
||||||
|
assert store.events(owner['id'],rid)[-1]['event'] == 'done'
|
||||||
|
accounts.close()
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_key_before_admission_never_starts_and_is_owner_scoped(tmp_path):
|
||||||
|
async def scenario():
|
||||||
|
accounts = Accounts(str(tmp_path/'cancel.db'))
|
||||||
|
user = accounts.create('test','Test','admin','offline-test-password')
|
||||||
|
other = accounts.create('other','Other','user','offline-test-password')
|
||||||
|
token, _ = accounts.issue_session(user['id'],3600,user['password_hash'])
|
||||||
|
store = ChatStore(accounts)
|
||||||
|
cid = store.create_conversation(user['id'])['id']
|
||||||
|
runs = ChatRuns(accounts,store,adapter=object())
|
||||||
|
assert await runs.cancel_operation(user['id'],cid,'uncertain-key') == {'ok':True,'run':None}
|
||||||
|
operation = await runs.send(user['id'],cid,token,content='Hi',idempotency_key='uncertain-key')
|
||||||
|
assert operation['run']['status'] == 'cancelled'
|
||||||
|
assert runs.tasks == {}
|
||||||
|
assert (await runs.send(user['id'],cid,token,content='Hi',idempotency_key='uncertain-key'))['run']['id'] == operation['run']['id']
|
||||||
|
try:
|
||||||
|
await runs.cancel_operation(other['id'],cid,'uncertain-key')
|
||||||
|
assert False, 'Foreign owner cancellation was accepted'
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
accounts.close()
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Offline cancellation/recovery regressions; no HTTP, NAS, secrets or model calls."""
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def offline_async(function):
|
||||||
|
@functools.wraps(function)
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
return asyncio.run(function(*args, **kwargs))
|
||||||
|
return run
|
||||||
|
|
||||||
|
from control.accounts import Accounts, token_hash
|
||||||
|
from control.chat_runs import ChatRuns
|
||||||
|
from control.chat_store import ChatConflict, ChatStore
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedAdapter:
|
||||||
|
available = True
|
||||||
|
approvals = False
|
||||||
|
|
||||||
|
def __init__(self, *, block_start=False, fail_stop=False):
|
||||||
|
self.block_start = block_start
|
||||||
|
self.fail_stop = fail_stop
|
||||||
|
self.start_entered = asyncio.Event()
|
||||||
|
self.start_release = asyncio.Event()
|
||||||
|
self.stop_entered = asyncio.Event()
|
||||||
|
self.stream_entered = asyncio.Event()
|
||||||
|
self.stream_release = asyncio.Event()
|
||||||
|
self.starts = []
|
||||||
|
self.stops = []
|
||||||
|
|
||||||
|
async def start(self, owner, rid, payload):
|
||||||
|
self.starts.append(rid)
|
||||||
|
self.start_entered.set()
|
||||||
|
if self.block_start:
|
||||||
|
await self.start_release.wait()
|
||||||
|
return {'run_id': 'offline-upstream'}
|
||||||
|
|
||||||
|
async def stop(self, owner, upstream):
|
||||||
|
self.stops.append(upstream)
|
||||||
|
self.stop_entered.set()
|
||||||
|
if self.fail_stop:
|
||||||
|
raise RuntimeError('Offline simulated unavailable stop')
|
||||||
|
|
||||||
|
async def status(self, owner, upstream):
|
||||||
|
return {'status': 'cancelled' if self.stops and not self.fail_stop else 'running'}
|
||||||
|
|
||||||
|
async def events(self, owner, upstream):
|
||||||
|
self.stream_entered.set()
|
||||||
|
await self.stream_release.wait()
|
||||||
|
yield {'event': 'run.cancelled'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def execution(tmp_path):
|
||||||
|
accounts = Accounts(str(tmp_path / 'offline.sqlite'))
|
||||||
|
user = accounts.create('review', 'Offline Review', 'user', 'offline-review-password')
|
||||||
|
accounts.update(user['id'], status='ready')
|
||||||
|
token, _ = accounts.issue_session(user['id'], 3600, user['password_hash'])
|
||||||
|
store = ChatStore(accounts)
|
||||||
|
cid = store.create_conversation(user['id'])['id']
|
||||||
|
operation = store.send(user['id'], cid, 'Offline request', 'offline-key')
|
||||||
|
yield accounts, store, user['id'], token, cid, operation['run']['id']
|
||||||
|
accounts.close()
|
||||||
|
|
||||||
|
|
||||||
|
def coordinator(execution, adapter, *, upstream=None):
|
||||||
|
accounts, store, owner, token, cid, rid = execution
|
||||||
|
runs = ChatRuns(accounts, store, adapter=adapter)
|
||||||
|
runs.metadata(owner, rid, {'conversation_id': cid, 'payload': {'input': 'Offline request'},
|
||||||
|
'session_hash': token_hash(token), 'room': None,
|
||||||
|
'upstream_id': upstream, 'assistant_id': None})
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_cancel_during_blocked_admission_stops_learned_upstream(execution):
|
||||||
|
_, store, owner, _, _, rid = execution
|
||||||
|
adapter = BlockedAdapter(block_start=True)
|
||||||
|
runs = coordinator(execution, adapter)
|
||||||
|
runs.schedule(owner, rid)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(adapter.start_entered.wait(), 2)
|
||||||
|
await runs.cancel(owner, rid)
|
||||||
|
assert not runs.tasks[rid].done(), 'Do not abort admission before learning its upstream ID'
|
||||||
|
adapter.start_release.set()
|
||||||
|
await asyncio.wait_for(runs.tasks[rid], 2)
|
||||||
|
assert adapter.starts == [rid]
|
||||||
|
assert adapter.stops, 'Cancellation must stop the execution admitted during cancellation'
|
||||||
|
assert store.get_run(owner, rid)['run']['status'] == 'cancelled'
|
||||||
|
finally:
|
||||||
|
adapter.start_release.set()
|
||||||
|
await runs.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_stop_failure_keeps_execution_slot_and_durable_intent(execution):
|
||||||
|
_, store, owner, _, _, rid = execution
|
||||||
|
adapter = BlockedAdapter(fail_stop=True)
|
||||||
|
runs = coordinator(execution, adapter, upstream='offline-upstream')
|
||||||
|
store.update_run(owner, rid, 'running')
|
||||||
|
runs.schedule(owner, rid)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(adapter.stream_entered.wait(), 2)
|
||||||
|
await runs.cancel(owner, rid)
|
||||||
|
assert runs.metadata(owner, rid).get('stop_requested') is True
|
||||||
|
assert store.get_run(owner, rid)['run']['status'] not in runs.store.TERMINAL
|
||||||
|
assert runs.locks[owner].locked(), 'Do not admit new account work while old work is unconfirmed'
|
||||||
|
assert not runs.tasks[rid].done()
|
||||||
|
finally:
|
||||||
|
await runs.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_revoked_session_recovery_stops_remote_before_terminalizing(execution):
|
||||||
|
accounts, store, owner, token, _, rid = execution
|
||||||
|
adapter = BlockedAdapter()
|
||||||
|
runs = coordinator(execution, adapter, upstream='offline-upstream')
|
||||||
|
store.update_run(owner, rid, 'running')
|
||||||
|
accounts.revoke(token)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(runs.execute(owner, rid), 2)
|
||||||
|
assert adapter.starts == [], 'Recovery must never replay a known admission'
|
||||||
|
assert adapter.stops, 'A revoked session does not mean its remote execution stopped'
|
||||||
|
assert store.get_run(owner, rid)['run']['status'] == 'cancelled'
|
||||||
|
finally:
|
||||||
|
await runs.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_stream_metadata_cannot_erase_cancellation_intent(execution):
|
||||||
|
_, _, owner, _, _, rid = execution
|
||||||
|
runs = coordinator(execution, BlockedAdapter(), upstream='offline-upstream')
|
||||||
|
stale = runs.metadata(owner, rid)
|
||||||
|
cancel = runs.metadata(owner, rid)
|
||||||
|
cancel['stop_requested'] = True
|
||||||
|
runs.metadata(owner, rid, cancel)
|
||||||
|
stale['content'] = 'A delta racing with cancellation'
|
||||||
|
runs.metadata(owner, rid, stale)
|
||||||
|
assert runs.metadata(owner, rid).get('stop_requested') is True
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_restart_after_cancel_during_admission_reconciles_remote(execution):
|
||||||
|
_, store, owner, _, _, rid = execution
|
||||||
|
adapter = BlockedAdapter(block_start=True, fail_stop=True)
|
||||||
|
original = coordinator(execution, adapter)
|
||||||
|
original.schedule(owner, rid)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(adapter.start_entered.wait(), 2)
|
||||||
|
await original.cancel(owner, rid)
|
||||||
|
adapter.start_release.set()
|
||||||
|
await asyncio.wait_for(adapter.stop_entered.wait(), 2)
|
||||||
|
finally:
|
||||||
|
adapter.start_release.set()
|
||||||
|
await original.stop()
|
||||||
|
# The remote run exists, its stop failed, and the local projection may already
|
||||||
|
# be terminal. A restart must still reconcile that durable remote stop intent.
|
||||||
|
recovered_adapter = BlockedAdapter()
|
||||||
|
recovered = ChatRuns(execution[0], store, adapter=recovered_adapter)
|
||||||
|
try:
|
||||||
|
await recovered.start()
|
||||||
|
await asyncio.wait_for(recovered_adapter.stop_entered.wait(), 2)
|
||||||
|
if rid in recovered.tasks:
|
||||||
|
await asyncio.wait_for(recovered.tasks[rid], 2)
|
||||||
|
assert recovered_adapter.starts == []
|
||||||
|
assert store.get_run(owner, rid)['run']['status'] == 'cancelled'
|
||||||
|
finally:
|
||||||
|
await recovered.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@offline_async
|
||||||
|
async def test_delete_cannot_discard_unconfirmed_remote_cancellation(execution):
|
||||||
|
_, store, owner, _, cid, rid = execution
|
||||||
|
adapter = BlockedAdapter(fail_stop=True)
|
||||||
|
runs = coordinator(execution, adapter, upstream='offline-upstream')
|
||||||
|
store.update_run(owner, rid, 'running')
|
||||||
|
runs.schedule(owner, rid)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(adapter.stream_entered.wait(), 2)
|
||||||
|
with pytest.raises(ChatConflict):
|
||||||
|
await runs.cancel_conversation(owner, cid)
|
||||||
|
store.delete_conversation(owner, cid)
|
||||||
|
assert store.get_run(owner, rid)['run']['status'] not in store.TERMINAL
|
||||||
|
assert runs.metadata(owner, rid).get('stop_requested') is True
|
||||||
|
finally:
|
||||||
|
await runs.stop()
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Offline chat persistence tests; never call deployed services."""
|
||||||
|
import sqlite3
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from control.accounts import Accounts
|
||||||
|
from control.chat_store import ChatConflict, ChatStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup(tmp_path):
|
||||||
|
accounts = Accounts(str(tmp_path / 'accounts.db'))
|
||||||
|
a = accounts.create('alice', 'Alice', 'admin', 'correct-password-alice')['id']
|
||||||
|
b = accounts.create('bob', 'Bob', 'user', 'correct-password-bob')['id']
|
||||||
|
store = ChatStore(accounts)
|
||||||
|
yield accounts, store, a, b
|
||||||
|
accounts.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_ownership_revisions_and_tombstones(setup):
|
||||||
|
accounts, s, a, b = setup
|
||||||
|
assert s.db is accounts.db and s.lock is accounts.lock
|
||||||
|
w = s.create_workspace(a, 'Work', 'Be concise')
|
||||||
|
c = s.create_conversation(a, workspace_id=w['id'])
|
||||||
|
assert 'hermes_session_id' not in c
|
||||||
|
with pytest.raises(KeyError): s.get_conversation(b, c['id'])
|
||||||
|
with pytest.raises(KeyError): s.create_conversation(b, workspace_id=w['id'])
|
||||||
|
with pytest.raises(KeyError): s.delete_workspace(b, w['id'])
|
||||||
|
c = s.update_conversation(a, c['id'], c['revision'], pinned=True, title='Revised')
|
||||||
|
with pytest.raises(ChatConflict): s.update_conversation(a, c['id'], 1, title='Stale')
|
||||||
|
w = s.update_workspace(a, w['id'], 1, name='Renamed')
|
||||||
|
with pytest.raises(ChatConflict): s.update_workspace(a, w['id'], 1, name='Stale')
|
||||||
|
s.delete_workspace(a, w['id'])
|
||||||
|
assert s.get_conversation(a, c['id'])['conversation']['workspace_id'] is None
|
||||||
|
s.delete_conversation(a, c['id'])
|
||||||
|
changes = s.sync(a)['changes']
|
||||||
|
assert any(x['kind']=='workspace' and x['deleted'] for x in changes)
|
||||||
|
assert changes[-1]['deleted'] and changes[-1]['id']==c['id']
|
||||||
|
assert s.sync(b)['changes']==[]
|
||||||
|
assert s.sync(a, s.sync(a)['cursor'])['changes']==[]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preferences_and_validation(setup):
|
||||||
|
_, s, a, b = setup
|
||||||
|
assert s.get_preferences(a)['revision']==0
|
||||||
|
assert s.set_preferences(a, 0, 'ocean', 'dark')['revision']==1
|
||||||
|
with pytest.raises(ChatConflict): s.set_preferences(a, 0, 'opal', 'light')
|
||||||
|
assert s.get_preferences(b)['palette']=='opal'
|
||||||
|
invalid = [lambda:s.create_conversation(a, title=''), lambda:s.create_conversation(a, temporary=1),
|
||||||
|
lambda:s.create_conversation(a, temporary=True), lambda:s.create_workspace(a, 'x', instructions=7),
|
||||||
|
lambda:s.list_conversations(a, limit=True), lambda:s.list_conversations(a, cursor='-1'),
|
||||||
|
lambda:s.set_preferences(a, True, 'opal', 'system'), lambda:s.set_preferences(a, 1, [], 'dark')]
|
||||||
|
for call in invalid:
|
||||||
|
with pytest.raises(ValueError): call()
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_durable_idempotency_concurrent_claim_and_fk(setup):
|
||||||
|
accounts, s, a, b = setup
|
||||||
|
cid = s.create_conversation(a)['id']
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
replies = list(pool.map(lambda _:s.send(a,cid,'hello','unique-key'), range(16)))
|
||||||
|
rid = replies[0]['run']['id']
|
||||||
|
assert len({x['run']['id'] for x in replies})==1
|
||||||
|
assert len(s.get_conversation(a,cid)['messages'])==1
|
||||||
|
with pytest.raises(ChatConflict): s.send(a,cid,'different','unique-key')
|
||||||
|
with pytest.raises(ChatConflict): s.send(a,cid,'hello','unique-key',mode='research')
|
||||||
|
with pytest.raises(KeyError): s.send(b,cid,'hello','unique-key')
|
||||||
|
with pytest.raises(KeyError): s.send(a,cid,'hello','attachments',['fake'])
|
||||||
|
s2 = ChatStore(accounts)
|
||||||
|
assert s2.send(a,cid,'hello','unique-key')['run']['id']==rid
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
assert sum(pool.map(lambda _:s.claim_run(a,rid),range(8)))==1
|
||||||
|
assert accounts.db.execute('PRAGMA foreign_keys').fetchone()[0]==1
|
||||||
|
other = s.create_conversation(b)['id']
|
||||||
|
with pytest.raises(sqlite3.IntegrityError), accounts.db:
|
||||||
|
accounts.db.execute('UPDATE chat_conversations SET workspace_id=? WHERE id=?',('missing',other))
|
||||||
|
with pytest.raises(ChatConflict): s.delete_conversation(a,cid)
|
||||||
|
s.update_run(a,rid,'cancelled')
|
||||||
|
s.delete_conversation(a,cid)
|
||||||
|
assert accounts.db.execute('SELECT count(*) FROM chat_runs WHERE id=?',(rid,)).fetchone()[0]==0
|
||||||
|
assert accounts.db.execute('PRAGMA foreign_key_check').fetchall()==[]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_messages_events_and_terminal_states(setup):
|
||||||
|
_, s, a, b = setup
|
||||||
|
cid = s.create_conversation(a)['id']
|
||||||
|
rid = s.send(a,cid,'hello','one')['run']['id']
|
||||||
|
s.update_run(a,rid,'running')
|
||||||
|
m = s.add_message(a,cid,'assistant','','pending',rid)
|
||||||
|
s.update_message(a,m['id'],content='part',status='streaming')
|
||||||
|
e1 = s.append_event(a,rid,'message',{'content':'part'})
|
||||||
|
e2 = s.append_event(a,rid,'progress',{'label':'Working'})
|
||||||
|
assert [e1['sequence'],e2['sequence']]==[1,2]
|
||||||
|
assert s.events(a,rid,after=1)==[e2]
|
||||||
|
with pytest.raises(KeyError): s.events(b,rid)
|
||||||
|
with pytest.raises(KeyError): s.update_message(b,m['id'],content='steal')
|
||||||
|
s.update_run(a,rid,'cancelled')
|
||||||
|
with pytest.raises(ChatConflict): s.update_run(a,rid,'running')
|
||||||
|
with pytest.raises(ChatConflict): s.update_run(a,rid,'completed')
|
||||||
|
with pytest.raises(ChatConflict): s.update_message(a,m['id'],content='late',status='completed')
|
||||||
|
s.update_message(a,m['id'],status='cancelled')
|
||||||
|
with pytest.raises(ChatConflict): s.update_message(a,m['id'],content='late')
|
||||||
|
assert s.get_run(a,rid)['run']['status']=='cancelled'
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_copies_history_without_execution_or_session(setup):
|
||||||
|
_, s, a, b = setup
|
||||||
|
cid = s.create_conversation(a)['id']
|
||||||
|
u = s.add_message(a,cid,'user','original')
|
||||||
|
reply = s.add_message(a,cid,'assistant','answer')
|
||||||
|
s.add_message(a,cid,'user','later')
|
||||||
|
s.set_hermes_session(a,cid,'trusted-session')
|
||||||
|
branch = s.branch(a,cid,reply['id'])
|
||||||
|
assert s.history(a,branch['id'])==[{'role':'user','content':'original'},{'role':'assistant','content':'answer'}]
|
||||||
|
assert s.get_hermes_session(a,branch['id']) is None
|
||||||
|
assert s.get_conversation(a,branch['id'])['runs']==[]
|
||||||
|
assert all(m['run_id'] is None for m in s.get_conversation(a,branch['id'])['messages'])
|
||||||
|
edited = s.branch(a,cid,u['id'],content='replacement')
|
||||||
|
assert s.history(a,edited['id'])==[]
|
||||||
|
assert len(s.history(a,cid))==3
|
||||||
|
with pytest.raises(KeyError): s.branch(b,cid,u['id'])
|
||||||
|
with pytest.raises(ValueError): s.branch(a,cid,reply['id'],content='bad edit')
|
||||||
|
s.set_hermes_session(a,branch['id'],'branch-session')
|
||||||
|
with pytest.raises(ChatConflict): s.set_session(a,branch['id'],'branch-session',seeded=False)
|
||||||
|
with pytest.raises(ChatConflict): s.set_hermes_session(a,branch['id'],'different')
|
||||||
|
assert 'trusted-session' not in str(s.get_conversation(a,cid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagination_search_and_revocation(setup):
|
||||||
|
accounts,s,a,b = setup
|
||||||
|
c1 = s.create_conversation(a,title='Needle')['id']
|
||||||
|
c2 = s.create_conversation(a,title='Other')['id']
|
||||||
|
s.create_conversation(b,title='Needle')
|
||||||
|
s.add_message(a,c2,'user','searchable phrase')
|
||||||
|
assert len(s.list_conversations(a,q='needle')['conversations'])==1
|
||||||
|
assert s.list_conversations(a,q='searchable')['conversations'][0]['id']==c2
|
||||||
|
first = s.list_conversations(a,limit=1)
|
||||||
|
second = s.list_conversations(a,cursor=first['next_cursor'],limit=1)
|
||||||
|
assert first['conversations'][0]['id']!=second['conversations'][0]['id']
|
||||||
|
assert second['next_cursor'] is None
|
||||||
|
s.add_message(a,c1,'user','1'); s.add_message(a,c1,'assistant','2')
|
||||||
|
page = s.get_conversation(a,c1,limit=1)
|
||||||
|
assert page['messages'][0]['content']=='2'
|
||||||
|
assert s.get_conversation(a,c1,cursor=page['next_cursor'],limit=1)['messages'][0]['content']=='1'
|
||||||
|
accounts.begin_delete(b)
|
||||||
|
with pytest.raises(KeyError): s.get_preferences(b)
|
||||||
|
accounts.finish_delete(b)
|
||||||
|
assert accounts.db.execute('PRAGMA foreign_key_check').fetchall()==[]
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_reopen_preserves_admission_and_events(tmp_path):
|
||||||
|
path=str(tmp_path/'persist.db')
|
||||||
|
accounts=Accounts(path)
|
||||||
|
owner=accounts.create('alice','Alice','admin','correct-password-alice')['id']
|
||||||
|
store=ChatStore(accounts)
|
||||||
|
cid=store.create_conversation(owner)['id']
|
||||||
|
admitted=store.send(owner,cid,'durable','key')
|
||||||
|
event=store.append_event(owner,admitted['run']['id'],'run',{'status':'queued'})
|
||||||
|
accounts.close()
|
||||||
|
accounts=Accounts(path)
|
||||||
|
store=ChatStore(accounts)
|
||||||
|
assert store.send(owner,cid,'durable','key')==admitted
|
||||||
|
assert store.events(owner,admitted['run']['id'])==[event]
|
||||||
|
accounts.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachments_owned_persisted_and_copied(setup):
|
||||||
|
accounts,s,a,b=setup
|
||||||
|
with accounts.db:
|
||||||
|
accounts.db.execute('CREATE TABLE hope_attachments(id TEXT PRIMARY KEY, owner TEXT, deleted_at REAL)')
|
||||||
|
accounts.db.execute('INSERT INTO hope_attachments VALUES(?,?,NULL)',('owned',a))
|
||||||
|
accounts.db.execute('INSERT INTO hope_attachments VALUES(?,?,NULL)',('foreign',b))
|
||||||
|
cid=s.create_conversation(a)['id']
|
||||||
|
sent=s.send(a,cid,'file','key',['owned'])
|
||||||
|
assert sent['message']['attachments']==['owned']
|
||||||
|
assert s.send(a,cid,'file','key',['owned'])['message']['attachments']==['owned']
|
||||||
|
with pytest.raises(KeyError): s.send(a,cid,'file','other',['foreign'])
|
||||||
|
with pytest.raises(ValueError): s.send(a,cid,'file','dup',['owned','owned'])
|
||||||
|
branched=s.branch(a,cid,sent['message']['id'])
|
||||||
|
assert s.get_conversation(a,branched['id'])['messages'][0]['attachments']==['owned']
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_remote_guard_and_final_sync(setup):
|
||||||
|
import json
|
||||||
|
accounts,s,a,_=setup
|
||||||
|
cid=s.create_conversation(a)['id']
|
||||||
|
sent=s.send(a,cid,'hello','key')
|
||||||
|
rid=sent['run']['id']
|
||||||
|
s.update_run(a,rid,'running')
|
||||||
|
cursor=s.sync(a)['cursor']
|
||||||
|
m=s.add_message(a,cid,'assistant','done',run_id=rid)
|
||||||
|
assert s.sync(a,cursor)['changes'][-1]['id']==cid
|
||||||
|
s.update_run(a,rid,'completed')
|
||||||
|
with accounts.db:
|
||||||
|
accounts.db.execute('CREATE TABLE chat_transport(run_id TEXT PRIMARY KEY,owner TEXT,conversation_id TEXT,metadata TEXT)')
|
||||||
|
accounts.db.execute('INSERT INTO chat_transport VALUES(?,?,?,?)',(rid,a,cid,json.dumps({'upstream_id':'remote'})))
|
||||||
|
with pytest.raises(ChatConflict): s.delete_conversation(a,cid)
|
||||||
|
with accounts.db:
|
||||||
|
accounts.db.execute('UPDATE chat_transport SET metadata=?',(json.dumps({'upstream_id':'remote','remote_terminal':True}),))
|
||||||
|
s.delete_conversation(a,cid)
|
||||||
|
assert accounts.db.execute('SELECT count(*) FROM chat_transport').fetchone()[0]==0
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Offline Chromium tests of real Hope assets; no NAS or live provider."""
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(page, replay=False):
|
||||||
|
calls, streams = [], []
|
||||||
|
conversation = dict(id='c1', title='A saved thought', workspace_id=None, pinned=False, revision=1, archived=False)
|
||||||
|
original = dict(id='m1', role='user', content='Original question', status='completed')
|
||||||
|
conversations, messages = {'c1': conversation}, {'c1': [original]}
|
||||||
|
preferences = dict(palette='opal', appearance='light', revision=1)
|
||||||
|
def route(r):
|
||||||
|
path, method = urlparse(r.request.url).path, r.request.method
|
||||||
|
body = r.request.post_data_json if r.request.post_data else {}
|
||||||
|
calls.append((method, path, body))
|
||||||
|
if path == '/api/auth/me': data = {'user': dict(id='alice', username='alice', role='user', status='ready')}
|
||||||
|
elif path == '/voices': data = {'voices': []}
|
||||||
|
elif path == '/api/research': data = {'available':True,'mode':'hermes_tools','reason':''}
|
||||||
|
elif path.endswith('/export'):
|
||||||
|
r.fulfill(body='private export',content_type='text/plain'); return
|
||||||
|
elif path == '/api/capabilities': data = {'version':1,'features':{}}
|
||||||
|
elif path == '/api/preferences':
|
||||||
|
if method == 'PUT': preferences.update(body); preferences['revision'] += 1
|
||||||
|
data = {'preferences': preferences}
|
||||||
|
elif path == '/api/workspaces': data = {'workspaces': [dict(id='w1',name='Writing',revision=1,instructions='Keep it concise')]}
|
||||||
|
elif path == '/api/sync': data = {'changes':[{'kind':'conversation','id':'c1','data':conversations['c1']},{'kind':'preferences','data':preferences}], 'cursor':1}
|
||||||
|
elif path == '/api/conversations':
|
||||||
|
if method == 'POST':
|
||||||
|
conversations['c2'] = dict(conversation, id='c2', title=body['title']); messages['c2'] = []
|
||||||
|
data = {'conversation': conversations['c2']}
|
||||||
|
else: data = {'conversations': [c for c in conversations.values() if c['archived'] == (parse_qs(urlparse(r.request.url).query).get('archived',['false'])[0] == 'true')], 'next_cursor': None}
|
||||||
|
elif path.endswith('/events'):
|
||||||
|
streams.append(r.request.url)
|
||||||
|
message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n| Name | Value |\\n| --- | --- |\\n| <img src=x> | **safe** |\\n**Safe** and `clear`.\\n<script>window.injected=true</script>\\n[bad](javascript:alert(1))","status":"completed"}}\n\n'
|
||||||
|
done = 'id: 2\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n'
|
||||||
|
if not replay or len(streams) > 1:
|
||||||
|
for c in conversations.values(): c['revision'] += 1
|
||||||
|
r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return
|
||||||
|
elif path.endswith('/messages'):
|
||||||
|
cid = path.split('/')[3]; message = dict(id='u2', role='user', content=body['content'], status='completed'); messages[cid].append(message)
|
||||||
|
data = {'message':message,'run':{'id':'r1','status':'running'}}
|
||||||
|
elif path.endswith('/branch') and method == 'POST':
|
||||||
|
conversations['branch'] = dict(conversation, id='branch', title='New branch'); messages['branch'] = []
|
||||||
|
data = {'conversation':conversations['branch']}
|
||||||
|
elif path.startswith('/api/conversations/'):
|
||||||
|
cid = path.split('/')[3]
|
||||||
|
if method == 'DELETE': del conversations[cid]; data = {'ok':True}
|
||||||
|
elif method == 'PATCH':
|
||||||
|
if body['revision'] != conversations[cid]['revision']:
|
||||||
|
r.fulfill(status=409,json={'error':'Stale revision'}); return
|
||||||
|
conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]}
|
||||||
|
else: data = {'conversation':conversations[cid],'messages':messages[cid],'runs':[],'next_cursor':None}
|
||||||
|
elif path == '/api/auth/logout': data = {'ok':True}
|
||||||
|
else:
|
||||||
|
asset = ROOT / 'web' / (path.lstrip('/') or 'index.html')
|
||||||
|
if asset.is_file():
|
||||||
|
r.fulfill(body=asset.read_bytes(), content_type={'.html':'text/html','.css':'text/css','.js':'application/javascript','.svg':'image/svg+xml'}.get(asset.suffix,'application/octet-stream')); return
|
||||||
|
r.fulfill(status=404,json={'error':'Unavailable'}); return
|
||||||
|
r.fulfill(json=data)
|
||||||
|
page.route('https://hope.test/**', route)
|
||||||
|
return calls, streams, conversations, messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_crud_replay_safe_markdown_and_preserved_branches():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width':1280,'height':900})
|
||||||
|
errors=[]; page.on('pageerror',lambda error: errors.append(str(error)))
|
||||||
|
calls, streams, conversations, messages = fixture(page, replay=True)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
page.get_by_role('button',name='A saved thought',exact=True).click()
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin')
|
||||||
|
page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click()
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled(timeout=10000)
|
||||||
|
assert len(streams) == 2 and streams[-1].endswith('after=1')
|
||||||
|
assert page.locator('[data-message-id="a1"]').count() == 1
|
||||||
|
assert page.locator('#chatTimeline table').count() == 1
|
||||||
|
assert page.locator('#chatTimeline img').count() == 0
|
||||||
|
assert page.locator('#chatTimeline script').count() == 0
|
||||||
|
assert page.locator('#chatTimeline a[href^="javascript:"]').count() == 0
|
||||||
|
assert page.evaluate('window.injected') is None
|
||||||
|
assert not any(path == '/token' for _,path,_ in calls)
|
||||||
|
page.get_by_role('button',name='Edit in new branch').first.click()
|
||||||
|
page.locator('#chatActionValue').fill('Revised question'); page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('New branch')
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Revised question')
|
||||||
|
assert messages['c1'][0]['content'] == 'Original question'
|
||||||
|
page.locator('#openPreferences').click(); expect(page.locator('#preferencesMessage')).to_have_text(''); page.locator('#paletteSelect').select_option('ocean')
|
||||||
|
page.get_by_role('button',name='Save appearance').click(); expect(page.locator('html')).to_have_attribute('data-palette','ocean')
|
||||||
|
page.locator('#settingsClose').click(); page.screenshot(path='/tmp/hope-chat-desktop.png')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('New conversation'); assert 'branch' not in conversations
|
||||||
|
assert errors == []
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_drawer_keyboard_voice_and_logout():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width':390,'height':844}); fixture(page)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
page.locator('#drawerOpen').click(); expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded','true')
|
||||||
|
page.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused()
|
||||||
|
page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click()
|
||||||
|
expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
|
page.locator('#logoutBtn').click()
|
||||||
|
expect(page.locator('#loginGate')).to_be_visible()
|
||||||
|
assert 'Original question' not in page.locator('#chatTimeline').inner_text()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_move_sync_conflict_and_mobile_actions():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width':390,'height':844})
|
||||||
|
calls, streams, conversations, messages = fixture(page)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
expect(page.locator('#conversationMenu')).to_be_hidden()
|
||||||
|
page.locator('#drawerOpen').click(); page.get_by_role('button',name='A saved thought',exact=True).click()
|
||||||
|
page.locator('#conversationMenu').click(); expect(page.locator('#moveConversation')).to_be_visible()
|
||||||
|
page.locator('#moveConversation').click(); page.locator('#chatWorkspaceSelect').select_option('w1'); page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatActionDialog')).not_to_be_visible()
|
||||||
|
assert conversations['c1']['workspace_id'] == 'w1'
|
||||||
|
conversations['c1'].update(title='Changed elsewhere', revision=4)
|
||||||
|
page.evaluate("document.dispatchEvent(new Event('visibilitychange'))")
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('Changed elsewhere')
|
||||||
|
page.locator('#conversationMenu').click(); page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('New conversation')
|
||||||
|
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
|
||||||
|
page.get_by_role('button',name='Changed elsewhere',exact=True).click()
|
||||||
|
page.locator('#conversationMenu').click(); expect(page.locator('#archiveConversation')).to_have_text('Unarchive')
|
||||||
|
page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('New conversation')
|
||||||
|
assert conversations['c1']['archived'] is False
|
||||||
|
# A stale optimistic write refreshes rather than overwriting another client's title.
|
||||||
|
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
|
||||||
|
page.get_by_role('button',name='Changed elsewhere',exact=True).click()
|
||||||
|
def conflict(route):
|
||||||
|
if route.request.method == 'PATCH':
|
||||||
|
conversations['c1'].update(title='Newest revision',revision=9)
|
||||||
|
route.fulfill(status=409,json={'error':'Stale revision'})
|
||||||
|
else: route.fallback()
|
||||||
|
page.route('https://hope.test/api/conversations/c1', conflict)
|
||||||
|
page.locator('#conversationMenu').click(); page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
page.locator('#pinConversation').click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('Newest revision')
|
||||||
|
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_history_refresh_keyboard_focus_and_large_text():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width':1280,'height':900}, reduced_motion='reduce')
|
||||||
|
calls, streams, conversations, messages = fixture(page)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
page.get_by_role('button',name='A saved thought',exact=True).click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
|
||||||
|
page.locator('#primaryInput').fill('Keep my draft')
|
||||||
|
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true,conversation_id:'c1'}}))")
|
||||||
|
messages['c1'].append(dict(id='voice1',role='assistant',content='Saved voice reply with [source](https://example.com/report)',status='completed'))
|
||||||
|
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false,conversation_id:'c1'}}))")
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Saved voice reply')
|
||||||
|
expect(page.locator('#primaryInput')).to_have_value('Keep my draft')
|
||||||
|
expect(page.locator('#chatTimeline a')).to_have_attribute('href','https://example.com/report')
|
||||||
|
expect(page.locator('#chatNotice')).to_contain_text('Voice conversation saved')
|
||||||
|
page.screenshot(path='/tmp/hope-chat-opal-desktop.png')
|
||||||
|
page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused()
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
page.locator('#openPreferences').click(); expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
|
page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused()
|
||||||
|
# Native controls remain usable at large text and narrow effective viewport.
|
||||||
|
page.set_viewport_size({'width':640,'height':900})
|
||||||
|
page.add_style_tag(content='html {font-size: 200% !important;}')
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
|
page.locator('#drawerOpen').click(); page.keyboard.press('Escape')
|
||||||
|
expect(page.locator('#drawerOpen')).to_be_focused()
|
||||||
|
assert page.evaluate("getComputedStyle(document.getElementById('drawerOpen')).outlineStyle") != 'none'
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualified_research_and_private_export():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(); calls, streams, conversations, messages = fixture(page)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
expect(page.locator('#researchMode')).to_be_visible()
|
||||||
|
page.locator('#researchMode').select_option('research')
|
||||||
|
page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click()
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
||||||
|
assert next(body for method,path,body in calls if method == 'POST' and path.endswith('/messages'))['mode'] == 'research'
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
with page.expect_download() as info: page.locator('#exportMarkdown').click()
|
||||||
|
assert info.value.suggested_filename.endswith('.md')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
with page.expect_download() as info: page.locator('#exportJSON').click()
|
||||||
|
assert info.value.suggested_filename.endswith('.json')
|
||||||
|
assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export'))
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_immediate_pin_after_response_uses_final_server_revision():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(); calls, streams, conversations, messages = fixture(page)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
page.get_by_role('button',name='A saved thought',exact=True).click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
page.locator('#primaryInput').fill('Finish and immediately pin'); page.locator('#primarySend').click()
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
page.locator('#pinConversation').click()
|
||||||
|
expect(page.locator('#pinConversation')).to_have_text('Unpin')
|
||||||
|
assert conversations['c1']['pinned'] is True
|
||||||
|
assert 'Conversation refreshed' not in page.locator('#chatNotice').inner_text()
|
||||||
|
patch = next(body for method,path,body in calls if method == 'PATCH')
|
||||||
|
assert patch['revision'] == 2
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_realtime_voice_is_inline_in_same_thread_and_creates_real_conversation():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
for width in [1440, 390, 320]:
|
||||||
|
page = browser.new_page(viewport={'width':width,'height':900})
|
||||||
|
errors = []; page.on('pageerror', lambda error: errors.append(str(error)))
|
||||||
|
calls, streams, conversations, messages = fixture(page)
|
||||||
|
page.add_init_script("window.addEventListener('hope:start-voice', event => {event.stopImmediatePropagation(); window.startedVoice = event.detail.conversation_id; window.dispatchEvent(new CustomEvent('hope:voice-state', {detail:{active:true,conversation_id:event.detail.conversation_id}}));}, true)")
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
# Replace only voice transport; durable conversation creation still uses real chat.js.
|
||||||
|
|
||||||
|
expect(page.locator('#primaryComposer #talkToHope')).to_be_visible()
|
||||||
|
assert page.locator('.chat-topbar #talkToHope').count() == 0
|
||||||
|
page.locator('#primaryInput').fill('Keep this draft while I talk')
|
||||||
|
page.locator('#talkToHope').click()
|
||||||
|
expect(page.locator('#legacyVoice')).to_be_visible()
|
||||||
|
expect(page.locator('#primaryChat')).to_be_visible()
|
||||||
|
expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk')
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled()
|
||||||
|
expect(page.locator('#primarySend')).to_be_disabled()
|
||||||
|
assert page.evaluate('window.startedVoice') == 'c2'
|
||||||
|
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
|
page.screenshot(path=f'/tmp/hope-talk-visible-{width}.png')
|
||||||
|
assert not errors
|
||||||
|
page.close()
|
||||||
|
browser.close()
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Offline real-SDK qualification of speech through durable Hope runs."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
import httpx
|
||||||
|
from livekit.agents import llm
|
||||||
|
from agent.conversation_llm import ConversationLLM
|
||||||
|
|
||||||
|
class ConversationLLMTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_single_turn_only_and_completed_public_output(self):
|
||||||
|
requests, progress = [], []
|
||||||
|
def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
if request.method == 'POST':
|
||||||
|
return httpx.Response(202, json={'run': {'id': 'run1'}, 'message': {'id': 'user1'}})
|
||||||
|
events = [
|
||||||
|
('message', {'message': {'id':'a','role':'assistant','status':'streaming','content':'draft'}}),
|
||||||
|
('progress', {'message':'PRIVATE TOOL DETAILS'}),
|
||||||
|
('message', {'message': {'id':'a','role':'assistant','status':'completed','content':'Final answer'}}),
|
||||||
|
('done', {'run': {'id':'run1','status':'completed'}}),
|
||||||
|
]
|
||||||
|
body = ''.join('event: '+name+'\ndata: '+json.dumps(data)+'\n\n' for name,data in events)
|
||||||
|
return httpx.Response(200, text=body, headers={'Content-Type':'text/event-stream'})
|
||||||
|
async def feedback(payload): progress.append(payload)
|
||||||
|
bridge = ConversationLLM(control_url='https://control.test',secret='internal-secret',room_id='room1',on_progress=feedback)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
context = llm.ChatContext([
|
||||||
|
llm.ChatMessage(role='system',content=['DO NOT FORWARD']),
|
||||||
|
llm.ChatMessage(role='user',content=['earlier']),
|
||||||
|
llm.ChatMessage(role='assistant',content=['earlier answer']),
|
||||||
|
llm.ChatMessage(role='user',content=['latest question']),
|
||||||
|
])
|
||||||
|
async with bridge.chat(chat_ctx=context) as stream:
|
||||||
|
chunks = [chunk async for chunk in stream]
|
||||||
|
self.assertEqual([c.delta.content for c in chunks], ['Final answer'])
|
||||||
|
payload=json.loads(requests[0].content)
|
||||||
|
self.assertEqual(payload['content'],'latest question')
|
||||||
|
self.assertEqual(payload['modality'],'voice')
|
||||||
|
self.assertNotIn('messages',payload)
|
||||||
|
self.assertEqual(requests[0].headers['X-Control-Secret'],'internal-secret')
|
||||||
|
self.assertEqual(progress[-1]['status'],'completed')
|
||||||
|
self.assertNotIn('PRIVATE',json.dumps(progress))
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
async def test_rejected_admission_has_no_answer_or_retry(self):
|
||||||
|
calls=[]
|
||||||
|
def handler(request):
|
||||||
|
calls.append(request)
|
||||||
|
return httpx.Response(403,json={'detail':'private diagnostics'})
|
||||||
|
async def progress(_): pass
|
||||||
|
bridge=ConversationLLM(control_url='https://control.test',secret='s',room_id='r',on_progress=progress)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client=httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
ctx=llm.ChatContext([llm.ChatMessage(role='user',content=['hello'])])
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
async with bridge.chat(chat_ctx=ctx) as stream:
|
||||||
|
async for _ in stream: pass
|
||||||
|
self.assertEqual(len(calls),1)
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
async def test_repeated_stream_uses_stable_user_turn_id(self):
|
||||||
|
payloads = []
|
||||||
|
def handler(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
payloads.append(json.loads(request.content))
|
||||||
|
return httpx.Response(202, json={'run': {'id': 'offline-stable-run'}})
|
||||||
|
body = ('event: message\ndata: {"message":{"role":"assistant","status":"completed","content":"Answer"}}\n\n'
|
||||||
|
'event: done\ndata: {"run":{"status":"completed"}}\n\n')
|
||||||
|
return httpx.Response(200, text=body)
|
||||||
|
async def progress(_): pass
|
||||||
|
bridge = ConversationLLM(control_url='https://offline.test', secret='offline', room_id='room', on_progress=progress)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
context = llm.ChatContext([llm.ChatMessage(role='user', content=['Same physical turn'])])
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
async with bridge.chat(chat_ctx=context) as stream:
|
||||||
|
self.assertEqual([chunk.delta.content async for chunk in stream], ['Answer'])
|
||||||
|
self.assertEqual(payloads[0]['idempotency_key'], payloads[1]['idempotency_key'])
|
||||||
|
self.assertEqual(payloads[0]['idempotency_key'], context.items[-1].id)
|
||||||
|
finally:
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
async def test_cancel_uncertain_admission_retries_identical_key_and_cancels(self):
|
||||||
|
entered, release = asyncio.Event(), asyncio.Event()
|
||||||
|
payloads, cancelled_paths = [], []
|
||||||
|
async def handler(request):
|
||||||
|
if request.url.path.endswith('/messages'):
|
||||||
|
payloads.append(json.loads(request.content))
|
||||||
|
if len(payloads) == 1:
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
raise httpx.ReadError('Offline lost admission receipt', request=request)
|
||||||
|
return httpx.Response(202, json={'run': {'id': 'offline-recovered-id'}})
|
||||||
|
if request.url.path.endswith('/cancel'):
|
||||||
|
cancelled_paths.append(request.url.path)
|
||||||
|
return httpx.Response(200, json={'run': {'status': 'cancelled'}})
|
||||||
|
self.fail('Cancelled admission must not attach an answer stream')
|
||||||
|
async def progress(_): pass
|
||||||
|
bridge = ConversationLLM(control_url='https://offline.test', secret='offline', room_id='bound-room', on_progress=progress)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
context = llm.ChatContext([llm.ChatMessage(role='user', content=['Uncertain admission'])])
|
||||||
|
stream = bridge.chat(chat_ctx=context)
|
||||||
|
async def consume():
|
||||||
|
async with stream:
|
||||||
|
return [chunk async for chunk in stream]
|
||||||
|
consumer = asyncio.create_task(consume())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(entered.wait(), 2)
|
||||||
|
closer = asyncio.create_task(stream.aclose())
|
||||||
|
await asyncio.sleep(.02)
|
||||||
|
release.set()
|
||||||
|
await asyncio.wait_for(closer, 2)
|
||||||
|
await asyncio.gather(consumer, return_exceptions=True)
|
||||||
|
self.assertEqual(len(payloads), 2)
|
||||||
|
self.assertEqual(payloads[0], payloads[1])
|
||||||
|
self.assertEqual(cancelled_paths, [
|
||||||
|
'/internal/chat/rooms/bound-room/operations/' + context.items[-1].id + '/cancel',
|
||||||
|
'/internal/chat/rooms/bound-room/runs/offline-recovered-id/cancel'])
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
if not consumer.done(): consumer.cancel()
|
||||||
|
await asyncio.gather(consumer, return_exceptions=True)
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
async def test_cancel_blocked_admission_learns_and_cancels_remote_id(self):
|
||||||
|
admission_entered, release_admission, cancelled = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
cancellations = []
|
||||||
|
async def handler(request):
|
||||||
|
if request.url.path.endswith('/messages'):
|
||||||
|
admission_entered.set()
|
||||||
|
await release_admission.wait()
|
||||||
|
return httpx.Response(202, json={'run': {'id': 'offline-admitted-run'}})
|
||||||
|
if request.url.path.endswith('/cancel'):
|
||||||
|
cancellations.append(request.url.path)
|
||||||
|
cancelled.set()
|
||||||
|
return httpx.Response(200, json={'run': {'status': 'cancelled'}})
|
||||||
|
return httpx.Response(200, text='event: done\ndata: {"run":{"status":"cancelled"}}\n\n')
|
||||||
|
async def progress(_): pass
|
||||||
|
bridge = ConversationLLM(control_url='https://offline.test', secret='offline', room_id='room', on_progress=progress)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
context = llm.ChatContext([llm.ChatMessage(role='user', content=['Cancel during admission'])])
|
||||||
|
stream = bridge.chat(chat_ctx=context)
|
||||||
|
async def consume():
|
||||||
|
async with stream:
|
||||||
|
return [chunk async for chunk in stream]
|
||||||
|
consumer = asyncio.create_task(consume())
|
||||||
|
closer = second_closer = None
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(admission_entered.wait(), 2)
|
||||||
|
# Public SDK cancellation must not orphan an admission already accepted
|
||||||
|
# remotely but whose response has not reached the agent.
|
||||||
|
closer = asyncio.create_task(stream.aclose())
|
||||||
|
await asyncio.sleep(.02)
|
||||||
|
second_closer = asyncio.create_task(stream.aclose())
|
||||||
|
await asyncio.sleep(.02)
|
||||||
|
release_admission.set()
|
||||||
|
await asyncio.wait_for(asyncio.gather(closer, second_closer), 2)
|
||||||
|
await asyncio.gather(consumer, return_exceptions=True)
|
||||||
|
await asyncio.wait_for(cancelled.wait(), .3)
|
||||||
|
self.assertEqual(cancellations, [
|
||||||
|
'/internal/chat/rooms/room/operations/' + context.items[-1].id + '/cancel',
|
||||||
|
'/internal/chat/rooms/room/runs/offline-admitted-run/cancel'])
|
||||||
|
finally:
|
||||||
|
release_admission.set()
|
||||||
|
if closer and not closer.done():
|
||||||
|
closer.cancel()
|
||||||
|
await asyncio.gather(closer, return_exceptions=True)
|
||||||
|
if not consumer.done():
|
||||||
|
consumer.cancel()
|
||||||
|
await asyncio.gather(consumer, return_exceptions=True)
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
async def test_normal_lost_admission_receipt_persists_key_cancellation(self):
|
||||||
|
requests, progress_events = [], []
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request.url.path)
|
||||||
|
if request.url.path.endswith('/messages'):
|
||||||
|
raise httpx.ReadError('Offline accepted admission receipt lost', request=request)
|
||||||
|
if '/operations/' in request.url.path and request.url.path.endswith('/cancel'):
|
||||||
|
return httpx.Response(200, json={'ok': True})
|
||||||
|
self.fail('Unknown admission identity must not stream or invent a run stop')
|
||||||
|
async def progress(value): progress_events.append(value)
|
||||||
|
bridge = ConversationLLM(control_url='https://offline.test', secret='offline', room_id='bound-room', on_progress=progress)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
context = llm.ChatContext([llm.ChatMessage(role='user', content=['Lost receipt without barge-in'])])
|
||||||
|
try:
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
async with bridge.chat(chat_ctx=context) as stream:
|
||||||
|
async for _ in stream:
|
||||||
|
self.fail('No answer is safe after uncertain admission failure')
|
||||||
|
self.assertEqual(requests, ['/internal/chat/rooms/bound-room/messages',
|
||||||
|
'/internal/chat/rooms/bound-room/operations/' + context.items[-1].id + '/cancel'])
|
||||||
|
self.assertEqual(progress_events[-1]['status'], 'failed')
|
||||||
|
finally:
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
async def test_key_cancel_receipt_prevents_late_rejected_admission_action(self):
|
||||||
|
entered, release, tombstoned = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
requests, progress_events = [], []
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request.url.path)
|
||||||
|
if request.url.path.endswith('/messages'):
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
self.assertTrue(tombstoned.is_set(), 'Persist operation cancellation before admission settles')
|
||||||
|
return httpx.Response(409, json={'detail': 'Operation cancelled'})
|
||||||
|
if '/operations/' in request.url.path and request.url.path.endswith('/cancel'):
|
||||||
|
tombstoned.set()
|
||||||
|
return httpx.Response(200, json={'ok': True})
|
||||||
|
self.fail('A rejected cancelled operation must never stream or cancel an invented run')
|
||||||
|
async def progress(value): progress_events.append(value)
|
||||||
|
bridge = ConversationLLM(control_url='https://offline.test', secret='offline', room_id='bound-room', on_progress=progress)
|
||||||
|
await bridge.client.aclose()
|
||||||
|
bridge.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
context = llm.ChatContext([llm.ChatMessage(role='user', content=['Cancel before admission'])])
|
||||||
|
stream = bridge.chat(chat_ctx=context)
|
||||||
|
async def consume():
|
||||||
|
async with stream:
|
||||||
|
return [chunk async for chunk in stream]
|
||||||
|
consumer = asyncio.create_task(consume())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(entered.wait(), 2)
|
||||||
|
closer = asyncio.create_task(stream.aclose())
|
||||||
|
await asyncio.wait_for(tombstoned.wait(), 2)
|
||||||
|
release.set()
|
||||||
|
await asyncio.wait_for(closer, 2)
|
||||||
|
await asyncio.gather(consumer, return_exceptions=True)
|
||||||
|
self.assertEqual(requests, ['/internal/chat/rooms/bound-room/messages',
|
||||||
|
'/internal/chat/rooms/bound-room/operations/' + context.items[-1].id + '/cancel'])
|
||||||
|
self.assertEqual(progress_events[-1]['status'], 'failed')
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
if not consumer.done(): consumer.cancel()
|
||||||
|
await asyncio.gather(consumer, return_exceptions=True)
|
||||||
|
await bridge.aclose()
|
||||||
|
|
||||||
|
if __name__=='__main__': unittest.main()
|
||||||
@@ -3,13 +3,17 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import stat
|
||||||
|
import tarfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
FAKE = r'''#!/usr/bin/env python3
|
FAKE = r'''#!/usr/bin/env python3
|
||||||
import json,os,sys
|
import json,os,sys
|
||||||
p=os.environ['STATE'];s=json.load(open(p));a=sys.argv[1:]
|
p=os.environ['STATE'];s=json.load(open(p));a=sys.argv[1:]
|
||||||
|
s.setdefault('operations',[]).append(a)
|
||||||
def save():open(p,'w').write(json.dumps(s))
|
def save():open(p,'w').write(json.dumps(s))
|
||||||
|
save()
|
||||||
def item(n):return next((x for x in s['containers'] if x['name']==n or x['id']==n),None)
|
def item(n):return next((x for x in s['containers'] if x['name']==n or x['id']==n),None)
|
||||||
cmd=a[0];n=a[-1];c=item(n)
|
cmd=a[0];n=a[-1];c=item(n)
|
||||||
if cmd=='inspect':
|
if cmd=='inspect':
|
||||||
@@ -35,12 +39,16 @@ elif cmd=='run':
|
|||||||
if entry=='curl' and '%{http_code}' in a:print('401')
|
if entry=='curl' and '%{http_code}' in a:print('401')
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
name=a[a.index('--name')+1]
|
name=a[a.index('--name')+1]
|
||||||
if name=='voice' and s['failure']=='run':sys.exit(9)
|
if name=='voice' and s['failure'] in ('run','post-schema'):
|
||||||
|
if s['failure']=='post-schema':
|
||||||
|
from pathlib import Path
|
||||||
|
(Path(p).parent/'accounts'/'post-deploy-message.txt').write_text('New user message must survive rollback')
|
||||||
|
sys.exit(9)
|
||||||
s['containers'].append({'name':name,'id':'new-'+name,'running':True});save()
|
s['containers'].append({'name':name,'id':'new-'+name,'running':True});save()
|
||||||
else:sys.exit(2)
|
else:sys.exit(2)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@pytest.mark.parametrize('failure',['stop','rename','run'])
|
@pytest.mark.parametrize('failure',['stop','rename','run','post-schema'])
|
||||||
def test_partial_deployment_restores_originals(tmp_path,failure):
|
def test_partial_deployment_restores_originals(tmp_path,failure):
|
||||||
root=tmp_path/'state';root.mkdir()
|
root=tmp_path/'state';root.mkdir()
|
||||||
for n in ['runtime.env','livekit.yaml','voice.env']:(root/n).touch()
|
for n in ['runtime.env','livekit.yaml','voice.env']:(root/n).touch()
|
||||||
@@ -58,3 +66,84 @@ def test_partial_deployment_restores_originals(tmp_path,failure):
|
|||||||
assert result.returncode!=0,result.stdout+result.stderr
|
assert result.returncode!=0,result.stdout+result.stderr
|
||||||
data=json.loads(state.read_text())
|
data=json.loads(state.read_text())
|
||||||
assert sorted((x['name'],x['id'],x['running']) for x in data['containers'])==[('hope-control','original-control',True),('voice','original-voice',True)]
|
assert sorted((x['name'],x['id'],x['running']) for x in data['containers'])==[('hope-control','original-control',True),('voice','original-voice',True)]
|
||||||
|
if failure == 'post-schema':
|
||||||
|
assert (root/'accounts'/'post-deploy-message.txt').read_text() == 'New user message must survive rollback'
|
||||||
|
backup = next((root/'rollback').iterdir())
|
||||||
|
with tarfile.open(backup/'account-state.tgz') as archive:
|
||||||
|
assert 'accounts/post-deploy-message.txt' not in archive.getnames()
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_retains_quiesced_account_snapshot_and_secret_permissions(tmp_path):
|
||||||
|
root = tmp_path/'state'
|
||||||
|
root.mkdir()
|
||||||
|
for directory in ('accounts', 'hermes', 'certs'):
|
||||||
|
(root/directory).mkdir()
|
||||||
|
files = {
|
||||||
|
'runtime.env': b'CONTROL_SERVICE_SECRET=local-test-secret\n',
|
||||||
|
'voice.env': b'AZURE_SPEECH_KEY=local-test-secret\n',
|
||||||
|
'livekit.yaml': b'keys: {test: private-test-key}\n',
|
||||||
|
'accounts/accounts.sqlite3': b'offline database snapshot',
|
||||||
|
'accounts/accounts.sqlite3-wal': b'offline sqlite wal snapshot',
|
||||||
|
'accounts/features/uploads/private-file': b'private upload bytes',
|
||||||
|
'hermes/private-home/memory.txt': b'keep original account home',
|
||||||
|
'certs/server.crt': b'keep original certificate',
|
||||||
|
}
|
||||||
|
for name, content in files.items():
|
||||||
|
path = root/name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(content)
|
||||||
|
state = root/'fake.json'
|
||||||
|
state.write_text(json.dumps({'failure': 'none', 'containers': [
|
||||||
|
{'name': 'voice', 'id': 'original-voice', 'running': True},
|
||||||
|
{'name': 'hope-control', 'id': 'original-control', 'running': True}]}))
|
||||||
|
bindir = tmp_path/'bin'
|
||||||
|
bindir.mkdir()
|
||||||
|
docker = bindir/'docker'
|
||||||
|
docker.write_text(FAKE)
|
||||||
|
docker.chmod(0o755)
|
||||||
|
# A real tar creates the archive after checking the exact mocked containers
|
||||||
|
# are stopped and renamed, and before either replacement has started.
|
||||||
|
tar = bindir/'tar'
|
||||||
|
tar.write_text('''#!/usr/bin/env python3
|
||||||
|
import json,os,sys
|
||||||
|
s=json.load(open(os.environ['STATE']))
|
||||||
|
assert sorted((x['id'],x['running']) for x in s['containers']) == [('original-control',False),('original-voice',False)]
|
||||||
|
assert all('-rollback-' in x['name'] for x in s['containers'])
|
||||||
|
os.execv('/usr/bin/tar',['/usr/bin/tar']+sys.argv[1:])
|
||||||
|
''')
|
||||||
|
tar.chmod(0o755)
|
||||||
|
source = (Path(__file__).parents[2]/'scripts/deploy-unraid.sh').read_text()
|
||||||
|
source = source.replace('ROOT=/mnt/user/appdata/hope-voice', f'ROOT={root}').replace('/mnt/user/dev/hope-voice-api/certs', str(root/'certs'))
|
||||||
|
script = tmp_path/'deploy.sh'
|
||||||
|
script.write_text(source)
|
||||||
|
env = dict(os.environ, STATE=str(state), PATH=str(bindir)+os.pathsep+os.environ['PATH'])
|
||||||
|
result = subprocess.run(['bash', str(script)], env=env, capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stdout+result.stderr
|
||||||
|
data = json.loads(state.read_text())
|
||||||
|
backups = list((root/'rollback').iterdir())
|
||||||
|
assert len(backups) == 1
|
||||||
|
backup = backups[0]
|
||||||
|
assert stat.S_IMODE(backup.stat().st_mode) == 0o700
|
||||||
|
for name in ('account-state.tgz', 'runtime.env', 'voice.env', 'livekit.yaml', 'voice.json', 'hope-control.json'):
|
||||||
|
assert stat.S_IMODE((backup/name).stat().st_mode) == 0o600
|
||||||
|
for name in ('runtime.env', 'voice.env', 'livekit.yaml'):
|
||||||
|
assert (backup/name).read_bytes() == files[name]
|
||||||
|
with tarfile.open(backup/'account-state.tgz') as archive:
|
||||||
|
for name, content in files.items():
|
||||||
|
if name.startswith('accounts/'):
|
||||||
|
assert archive.extractfile(name).read() == content
|
||||||
|
assert not any(name.startswith(('hermes/', 'certs/')) for name in archive.getnames())
|
||||||
|
for name, identifier in (('voice', 'original-voice'), ('hope-control', 'original-control')):
|
||||||
|
assert (backup/(name+'-container-id.txt')).read_text().strip() == identifier
|
||||||
|
original = next(container for container in data['containers'] if container['id'] == identifier)
|
||||||
|
assert original['name'] == name+'-rollback-'+backup.name
|
||||||
|
assert original['running'] is False
|
||||||
|
replacement = next(container for container in data['containers'] if container['name'] == name)
|
||||||
|
assert replacement['id'] == 'new-'+name and replacement['running'] is True
|
||||||
|
assert not any(operation[0] == 'rm' for operation in data['operations'])
|
||||||
|
for name, content in files.items():
|
||||||
|
assert (root/name).read_bytes() == content
|
||||||
|
assert 'local-test-secret' not in result.stdout+result.stderr
|
||||||
|
control_run = next(operation for operation in data['operations'] if operation[0] == 'run' and '--name' in operation and operation[operation.index('--name')+1] == 'hope-control')
|
||||||
|
assert str(root/'accounts')+':/var/lib/hope' in control_run
|
||||||
|
assert str(root/'hermes')+':'+str(root/'hermes') in control_run
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Offline token contract, native identity, generator drift and browser cascade tests."""
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
module_spec = importlib.util.spec_from_file_location("hope_theme_generator", ROOT / "scripts/generate_hope_themes.py")
|
||||||
|
generator = importlib.util.module_from_spec(module_spec)
|
||||||
|
module_spec.loader.exec_module(generator)
|
||||||
|
|
||||||
|
|
||||||
|
class HopeThemeTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.source = generator.SPEC.read_bytes()
|
||||||
|
cls.spec = json.loads(cls.source)
|
||||||
|
|
||||||
|
def test_contract_defaults_and_identifiers(self):
|
||||||
|
self.assertEqual(self.spec["defaultPalette"], "opal")
|
||||||
|
self.assertEqual(self.spec["defaultAppearance"], "system")
|
||||||
|
self.assertEqual(list(self.spec["palettes"]), ["opal", "graphite", "ocean", "sage", "lavender"])
|
||||||
|
generator.validate(self.spec)
|
||||||
|
|
||||||
|
def test_opal_exact_native_identity(self):
|
||||||
|
# Fixed from voice-ios/Sources/App/Nearlight.swift; no external checkout needed in CI.
|
||||||
|
expected = {
|
||||||
|
"light": ["#FFFDFD", "#FFF7FA", "#FFF2F7", "#302B40", "#706074", "#A64270", "#FFFFFF"],
|
||||||
|
"dark": ["#201820", "#302330", "#3B2836", "#FFF2F7", "#CBB6C7", "#F0A8C8", "#201820"],
|
||||||
|
}
|
||||||
|
for appearance, values in expected.items():
|
||||||
|
for role, value in zip(("ground", "surface", "personaSurface", "ink", "quiet", "accent", "onAccent"), values):
|
||||||
|
self.assertEqual(self.spec["palettes"]["opal"][appearance][role], value)
|
||||||
|
self.assertEqual(self.spec["primitives"]["alertLight"], [0.675, 0.208, 0.282])
|
||||||
|
self.assertEqual(self.spec["primitives"]["alertDark"], [1.0, 0.604, 0.675])
|
||||||
|
|
||||||
|
def test_all_required_contrast_pairs(self):
|
||||||
|
checks = generator.contrast_checks(self.spec)
|
||||||
|
self.assertEqual(len(checks), 230)
|
||||||
|
for row in checks:
|
||||||
|
with self.subTest(**row):
|
||||||
|
self.assertGreaterEqual(row["ratio"], row["minimum"])
|
||||||
|
self.assertAlmostEqual(generator.contrast((1, 1, 1), (0, 0, 0)), 21)
|
||||||
|
|
||||||
|
def test_bad_contrast_rejected(self):
|
||||||
|
changed = copy.deepcopy(self.spec)
|
||||||
|
changed["palettes"]["opal"]["light"]["quiet"] = "#FFFDFD"
|
||||||
|
with self.assertRaisesRegex(ValueError, "Contrast verification failed"):
|
||||||
|
generator.validate(changed)
|
||||||
|
|
||||||
|
def test_missing_role_rejected(self):
|
||||||
|
changed = copy.deepcopy(self.spec)
|
||||||
|
del changed["palettes"]["ocean"]["dark"]["focus"]
|
||||||
|
with self.assertRaisesRegex(ValueError, "Incomplete semantic roles"):
|
||||||
|
generator.validate(changed)
|
||||||
|
|
||||||
|
def test_invalid_primitive_rejected(self):
|
||||||
|
for value in ("#abc", [1.1, 0, 0], [True, 0, 0], "transparent"):
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||||
|
generator.rgb(value)
|
||||||
|
|
||||||
|
def test_alert_is_not_palette_accent(self):
|
||||||
|
for appearance in ("light", "dark"):
|
||||||
|
alerts = {generator.resolve(self.spec, palette, appearance)["alert"] for palette in self.spec["palettes"]}
|
||||||
|
self.assertEqual(len(alerts), 1)
|
||||||
|
|
||||||
|
def test_derived_roles_are_opaque_surface_composites(self):
|
||||||
|
colors = generator.resolve(self.spec, "opal", "light")
|
||||||
|
for role, fg, alpha in (("separator", "ink", .14), ("selectedWash", "accent", .10), ("errorWash", "alert", .08)):
|
||||||
|
self.assertEqual(colors[role], tuple(f * alpha + b * (1 - alpha) for f, b in zip(colors[fg], colors["surface"])))
|
||||||
|
|
||||||
|
def test_generated_artifacts_are_current_and_deterministic(self):
|
||||||
|
first = generator.artifacts(self.spec, self.source)
|
||||||
|
self.assertEqual(first, generator.artifacts(self.spec, self.source))
|
||||||
|
for key, path in generator.OUTPUTS.items():
|
||||||
|
self.assertEqual(path.read_text(), first[key], str(path))
|
||||||
|
|
||||||
|
def test_swift_channels_round_trip(self):
|
||||||
|
for palette in self.spec["palettes"]:
|
||||||
|
for appearance in ("light", "dark"):
|
||||||
|
for color in generator.resolve(self.spec, palette, appearance).values():
|
||||||
|
self.assertEqual(tuple(float(generator.number(c)) for c in color), color)
|
||||||
|
|
||||||
|
|
||||||
|
class HopeThemeBrowserTests(unittest.TestCase):
|
||||||
|
def test_browser_palette_appearance_cascade(self):
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("Optional Playwright unavailable; install it to verify browser cascade")
|
||||||
|
spec = json.loads(generator.SPEC.read_bytes())
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
try:
|
||||||
|
page = browser.new_page()
|
||||||
|
page.set_content('<html><head></head><body><span id="probe">Hope</span></body></html>')
|
||||||
|
page.add_style_tag(content=generator.OUTPUTS["css"].read_text())
|
||||||
|
page.add_style_tag(content='#probe { color: var(--hope-ink); background-color: var(--hope-ground); }')
|
||||||
|
for system in ("light", "dark"):
|
||||||
|
page.emulate_media(color_scheme=system)
|
||||||
|
for palette in [None, "obsolete"] + list(spec["palettes"]):
|
||||||
|
for appearance in (None, "obsolete", "system", "light", "dark"):
|
||||||
|
page.evaluate('([p, a]) => { for (const [key, value] of [["palette", p], ["appearance", a]]) { if (value === null) delete document.documentElement.dataset[key]; else document.documentElement.dataset[key] = value; } }', [palette, appearance])
|
||||||
|
actual = page.locator("#probe").evaluate('(el) => { const s = getComputedStyle(el); return [s.color, s.backgroundColor]; }')
|
||||||
|
resolved_palette = palette if palette in spec["palettes"] else "opal"
|
||||||
|
resolved_appearance = appearance if appearance in ("light", "dark") else system
|
||||||
|
colors = generator.resolve(spec, resolved_palette, resolved_appearance)
|
||||||
|
expected = ["rgb(" + ", ".join(str(round(c * 255)) for c in colors[role]) + ")" for role in ("ink", "ground")]
|
||||||
|
with self.subTest(system=system, palette=palette, appearance=appearance):
|
||||||
|
self.assertEqual(actual, expected)
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,6 +19,8 @@ class DockerBoundary:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.containers, self.networks, self.created, self.deleted = {}, {}, [], []
|
self.containers, self.networks, self.created, self.deleted = {}, {}, [], []
|
||||||
self.fail_start = False
|
self.fail_start = False
|
||||||
|
self.restarted = []
|
||||||
|
self.fail_restart = False
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
path, method = request.url.path, request.method
|
path, method = request.url.path, request.method
|
||||||
@@ -51,6 +53,13 @@ class DockerBoundary:
|
|||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
return httpx.Response(200, json=info)
|
return httpx.Response(200, json=info)
|
||||||
if method == 'POST':
|
if method == 'POST':
|
||||||
|
if path.endswith('/restart'):
|
||||||
|
self.restarted.append(key)
|
||||||
|
if self.fail_restart:
|
||||||
|
self.fail_restart = False
|
||||||
|
return httpx.Response(500)
|
||||||
|
elif not path.endswith('/start'):
|
||||||
|
raise AssertionError(path)
|
||||||
if self.fail_start:
|
if self.fail_start:
|
||||||
self.fail_start = False
|
self.fail_start = False
|
||||||
return httpx.Response(500)
|
return httpx.Response(500)
|
||||||
@@ -120,7 +129,7 @@ def test_account_isolation_and_runtime_configuration(boundary):
|
|||||||
assert config['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'}
|
assert config['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'}
|
||||||
assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1',
|
assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1',
|
||||||
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': 'auto',
|
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': 'auto',
|
||||||
'models': ['auto'], 'discover_models': False}
|
'models': {'auto': {'supports_vision': True}}, 'discover_models': False}
|
||||||
assert config['agent']['reasoning_effort'] == 'none'
|
assert config['agent']['reasoning_effort'] == 'none'
|
||||||
assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'}
|
assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'}
|
||||||
assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server'])
|
assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server'])
|
||||||
@@ -209,6 +218,68 @@ def test_owned_container_cannot_rotate_missing_private_secret(boundary):
|
|||||||
assert len(docker.created) == 1
|
assert len(docker.created) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('models', [
|
||||||
|
['auto', 'text-only'],
|
||||||
|
{'auto': {'supports_vision': False, 'context_window': 32768}, 'text-only': {'supports_vision': False}},
|
||||||
|
])
|
||||||
|
def test_auto_vision_migration_preserves_settings_and_reloads_once(boundary, models):
|
||||||
|
p, docker, _ = boundary
|
||||||
|
original = asyncio.run(p.ensure(ALICE))
|
||||||
|
private, home, workspace = p._paths(ALICE)
|
||||||
|
config = yaml.safe_load((home / 'config.yaml').read_text())
|
||||||
|
config['providers']['hope-upstream']['models'] = models
|
||||||
|
config['model']['default'] = 'text-only'
|
||||||
|
config['agent']['reasoning_effort'] = 'high'
|
||||||
|
config['retained'] = {'private': True}
|
||||||
|
(home / 'config.yaml').write_text(yaml.safe_dump(config))
|
||||||
|
(workspace / 'work.txt').write_text('retained')
|
||||||
|
assert not asyncio.run(p.ready(ALICE))
|
||||||
|
assert asyncio.run(p.ensure(ALICE)) == original
|
||||||
|
result = yaml.safe_load((home / 'config.yaml').read_text())
|
||||||
|
expected = copy.deepcopy(config)
|
||||||
|
if isinstance(models, list):
|
||||||
|
expected['providers']['hope-upstream']['models'] = {m: {} for m in models}
|
||||||
|
expected['providers']['hope-upstream']['models']['auto']['supports_vision'] = True
|
||||||
|
assert result == expected
|
||||||
|
assert 'supports_vision' not in result['model']
|
||||||
|
assert docker.restarted == ['id-' + container_name(ALICE)]
|
||||||
|
assert not docker.deleted and len(docker.created) == 1
|
||||||
|
assert (workspace / 'work.txt').read_text() == 'retained'
|
||||||
|
assert not p._reload_marker(ALICE).exists()
|
||||||
|
assert asyncio.run(p.ready(ALICE))
|
||||||
|
asyncio.run(p.ensure(ALICE))
|
||||||
|
assert len(docker.restarted) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_reload_is_retried_after_control_restart(boundary):
|
||||||
|
p, docker, _ = boundary
|
||||||
|
original = asyncio.run(p.ensure(ALICE))
|
||||||
|
_, home, _ = p._paths(ALICE)
|
||||||
|
config = yaml.safe_load((home / 'config.yaml').read_text())
|
||||||
|
config['providers']['hope-upstream']['models'] = ['auto']
|
||||||
|
(home / 'config.yaml').write_text(yaml.safe_dump(config))
|
||||||
|
docker.fail_restart = True
|
||||||
|
with pytest.raises(ProvisioningError, match='HTTP 500'):
|
||||||
|
asyncio.run(p.ensure(ALICE))
|
||||||
|
assert p._reload_marker(ALICE).exists()
|
||||||
|
assert not asyncio.run(p.ready(ALICE))
|
||||||
|
restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health,
|
||||||
|
upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid)
|
||||||
|
assert asyncio.run(restarted.ensure(ALICE)) == original
|
||||||
|
assert len(docker.restarted) == 2 and not docker.deleted
|
||||||
|
assert not p._reload_marker(ALICE).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_patch_skips_unverified_models_and_preserves_metadata():
|
||||||
|
for config in ({'retained': True}, {'providers': {'hope-upstream': {'models': ['text-only']}}}):
|
||||||
|
original = copy.deepcopy(config)
|
||||||
|
assert Provisioner._declare_auto_vision(config) is False
|
||||||
|
assert config == original
|
||||||
|
for models in ('auto', [None], {'auto': None}):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Provisioner._declare_auto_vision({'providers': {'hope-upstream': {'models': models}}})
|
||||||
|
|
||||||
|
|
||||||
def test_upstream_runtime_overrides(monkeypatch):
|
def test_upstream_runtime_overrides(monkeypatch):
|
||||||
monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1')
|
monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1')
|
||||||
monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')
|
monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Offline browser settings qualification: real assets, mocked same-origin APIs only."""
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings_page():
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width': 390, 'height': 844})
|
||||||
|
state = {
|
||||||
|
'user': dict(id='alice', username='alice', display_name='Alice <script>', role='admin', status='ready'),
|
||||||
|
'users': [dict(id='bob', username='bob', display_name='Bob', role='user', status='error', container_name='hope-hermes-bob', error='Provisioning failed')],
|
||||||
|
'preferences': dict(palette='opal', appearance='light', revision=1),
|
||||||
|
'model': dict(model='auto', thinking_level='none'),
|
||||||
|
'memory': True, 'appearance_error': False, 'calls': [], 'errors': [],
|
||||||
|
}
|
||||||
|
page.on('pageerror', lambda error: state['errors'].append(str(error)))
|
||||||
|
|
||||||
|
def route(request):
|
||||||
|
path = urlparse(request.request.url).path
|
||||||
|
method = request.request.method
|
||||||
|
body = request.request.post_data_json if request.request.post_data else {}
|
||||||
|
state['calls'].append((method, path, body))
|
||||||
|
if path == '/api/auth/me':
|
||||||
|
if state['user'] is None:
|
||||||
|
request.fulfill(status=401, json={'error': 'Sign in required'}); return
|
||||||
|
data = {'user': state['user']}
|
||||||
|
elif path == '/api/auth/logout': data = {'ok': True}
|
||||||
|
elif path == '/api/capabilities': data = {'features': {'memory_controls': state['memory']}}
|
||||||
|
elif path == '/api/research': data = {'available': False}
|
||||||
|
elif path == '/api/preferences':
|
||||||
|
if state['appearance_error']:
|
||||||
|
request.fulfill(status=503, json={'error': 'Appearance unavailable'}); return
|
||||||
|
if method == 'PUT':
|
||||||
|
assert body['revision'] == state['preferences']['revision']
|
||||||
|
state['preferences'].update(body)
|
||||||
|
state['preferences']['revision'] += 1
|
||||||
|
data = {'preferences': state['preferences']}
|
||||||
|
elif path == '/api/settings/model':
|
||||||
|
if method == 'PUT': state['model'].update(body)
|
||||||
|
data = {'settings': state['model'], 'catalog_available': True,
|
||||||
|
'models': [{'id': 'auto', 'name': 'Managed auto', 'thinking_levels': ['none', 'low', 'high']}]}
|
||||||
|
elif path == '/voices':
|
||||||
|
data = {'catalog_available': True, 'default_voice': 'en-US-Ava:DragonHDOmniLatestNeural',
|
||||||
|
'voices': [{'id': 'en-US-Ava:DragonHDOmniLatestNeural', 'name': 'Ava', 'locale': 'en-US', 'gender': 'Female', 'tag': 'Omni'}]}
|
||||||
|
elif path == '/api/admin/users':
|
||||||
|
if state['user']['role'] != 'admin':
|
||||||
|
request.fulfill(status=403, json={'error': 'Forbidden'}); return
|
||||||
|
if method == 'POST': state['users'].append(dict(body, id='new', status='provisioning', container_name='hope-hermes-new'))
|
||||||
|
data = {'users': state['users']}
|
||||||
|
elif path.startswith('/api/admin/users/'):
|
||||||
|
if method == 'DELETE': state['users'] = [user for user in state['users'] if user['id'] != path.split('/')[-1]]
|
||||||
|
data = {'ok': True}
|
||||||
|
elif path == '/api/memory': data = {'sections': [{'section': 'memory', 'content': 'Saved facts', 'revision': 'm1'}]}
|
||||||
|
elif path == '/api/memory/memory': data = {'revision': 'm2'}
|
||||||
|
elif path == '/api/conversations': data = {'conversations': [], 'next_cursor': None}
|
||||||
|
elif path == '/api/workspaces': data = {'workspaces': []}
|
||||||
|
elif path == '/api/sync': data = {'changes': [], 'cursor': 0}
|
||||||
|
else:
|
||||||
|
asset = ROOT / 'web' / (path.lstrip('/') or 'index.html')
|
||||||
|
if asset.is_file():
|
||||||
|
request.fulfill(body=asset.read_bytes(), content_type={'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}.get(asset.suffix, 'application/octet-stream')); return
|
||||||
|
request.fulfill(status=404, json={'error': 'Unavailable'}); return
|
||||||
|
request.fulfill(json=data)
|
||||||
|
|
||||||
|
page.route('https://hope.test/**', route)
|
||||||
|
page.clock.install()
|
||||||
|
page.goto('https://hope.test/')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
yield page, state
|
||||||
|
assert state['errors'] == []
|
||||||
|
page.close(); browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def open_section(page, name):
|
||||||
|
page.evaluate('(section) => HopeSettings.open(section)', name)
|
||||||
|
expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
|
expect(page.locator(f'[data-settings-panel="{name}"]')).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_readonly_keyboard_focus_and_idempotent_mobile_navigation(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
# Account settings remain reachable outside the voice-only legacy surface.
|
||||||
|
page.locator('#drawerOpen').click()
|
||||||
|
opener = page.locator('[data-settings-open="account"]').first
|
||||||
|
opener.click()
|
||||||
|
expect(page.locator('#settingsAccountName')).to_have_text('Alice <script>')
|
||||||
|
page.screenshot(path='/tmp/hope-settings-account-mobile.png', full_page=True)
|
||||||
|
assert page.locator('#settingsDialog script').count() == 0
|
||||||
|
assert page.locator('[data-settings-panel="account"] input:not([readonly])').count() == 0
|
||||||
|
page.evaluate('HopeSettings.initialize(); HopeSettings.initialize()')
|
||||||
|
page.locator('[data-settings-target="security"]').click()
|
||||||
|
expect(page.locator('[data-settings-target="security"]')).to_have_attribute('aria-current', 'page')
|
||||||
|
for _ in range(12):
|
||||||
|
page.keyboard.press('Tab')
|
||||||
|
assert page.evaluate('document.getElementById("settingsDialog").contains(document.activeElement)')
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
expect(page.locator('#settingsDialog')).not_to_be_visible()
|
||||||
|
expect(page.locator('#drawerOpen')).to_be_focused()
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
|
ids = page.evaluate('Array.from(document.querySelectorAll("[id]")).map(e=>e.id)')
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
assert not any(method in ('PUT', 'PATCH', 'POST') for method, _, _ in state['calls'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_reset_delete_keep_existing_auth_contracts(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
open_section(page, 'admin')
|
||||||
|
expect(page.locator('#usersBody')).to_contain_text('Provisioning failed')
|
||||||
|
page.locator('#usersBody tr').filter(has_text='Bob').get_by_role('button', name='Retry provisioning', exact=True).click()
|
||||||
|
page.wait_for_function("document.getElementById('adminMessage').textContent.includes('refresh')")
|
||||||
|
page.locator('#newUsername').fill('charlie')
|
||||||
|
page.locator('#newDisplayName').fill('Charlie')
|
||||||
|
page.locator('#newPassword').fill('fictional-password-123')
|
||||||
|
page.locator('#newRole').select_option('user')
|
||||||
|
page.locator('#createUserSubmit').click()
|
||||||
|
expect(page.locator('#usersBody')).to_contain_text('Charlie')
|
||||||
|
assert page.locator('#newPassword').input_value() == ''
|
||||||
|
page.locator('#usersBody tr').filter(has_text='Bob').get_by_role('button', name='Reset password').click()
|
||||||
|
expect(page.locator('#accountDialog')).to_be_visible()
|
||||||
|
page.locator('#resetPassword').fill('replacement-password-123')
|
||||||
|
page.locator('#confirmAccountAction').click()
|
||||||
|
expect(page.locator('#accountDialog')).not_to_be_visible()
|
||||||
|
assert page.locator('#resetPassword').input_value() == ''
|
||||||
|
page.locator('#usersBody tr').filter(has_text='Bob').get_by_role('button', name='Delete', exact=True).click()
|
||||||
|
expect(page.locator('#accountDialogDescription')).to_contain_text('Retained private data is not erased')
|
||||||
|
page.locator('#confirmAccountAction').click()
|
||||||
|
expect(page.locator('#usersBody')).not_to_contain_text('Bob')
|
||||||
|
writes = [(method, path, body) for method, path, body in state['calls'] if method != 'GET']
|
||||||
|
assert writes == [
|
||||||
|
('POST', '/api/admin/users/bob/retry', {}),
|
||||||
|
('POST', '/api/admin/users', {'username': 'charlie', 'display_name': 'Charlie', 'password': 'fictional-password-123', 'role': 'user'}),
|
||||||
|
('POST', '/api/admin/users/bob/password', {'password': 'replacement-password-123'}),
|
||||||
|
('DELETE', '/api/admin/users/bob', {}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_downgrade_and_logout_remove_privileged_navigation(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
open_section(page, 'admin')
|
||||||
|
state['user'] = dict(state['user'], role='user')
|
||||||
|
# Poll through the actual auth owner instead of replacing its session state.
|
||||||
|
page.clock.fast_forward(11000)
|
||||||
|
expect(page.locator('[data-settings-target="admin"]')).to_be_hidden()
|
||||||
|
expect(page.locator('[data-settings-panel="account"]')).to_be_visible()
|
||||||
|
expect(page.locator('#settingsAccountRole')).to_have_text('User')
|
||||||
|
page.locator('#logoutBtn').click()
|
||||||
|
expect(page.locator('#settingsDialog')).not_to_be_visible()
|
||||||
|
expect(page.locator('#settingsAccountName')).to_have_text('')
|
||||||
|
before = len([call for call in state['calls'] if call[1] == '/api/admin/users'])
|
||||||
|
page.evaluate("HopeSettings.open('admin')")
|
||||||
|
assert not page.evaluate('HopeSettings.isOpen')
|
||||||
|
assert len([call for call in state['calls'] if call[1] == '/api/admin/users']) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_appearance_revision_save_failure_and_memory_real_controls(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
open_section(page, 'appearance')
|
||||||
|
expect(page.locator('#preferencesMessage')).not_to_contain_text('Loading')
|
||||||
|
page.locator('#paletteSelect').select_option('ocean')
|
||||||
|
page.locator('#preferencesForm button[type="submit"], #preferencesForm button:not([type])').last.click()
|
||||||
|
expect(page.locator('html')).to_have_attribute('data-palette', 'ocean')
|
||||||
|
assert state['preferences']['revision'] == 2
|
||||||
|
state['appearance_error'] = True
|
||||||
|
open_section(page, 'appearance')
|
||||||
|
expect(page.locator('#preferencesMessage')).to_contain_text('Appearance unavailable')
|
||||||
|
open_section(page, 'privacy')
|
||||||
|
page.locator('[data-settings-panel="privacy"] summary').filter(has_text='Memory').click()
|
||||||
|
textarea = page.get_by_label('Assistant memory', exact=True)
|
||||||
|
expect(textarea).to_have_value('Saved facts')
|
||||||
|
textarea.fill('Revised private facts')
|
||||||
|
page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||||
|
expect(page.locator('#featureStatus')).to_contain_text('saved')
|
||||||
|
assert ('PUT', '/api/memory/memory', {'content': 'Revised private facts', 'revision': 'm1'}) in state['calls']
|
||||||
|
state['memory'] = False
|
||||||
|
page.evaluate('HopeFeatures.refresh()')
|
||||||
|
expect(page.locator('#settingsMemoryStatus')).to_contain_text('unavailable')
|
||||||
|
page.locator('#settingsClose').click()
|
||||||
|
assert page.locator('[data-settings-panel="privacy"] #featureStatus').count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_preferences_preserve_active_voice_and_model_owner(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
page.evaluate("window.voiceEvents=[];window.addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
||||||
|
open_section(page, 'preferences')
|
||||||
|
expect(page.locator('#modelSelect')).to_be_enabled()
|
||||||
|
page.locator('#thinkingLevelSelect').select_option('high')
|
||||||
|
page.locator('#saveModelSettings').click()
|
||||||
|
expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved')
|
||||||
|
page.locator('#settingsClose').click()
|
||||||
|
assert page.evaluate('voiceEvents') == [{'active': True}]
|
||||||
|
assert not any(path == '/token' for _, path, _ in state['calls'])
|
||||||
|
assert ('PUT', '/api/settings/model', {'model': 'auto', 'thinking_level': 'high'}) in state['calls']
|
||||||
@@ -39,7 +39,9 @@ def test_english_voice_search_selection_and_retry():
|
|||||||
page.route('https://hope.test/**', route)
|
page.route('https://hope.test/**', route)
|
||||||
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
|
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
|
||||||
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
page.locator('#settingsBtn').click()
|
page.locator('#drawerOpen').click()
|
||||||
|
page.locator('#accountMenuBtn').click()
|
||||||
|
page.locator('[data-settings-target="preferences"]').click()
|
||||||
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
|
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
|
||||||
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
|
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
|
||||||
page.locator('#voiceSearch').fill('en-GB')
|
page.locator('#voiceSearch').fill('en-GB')
|
||||||
@@ -51,9 +53,9 @@ def test_english_voice_search_selection_and_retry():
|
|||||||
page.locator('#voiceSearch').fill('')
|
page.locator('#voiceSearch').fill('')
|
||||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
page.screenshot(path='/tmp/hope-english-voices-mobile.png', full_page=True)
|
page.screenshot(path='/tmp/hope-english-voices-mobile.png', full_page=True)
|
||||||
page.locator('#closeSheetBtn').click()
|
page.locator('#settingsClose').click()
|
||||||
available = False
|
available = False
|
||||||
page.locator('#settingsBtn').click()
|
page.evaluate("HopeSettings.open('preferences')")
|
||||||
expect(page.locator('#retryVoices')).to_be_visible()
|
expect(page.locator('#retryVoices')).to_be_visible()
|
||||||
expect(page.locator('#voiceList [role=radio]')).to_have_count(3)
|
expect(page.locator('#voiceList [role=radio]')).to_have_count(3)
|
||||||
available = True
|
available = True
|
||||||
@@ -61,3 +63,72 @@ def test_english_voice_search_selection_and_retry():
|
|||||||
expect(page.locator('#retryVoices')).to_be_hidden()
|
expect(page.locator('#retryVoices')).to_be_hidden()
|
||||||
assert errors == []
|
assert errors == []
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Synthetic transport tests browser wiring only, not actual WebRTC/audio quality.
|
||||||
|
VOICE_SDK = r"""
|
||||||
|
(() => {
|
||||||
|
const events=['ParticipantConnected','ParticipantDisconnected','TrackSubscribed','TrackUnsubscribed','AudioPlaybackStatusChanged','DataReceived','ConnectionQualityChanged','Disconnected','TrackMuted','TrackUnmuted','LocalTrackUnpublished'];
|
||||||
|
class Room {
|
||||||
|
constructor(){this.handlers={};this.state='disconnected';this.remoteParticipants=new Map();this.localParticipant={isMicrophoneEnabled:false,getTrackPublication:()=>null,publishData:async()=>{},setMicrophoneEnabled:async enabled=>{if(enabled && window.denyMic)throw Error('Permission denied');this.localParticipant.isMicrophoneEnabled=enabled;}};window.testRoom=this;}
|
||||||
|
on(name,fn){(this.handlers[name]||=[]).push(fn);return this;}
|
||||||
|
emit(name,...args){(this.handlers[name]||[]).forEach(fn=>fn(...args));}
|
||||||
|
registerTextStreamHandler(){} async startAudio(){}
|
||||||
|
async disconnect(){this.state='disconnected';this.localParticipant.isMicrophoneEnabled=false;this.emit('Disconnected');}
|
||||||
|
async connect(){this.state='connected';const agent={identity:'agent',isAgent:true};this.remoteParticipants.set('agent',agent);setTimeout(()=>this.emit('DataReceived',new TextEncoder().encode(JSON.stringify({type:'agent_ready'})),agent,null,'conversation-control'),20);}
|
||||||
|
}
|
||||||
|
window.LivekitClient={Room,RoomEvent:Object.fromEntries(events.map(x=>[x,x])),Track:{Source:{Microphone:'microphone'},Kind:{Audio:'audio'}}};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_api_lifecycle_and_denied_permission():
|
||||||
|
account = {'id': 'alice', 'username': 'alice', 'display_name': 'Alice', 'role': 'user', 'status': 'ready'}
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
errors = []
|
||||||
|
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||||
|
def route(req):
|
||||||
|
url = req.request.url
|
||||||
|
if url.endswith('/livekit-client.umd.js'):
|
||||||
|
req.fulfill(body=VOICE_SDK, content_type='application/javascript')
|
||||||
|
elif '/api/auth/me' in url:
|
||||||
|
req.fulfill(json={'user': account})
|
||||||
|
elif url.endswith('/token'):
|
||||||
|
req.fulfill(json={'token': 'synthetic-token', 'conversation_id': 'voice-cid'})
|
||||||
|
elif url.endswith('/voices'):
|
||||||
|
req.fulfill(json={'voices': [], 'catalog_available': False})
|
||||||
|
elif '/api/settings/model' in url:
|
||||||
|
req.fulfill(json={'settings': {'model': 'auto', 'thinking_level': 'none'}, 'models': [], 'catalog_available': False})
|
||||||
|
elif '/api/' in url:
|
||||||
|
req.fulfill(json={'conversations': [], 'messages': [], 'files': [], 'data': []})
|
||||||
|
else:
|
||||||
|
asset = ROOT / 'web' / (url.split('/', 3)[-1].split('?')[0] or 'index.html')
|
||||||
|
types = {'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
|
||||||
|
if asset.is_file():
|
||||||
|
req.fulfill(body=asset.read_bytes(), content_type=types.get(asset.suffix, 'application/octet-stream'))
|
||||||
|
else:
|
||||||
|
req.fulfill(status=404)
|
||||||
|
page.route('https://hope.test/**', route)
|
||||||
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
|
page.evaluate("window.voiceEvents=[];window.boundEvents=[];addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));addEventListener('hope:voice-conversation-bound',e=>boundEvents.push(e.detail));")
|
||||||
|
assert page.evaluate('HopeVoice.state().ready') is False
|
||||||
|
page.evaluate('HopeVoice.start()')
|
||||||
|
page.wait_for_function('HopeVoice.state().ready')
|
||||||
|
state = page.evaluate('HopeVoice.state()')
|
||||||
|
assert state['connected'] and state['agent_connected'] and state['microphone_enabled']
|
||||||
|
assert state['conversation_id'] == 'voice-cid'
|
||||||
|
assert page.evaluate('boundEvents[0].conversation_id') == 'voice-cid'
|
||||||
|
page.evaluate('HopeVoice.mute()')
|
||||||
|
assert page.evaluate('HopeVoice.state().muted && !HopeVoice.state().microphone_enabled && !HopeVoice.state().ready')
|
||||||
|
page.evaluate('HopeVoice.mute()')
|
||||||
|
assert page.evaluate('HopeVoice.state().ready')
|
||||||
|
page.evaluate("dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'other-cid'}}))")
|
||||||
|
assert page.evaluate('!HopeVoice.state().active && !HopeVoice.state().connected')
|
||||||
|
assert page.evaluate('voiceEvents.at(-1).conversation_id') == 'voice-cid'
|
||||||
|
page.evaluate('window.denyMic=true;HopeVoice.start().catch(()=>{})')
|
||||||
|
page.wait_for_function("document.getElementById('status').textContent.includes('Permission denied')")
|
||||||
|
assert page.evaluate('!HopeVoice.state().active && !HopeVoice.state().ready && !HopeVoice.state().microphone_enabled')
|
||||||
|
assert errors == []
|
||||||
|
browser.close()
|
||||||
+90
-19
@@ -29,6 +29,11 @@ let userSpeaking = false; // local VAD-ish flag from mic level
|
|||||||
let wakeLock = null;
|
let wakeLock = null;
|
||||||
let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice";
|
let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice";
|
||||||
let connectionPromise = null;
|
let connectionPromise = null;
|
||||||
|
let connectionEpoch = 0;
|
||||||
|
let tokenAbort = null;
|
||||||
|
let voiceConversationId = null;
|
||||||
|
let voiceSessionActive = false;
|
||||||
|
let lastVoiceState = "";
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
@@ -86,10 +91,11 @@ function handleProgress(data) {
|
|||||||
setState(working ? "thinking" : "listening");
|
setState(working ? "thinking" : "listening");
|
||||||
}
|
}
|
||||||
function setTasksOpen(open) {
|
function setTasksOpen(open) {
|
||||||
|
const wasOpen = !tasksPanel.hidden;
|
||||||
tasksPanel.hidden = !open; tasksPanel.inert = !open;
|
tasksPanel.hidden = !open; tasksPanel.inert = !open;
|
||||||
tasksPanel.classList.toggle("open", open);
|
tasksPanel.classList.toggle("open", open);
|
||||||
tasksBtn.setAttribute("aria-expanded", String(open));
|
tasksBtn.setAttribute("aria-expanded", String(open));
|
||||||
if (open) closeTasksBtn.focus(); else tasksBtn.focus();
|
if (open) closeTasksBtn.focus(); else if (wasOpen) tasksBtn.focus();
|
||||||
}
|
}
|
||||||
tasksBtn.setAttribute("aria-expanded", "false");
|
tasksBtn.setAttribute("aria-expanded", "false");
|
||||||
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
|
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
|
||||||
@@ -133,6 +139,7 @@ async function setConversationMode(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!room || !agentReady || !room.localParticipant) return;
|
if (!room || !agentReady || !room.localParticipant) return;
|
||||||
|
const modeRoom = room;
|
||||||
sendModeMessage(mode);
|
sendModeMessage(mode);
|
||||||
if (mode === "text") {
|
if (mode === "text") {
|
||||||
await room.localParticipant.setMicrophoneEnabled(false);
|
await room.localParticipant.setMicrophoneEnabled(false);
|
||||||
@@ -148,7 +155,9 @@ async function setConversationMode(mode) {
|
|||||||
startVisualLoop();
|
startVisualLoop();
|
||||||
try {
|
try {
|
||||||
await room.startAudio();
|
await room.startAudio();
|
||||||
await room.localParticipant.setMicrophoneEnabled(true);
|
await modeRoom.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
if (modeRoom !== room) { await modeRoom.localParticipant.setMicrophoneEnabled(false); return; }
|
||||||
|
micMuted = !microphoneEnabled();
|
||||||
setupMicAnalyser();
|
setupMicAnalyser();
|
||||||
statusEl.textContent = "Secure room · Microphone on";
|
statusEl.textContent = "Secure room · Microphone on";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -160,6 +169,7 @@ async function setConversationMode(mode) {
|
|||||||
statusEl.textContent = "Voice mode · Microphone unavailable";
|
statusEl.textContent = "Voice mode · Microphone unavailable";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateMuteIcon(); setState(uiState);
|
||||||
}
|
}
|
||||||
|
|
||||||
voiceModeBtn.addEventListener("click", () => setConversationMode("voice").catch(console.error));
|
voiceModeBtn.addEventListener("click", () => setConversationMode("voice").catch(console.error));
|
||||||
@@ -243,6 +253,8 @@ function buildLedWall() {
|
|||||||
// Renders one frame of the LED wall. `level` is 0..1 (agent audio when speaking,
|
// Renders one frame of the LED wall. `level` is 0..1 (agent audio when speaking,
|
||||||
// mic when listening). `mode` picks the palette/gradient by conversation state.
|
// mic when listening). `mode` picks the palette/gradient by conversation state.
|
||||||
function renderLedWall(level, mode) {
|
function renderLedWall(level, mode) {
|
||||||
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reducedMotion) { level = 0; mode = "idle"; }
|
||||||
ledWallTime += 1;
|
ledWallTime += 1;
|
||||||
ledWallEl.dataset.mode = mode;
|
ledWallEl.dataset.mode = mode;
|
||||||
const n = ledDots.length;
|
const n = ledDots.length;
|
||||||
@@ -279,8 +291,8 @@ function renderLedWall(level, mode) {
|
|||||||
|
|
||||||
vis = Math.max(0.02, Math.min(1, vis));
|
vis = Math.max(0.02, Math.min(1, vis));
|
||||||
// Dot grows toward the center and sits on a subtle 4px grid inset.
|
// Dot grows toward the center and sits on a subtle 4px grid inset.
|
||||||
const px = (2 * size).toFixed(1);
|
|
||||||
dot.style.setProperty("--sz", `${px}px`);
|
dot.style.transform = `scale(${size.toFixed(2)})`;
|
||||||
dot.style.opacity = vis.toFixed(3);
|
dot.style.opacity = vis.toFixed(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,9 +342,13 @@ const STATE_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
|
if (["listening", "speaking"].includes(next) && conversationMode === "voice" && !microphoneEnabled()) next = voiceSessionActive ? "connecting" : "idle";
|
||||||
uiState = next;
|
uiState = next;
|
||||||
ledWallEl.dataset.state = next;
|
ledWallEl.dataset.state = next;
|
||||||
stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next;
|
publishVoiceState();
|
||||||
|
stateLabelEl.textContent = conversationMode === "voice" && voiceSessionActive && agentReady && micMuted
|
||||||
|
? "Microphone muted"
|
||||||
|
: STATE_LABELS[conversationMode][next] || next;
|
||||||
|
|
||||||
// Thinking timer: show the "thinking..." chip if it lingers past 2s
|
// Thinking timer: show the "thinking..." chip if it lingers past 2s
|
||||||
if (next === "thinking") {
|
if (next === "thinking") {
|
||||||
@@ -358,7 +374,7 @@ updateModeUI();
|
|||||||
|
|
||||||
function inferState() {
|
function inferState() {
|
||||||
if (!room || !room.localParticipant) return "idle";
|
if (!room || !room.localParticipant) return "idle";
|
||||||
if (!agentParticipant) return "connecting";
|
if (!agentParticipant || !agentReady || !microphoneEnabled()) return "connecting";
|
||||||
readMicLevel();
|
readMicLevel();
|
||||||
readAgentAudioLevel();
|
readAgentAudioLevel();
|
||||||
userSpeaking = micLevel > 0.035;
|
userSpeaking = micLevel > 0.035;
|
||||||
@@ -565,6 +581,7 @@ function handleDataPacket(payload, participant, topic) {
|
|||||||
if (!HopeAuth.user || !participant || !isAgent(participant)) return;
|
if (!HopeAuth.user || !participant || !isAgent(participant)) return;
|
||||||
if (msg.type === "agent_ready" && topic === "conversation-control") {
|
if (msg.type === "agent_ready" && topic === "conversation-control") {
|
||||||
agentReady = true;
|
agentReady = true;
|
||||||
|
publishVoiceState();
|
||||||
sendVoiceMessage(currentVoiceId());
|
sendVoiceMessage(currentVoiceId());
|
||||||
} else if (msg.type === "transcript" && topic === "transcript") {
|
} else if (msg.type === "transcript" && topic === "transcript") {
|
||||||
if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return;
|
if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return;
|
||||||
@@ -833,6 +850,7 @@ function openSheet() {
|
|||||||
if (!HopeAuth.user) return;
|
if (!HopeAuth.user) return;
|
||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
loadVoiceCatalog();
|
loadVoiceCatalog();
|
||||||
|
if (window.HopeSettings) { window.HopeSettings.open("preferences"); return; }
|
||||||
settingsSheet.hidden = false; settingsSheet.inert = false;
|
settingsSheet.hidden = false; settingsSheet.inert = false;
|
||||||
document.getElementById("authenticatedApp").inert = true;
|
document.getElementById("authenticatedApp").inert = true;
|
||||||
sheetBackdrop.hidden = false;
|
sheetBackdrop.hidden = false;
|
||||||
@@ -844,6 +862,7 @@ function openSheet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSheet() {
|
function closeSheet() {
|
||||||
|
if (window.HopeSettings) { window.HopeSettings.close(); return; }
|
||||||
settingsSheet.classList.remove("open");
|
settingsSheet.classList.remove("open");
|
||||||
settingsSheet.hidden = true; settingsSheet.inert = true;
|
settingsSheet.hidden = true; settingsSheet.inert = true;
|
||||||
document.getElementById("authenticatedApp").inert = false;
|
document.getElementById("authenticatedApp").inert = false;
|
||||||
@@ -914,6 +933,7 @@ function findAgent(rm) {
|
|||||||
function markAgentFound(rm, participant) {
|
function markAgentFound(rm, participant) {
|
||||||
if (rm !== room || !HopeAuth.user) return;
|
if (rm !== room || !HopeAuth.user) return;
|
||||||
agentParticipant = participant;
|
agentParticipant = participant;
|
||||||
|
publishVoiceState();
|
||||||
statusEl.textContent = conversationMode === "text"
|
statusEl.textContent = conversationMode === "text"
|
||||||
? "Hope joined · Text chat ready"
|
? "Hope joined · Text chat ready"
|
||||||
: "Hope joined · Preparing microphone…";
|
: "Hope joined · Preparing microphone…";
|
||||||
@@ -934,6 +954,14 @@ async function createRoom(token, attempt) {
|
|||||||
if (newRoom === room && isAgent(participant)) markAgentFound(newRoom, participant);
|
if (newRoom === room && isAgent(participant)) markAgentFound(newRoom, participant);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
newRoom.on(RoomEvent.ParticipantDisconnected, participant => {
|
||||||
|
if (newRoom === room && participant === agentParticipant) {
|
||||||
|
stopConversation(); statusEl.textContent = "Hope disconnected. Start again to reconnect.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
newRoom.on(RoomEvent.TrackMuted, () => { if (newRoom === room) { micMuted = !microphoneEnabled(); publishVoiceState(); } });
|
||||||
|
newRoom.on(RoomEvent.TrackUnmuted, () => { if (newRoom === room) { micMuted = !microphoneEnabled(); publishVoiceState(); } });
|
||||||
|
newRoom.on(RoomEvent.LocalTrackUnpublished, () => { if (newRoom === room) { micMuted = !microphoneEnabled(); updateMuteIcon(); } });
|
||||||
// Attach and play incoming audio tracks (agent TTS)
|
// Attach and play incoming audio tracks (agent TTS)
|
||||||
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||||
if (newRoom === room && isAgent(participant) && track.kind === LivekitClient.Track.Kind.Audio) {
|
if (newRoom === room && isAgent(participant) && track.kind === LivekitClient.Track.Kind.Audio) {
|
||||||
@@ -992,13 +1020,37 @@ async function createRoom(token, attempt) {
|
|||||||
});
|
});
|
||||||
await newRoom.connect(LIVEKIT_URL, token, { autoSubscribe: true });
|
await newRoom.connect(LIVEKIT_URL, token, { autoSubscribe: true });
|
||||||
checkAttempt(attempt);
|
checkAttempt(attempt);
|
||||||
|
publishVoiceState();
|
||||||
const existing = findAgent(newRoom);
|
const existing = findAgent(newRoom);
|
||||||
if (existing) markAgentFound(newRoom, existing);
|
if (existing) markAgentFound(newRoom, existing);
|
||||||
return newRoom;
|
return newRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
let connectionEpoch = 0;
|
function microphoneEnabled() {
|
||||||
let tokenAbort = null;
|
const local = room?.localParticipant;
|
||||||
|
if (!local || !local.isMicrophoneEnabled) return false;
|
||||||
|
const track = local.getTrackPublication?.(LivekitClient.Track.Source.Microphone)?.track?.mediaStreamTrack;
|
||||||
|
return !track || (track.enabled && track.readyState !== "ended");
|
||||||
|
}
|
||||||
|
function voiceState() {
|
||||||
|
const connected = !!room && room.state === "connected";
|
||||||
|
const microphone_enabled = connected && microphoneEnabled();
|
||||||
|
const agent_connected = connected && !!agentParticipant;
|
||||||
|
const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
|
||||||
|
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
|
||||||
|
ready, muted: micMuted, connected, agent_connected, microphone_enabled,
|
||||||
|
conversation_id: voiceConversationId, cid: voiceConversationId };
|
||||||
|
}
|
||||||
|
function publishVoiceState() {
|
||||||
|
const detail = voiceState();
|
||||||
|
const signature = JSON.stringify(detail);
|
||||||
|
if (signature === lastVoiceState) return;
|
||||||
|
lastVoiceState = signature;
|
||||||
|
window.dispatchEvent(new CustomEvent('hope:voice-state', { detail }));
|
||||||
|
}
|
||||||
|
window.addEventListener('hope:conversation-selected', event => {
|
||||||
|
if ((room || connectionPromise) && event.detail?.conversation_id !== voiceConversationId) stopConversation();
|
||||||
|
});
|
||||||
function checkAttempt(attempt) {
|
function checkAttempt(attempt) {
|
||||||
if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
|
if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1073,7 @@ function cleanupRoom() {
|
|||||||
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
||||||
document.getElementById("audioUnlockBtn")?.remove();
|
document.getElementById("audioUnlockBtn")?.remove();
|
||||||
muteBtn.disabled = true; micMuted = false; updateMuteIcon();
|
muteBtn.disabled = true; micMuted = false; updateMuteIcon();
|
||||||
|
voiceSessionActive = false; publishVoiceState();
|
||||||
}
|
}
|
||||||
function stopConversation() {
|
function stopConversation() {
|
||||||
connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
|
connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
|
||||||
@@ -1034,6 +1087,9 @@ async function connectConversation() {
|
|||||||
if (connectionPromise) return connectionPromise;
|
if (connectionPromise) return connectionPromise;
|
||||||
if (room && agentReady) return room;
|
if (room && agentReady) return room;
|
||||||
const attempt = ++connectionEpoch;
|
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;
|
||||||
|
voiceSessionActive = true; publishVoiceState();
|
||||||
startBtn.disabled = true; stopBtn.disabled = false;
|
startBtn.disabled = true; stopBtn.disabled = false;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1068,9 +1124,16 @@ async function connectConversation() {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
startBtn.addEventListener("click", () => {
|
async function startVoice() {
|
||||||
connectConversation().catch(() => {});
|
if (window.HopeChat?.running) throw new Error("Wait for the current response or stop it before starting voice.");
|
||||||
});
|
await setConversationMode("voice");
|
||||||
|
return connectConversation();
|
||||||
|
}
|
||||||
|
window.HopeVoiceSettings = Object.freeze({ load: () => { renderVoiceList(); return Promise.all([loadVoiceCatalog(), loadModelSettings()]); } });
|
||||||
|
window.HopeVoice = Object.freeze({ start: startVoice, stop: stopConversation, mute: toggleMute, state: voiceState,
|
||||||
|
loadPreferences: () => Promise.all([loadVoiceCatalog(), loadModelSettings()]) });
|
||||||
|
window.addEventListener("hope:start-voice", () => startVoice().catch(error => { statusEl.textContent = error.message; }));
|
||||||
|
startBtn.addEventListener("click", () => startVoice().catch(error => { statusEl.textContent = error.message; }));
|
||||||
|
|
||||||
// ── Text chat ──────────────────────────────────────────────────────────────
|
// ── Text chat ──────────────────────────────────────────────────────────────
|
||||||
function resizeChatInput() {
|
function resizeChatInput() {
|
||||||
@@ -1119,20 +1182,25 @@ chatComposer.addEventListener("submit", async (event) => {
|
|||||||
stopBtn.addEventListener("click", stopConversation);
|
stopBtn.addEventListener("click", stopConversation);
|
||||||
|
|
||||||
// ── Mic mute toggle ─────────────────────────────────────────────────────────
|
// ── Mic mute toggle ─────────────────────────────────────────────────────────
|
||||||
muteBtn.addEventListener("click", async () => {
|
async function toggleMute() {
|
||||||
if (!room || !room.localParticipant) return;
|
if (!room || !agentReady || !room.localParticipant || conversationMode !== "voice") return;
|
||||||
const nextMuted = !micMuted;
|
const active = room;
|
||||||
|
const nextMuted = microphoneEnabled();
|
||||||
try {
|
try {
|
||||||
await room.localParticipant.setMicrophoneEnabled(!nextMuted);
|
await active.localParticipant.setMicrophoneEnabled(!nextMuted);
|
||||||
micMuted = nextMuted;
|
if (active !== room) return;
|
||||||
|
micMuted = !microphoneEnabled();
|
||||||
updateMuteIcon();
|
updateMuteIcon();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Microphone toggle failed:", err);
|
console.warn("Microphone toggle failed:", err);
|
||||||
|
micMuted = !microphoneEnabled(); updateMuteIcon();
|
||||||
statusEl.textContent = "Microphone unavailable in this browser";
|
statusEl.textContent = "Microphone unavailable in this browser";
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
muteBtn.addEventListener("click", toggleMute);
|
||||||
|
|
||||||
function updateMuteIcon() {
|
function updateMuteIcon() {
|
||||||
|
publishVoiceState();
|
||||||
const muted = muteBtn.classList.toggle("muted", micMuted);
|
const muted = muteBtn.classList.toggle("muted", micMuted);
|
||||||
muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
|
muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
|
||||||
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
|
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
|
||||||
@@ -1142,8 +1210,11 @@ function updateMuteIcon() {
|
|||||||
async function fetchToken(attempt) {
|
async function fetchToken(attempt) {
|
||||||
checkAttempt(attempt);
|
checkAttempt(attempt);
|
||||||
tokenAbort = new AbortController();
|
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);
|
checkAttempt(attempt);
|
||||||
|
voiceConversationId = data.conversation_id || voiceConversationId;
|
||||||
|
if (voiceConversationId) window.dispatchEvent(new CustomEvent('hope:voice-conversation-bound', { detail: { conversation_id: voiceConversationId } }));
|
||||||
|
publishVoiceState();
|
||||||
if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
|
if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -1170,7 +1241,7 @@ function sendModeMessage(mode, targetRoom) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HopeAuth.onChange((user, changed) => {
|
HopeAuth.onChange((user, changed) => {
|
||||||
if (changed || !user || user.status !== "ready") stopConversation();
|
if (changed || !user || user.status !== "ready") { stopConversation(); voiceConversationId = null; publishVoiceState(); }
|
||||||
if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user);
|
if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user);
|
||||||
if (changed) {
|
if (changed) {
|
||||||
voiceCatalogGeneration++;
|
voiceCatalogGeneration++;
|
||||||
|
|||||||
+14
-1
@@ -56,7 +56,20 @@
|
|||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error));
|
row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error));
|
||||||
const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions';
|
const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions';
|
||||||
actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete'))); row.append(actions); el('usersBody').append(row);
|
if (account.status === 'error') {
|
||||||
|
let retrying = false;
|
||||||
|
const retry = action('Retry provisioning', async () => {
|
||||||
|
if (retrying || user?.role !== 'admin') return;
|
||||||
|
retrying = true; retry.disabled = true; const retryEpoch = epoch;
|
||||||
|
try { await request(`/api/admin/users/${encodeURIComponent(account.id)}/retry`, {method: 'POST'}); if (retryEpoch === epoch) await refreshUsers(); }
|
||||||
|
catch (error) { if (retryEpoch === epoch) say('adminMessage', error.message); }
|
||||||
|
finally { retrying = false; retry.disabled = false; }
|
||||||
|
});
|
||||||
|
actions.append(retry);
|
||||||
|
}
|
||||||
|
actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete')));
|
||||||
|
Array.from(row.children).forEach((td, index) => td.dataset.label = ['Account', 'Role', 'Status', 'Private assistant', 'Error'][index]);
|
||||||
|
actions.dataset.label = 'Actions'; row.append(actions); el('usersBody').append(row);
|
||||||
}
|
}
|
||||||
say('adminMessage', 'Account and container status refresh automatically.');
|
say('adminMessage', 'Account and container status refresh automatically.');
|
||||||
} catch (error) { say('adminMessage', error.message); } finally { refreshing = false; }
|
} catch (error) { say('adminMessage', error.message); } finally { refreshing = false; }
|
||||||
|
|||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
// Durable HTTP chat. Deliberately independent of the LiveKit composer.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal});
|
||||||
|
const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null};
|
||||||
|
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false;
|
||||||
|
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
|
||||||
|
const notice = text => { $('chatNotice').textContent = text; };
|
||||||
|
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; };
|
||||||
|
function controls() {
|
||||||
|
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
|
||||||
|
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
|
||||||
|
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
|
||||||
|
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
|
||||||
|
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run);
|
||||||
|
$('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation;
|
||||||
|
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
||||||
|
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
||||||
|
$('chatTitle').textContent = state.conversation?.title || 'New conversation';
|
||||||
|
}
|
||||||
|
function selected(conversation) {
|
||||||
|
state.conversation = conversation;
|
||||||
|
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
||||||
|
controls();
|
||||||
|
}
|
||||||
|
window.addEventListener('hope:voice-state', event => {const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else {if (wasActive) notice(''); if (wasActive && HopeAuth.user && state.conversation) refreshSelected().then(() => {notice('Voice conversation saved.'); list();}).catch(error => notice(error.message));}});
|
||||||
|
window.addEventListener('hope:attachments-changed', controls);
|
||||||
|
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
||||||
|
function stopStream() { state.stream?.abort(); state.stream = null; }
|
||||||
|
function drawer(open) {
|
||||||
|
$('chatSidebar').classList.toggle('drawer-open', open); $('drawerBackdrop').hidden = !open;
|
||||||
|
$('drawerOpen').setAttribute('aria-expanded', String(open));
|
||||||
|
if (matchMedia('(max-width: 760px)').matches) { $('primaryChat').inert = open; $('legacyVoice').inert = open; $('chatSidebar').inert = !open; }
|
||||||
|
(open ? $('drawerClose') : $('drawerOpen')).focus();
|
||||||
|
}
|
||||||
|
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
|
||||||
|
document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
|
||||||
|
const mobile = matchMedia('(max-width: 760px)');
|
||||||
|
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
|
||||||
|
mobile.addEventListener('change', adapt); adapt();
|
||||||
|
async function startVoice() {
|
||||||
|
if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; }
|
||||||
|
if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
|
||||||
|
const epoch = state.epoch; state.busy = true; controls();
|
||||||
|
try {
|
||||||
|
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: 'Voice conversation', ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation); await list();}
|
||||||
|
if (epoch !== state.epoch) return;
|
||||||
|
$('legacyVoice').hidden = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}}));
|
||||||
|
} catch (error) {if (epoch === state.epoch) notice(error.message);} finally {if (epoch === state.epoch) {state.busy = false; controls();}}
|
||||||
|
}
|
||||||
|
$('talkToHope').onclick = startVoice;
|
||||||
|
$('openVoice').onclick = startVoice;
|
||||||
|
$('backToChat').onclick = () => $('primaryInput').focus();
|
||||||
|
document.querySelectorAll('.lightwell-symbol').forEach(el => {for (let i=0;i<25;i++) el.append(document.createElement('i'));});
|
||||||
|
$('openLibrary').onclick = () => {if (mobile.matches) drawer(false); $('libraryDialog').showModal();};
|
||||||
|
$('closeLibrary').onclick = () => $('libraryDialog').close();
|
||||||
|
// Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links.
|
||||||
|
function inline(parent, text) {
|
||||||
|
const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g;
|
||||||
|
let last = 0;
|
||||||
|
for (const match of text.matchAll(expression)) {
|
||||||
|
parent.append(document.createTextNode(text.slice(last, match.index))); const token = match[0]; let node;
|
||||||
|
if (token.startsWith('`')) {node = document.createElement('code'); node.textContent = token.slice(1, -1);}
|
||||||
|
else if (token.startsWith('**')) {node = document.createElement('strong'); node.textContent = token.slice(2, -2);}
|
||||||
|
else if (token.startsWith('*')) {node = document.createElement('em'); node.textContent = token.slice(1, -1);}
|
||||||
|
else {const parts = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); let url; try {url = new URL(parts[2]);} catch {} if (url && ['https:', 'http:', 'mailto:'].includes(url.protocol)) {node = document.createElement('a'); node.href = url.href; node.rel = 'noopener noreferrer'; node.textContent = parts[1];} else node = document.createTextNode(token);}
|
||||||
|
parent.append(node); last = match.index + token.length;
|
||||||
|
}
|
||||||
|
parent.append(document.createTextNode(text.slice(last)));
|
||||||
|
}
|
||||||
|
function markdown(text) {
|
||||||
|
const root = document.createElement('div'); root.className = 'markdown'; let code = null, list = null;
|
||||||
|
const lines = String(text || '').split('\n');
|
||||||
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex];
|
||||||
|
if (line.startsWith('```')) { if (code) {code = null;} else {const pre = document.createElement('pre'); code = document.createElement('code'); pre.append(code); root.append(pre);} list = null; continue; }
|
||||||
|
if (code) {code.append(document.createTextNode(line + '\n')); continue;}
|
||||||
|
if (line.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[lineIndex + 1] || '')) {const table = document.createElement('table'), head = document.createElement('thead'), body = document.createElement('tbody'), wrap = document.createElement('div'); wrap.className = 'markdown-table'; const cells = value => value.trim().replace(/^\||\|$/g, '').split('|'); const addRow = (value, parent, header) => {const row = document.createElement('tr'); for (const cell of cells(value)) {const node = document.createElement(header ? 'th' : 'td'); if (header) node.scope = 'col'; inline(node, cell.trim()); row.append(node);} parent.append(row);}; addRow(line, head, true); lineIndex += 1; while (lines[lineIndex + 1]?.includes('|') && lines[lineIndex + 1].trim()) {lineIndex++; addRow(lines[lineIndex], body, false);} table.append(head, body); wrap.append(table); root.append(wrap); list = null; continue;}
|
||||||
|
const item = /^(?:[-*] |\d+\. )(.*)$/.exec(line);
|
||||||
|
if (item) {const type = /^\d/.test(line) ? 'ol' : 'ul'; if (!list || list.tagName.toLowerCase() !== type) {list = document.createElement(type); root.append(list);} const li = document.createElement('li'); inline(li, item[1]); list.append(li); continue;}
|
||||||
|
list = null; if (!line.trim()) continue;
|
||||||
|
const heading = /^(#{1,6}) (.*)$/.exec(line); const node = document.createElement(heading ? 'h' + Math.min(heading[1].length + 1, 6) : line.startsWith('> ') ? 'blockquote' : 'p'); inline(node, heading ? heading[2] : line.replace(/^> /, '')); root.append(node);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop;
|
||||||
|
timeline.replaceChildren();
|
||||||
|
if (state.messageCursor) timeline.append(button('Load earlier messages', olderMessages));
|
||||||
|
if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const mark = document.createElement('span'); mark.className = 'hope-mark'; mark.setAttribute('aria-hidden','true'); const h = document.createElement('h2'); h.textContent = 'A little space for you.'; const p = document.createElement('p'); p.textContent = 'Think out loud, work something through, or make something new. You can type, or talk to Hope.'; welcome.append(mark, h, p); timeline.append(welcome); }
|
||||||
|
for (const message of state.messages) {
|
||||||
|
const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id;
|
||||||
|
const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; row.append(name, markdown(message.content));
|
||||||
|
const actions = document.createElement('div'); actions.className = 'message-actions'; actions.setAttribute('aria-label', (message.role === 'user' ? 'Your message' : 'Hope response') + ' actions');
|
||||||
|
actions.append(button('Copy', () => navigator.clipboard.writeText(message.content)));
|
||||||
|
if (terminal(state.run)) {
|
||||||
|
if (message.role === 'user') actions.append(button('Edit in new branch', () => edit(message)));
|
||||||
|
else {actions.append(button('Branch from here', () => branch(message))); const index = state.messages.indexOf(message); const prior = state.messages.slice(0, index).reverse().find(m => m.role === 'user'); if (prior) actions.append(button('Regenerate', () => branch(prior, prior.content, true)));}
|
||||||
|
}
|
||||||
|
if (['failed', 'cancelled'].includes(message.status)) {const status = document.createElement('span'); status.textContent = message.status === 'failed' ? 'Response failed' : 'Response stopped'; actions.append(status);}
|
||||||
|
row.append(actions); timeline.append(row);
|
||||||
|
}
|
||||||
|
timeline.scrollTop = atBottom ? timeline.scrollHeight : scroll; controls();
|
||||||
|
}
|
||||||
|
async function list(append = false) {
|
||||||
|
const version = ++listVersion, epoch = state.epoch;
|
||||||
|
const params = new URLSearchParams({q: $('conversationSearch').value, archived: String(state.archived), limit: '50'});
|
||||||
|
if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor);
|
||||||
|
const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return;
|
||||||
|
if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); }
|
||||||
|
for (const conversation of data.conversations) {const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false'); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(b);}
|
||||||
|
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
|
||||||
|
}
|
||||||
|
async function open(id) {
|
||||||
|
if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;}
|
||||||
|
stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls();
|
||||||
|
const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return;
|
||||||
|
selected(data.conversation); state.messages = data.messages || []; state.messageCursor = data.next_cursor; state.run = (data.runs || []).find(r => !terminal(r)) || null; render(); notice(''); if (mobile.matches) drawer(false); if (state.run) stream(state.run); await list();
|
||||||
|
}
|
||||||
|
async function olderMessages() {const epoch = state.epoch, timeline = $('chatTimeline'), height = timeline.scrollHeight; const data = await api('/api/conversations/' + enc(state.conversation.id) + '?' + new URLSearchParams({cursor: state.messageCursor, limit: '50'})); if (epoch !== state.epoch) return; const ids = new Set(state.messages.map(m => m.id)); state.messages = (data.messages || []).filter(m => !ids.has(m.id)).concat(state.messages); state.messageCursor = data.next_cursor; render(); timeline.scrollTop += timeline.scrollHeight - height;}
|
||||||
|
function reset() { stopStream(); ++state.epoch; state.messages = []; state.messageCursor = null; state.run = null; state.pending = null; state.busy = false; selected(null); render(); notice(''); $('primaryInput').value = ''; controls(); }
|
||||||
|
$('newConversation').onclick = () => {if (state.voiceActive) {notice('End voice before starting a new conversation.'); return;} reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();};
|
||||||
|
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
|
||||||
|
$('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));};
|
||||||
|
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
|
||||||
|
async function update(patch) {const epoch = state.epoch; let data; try {data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch});} catch (error) {await refreshSelected(false); await list(); throw new Error(error.message + ' Conversation refreshed. Review the latest version and try again.');} if (epoch !== state.epoch) return; selected(data.conversation); await list();}
|
||||||
|
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
|
||||||
|
function dialog(title, value, fn, description = '', needsValue = true) {
|
||||||
|
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
|
||||||
|
}
|
||||||
|
$('chatActionCancel').onclick = () => $('chatActionDialog').close();
|
||||||
|
$('chatActionForm').onsubmit = async event => {event.preventDefault(); $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); $('chatActionDialog').close();} catch (e) {$('chatActionError').textContent = e.message;} finally {$('chatActionSave').disabled = false;} };
|
||||||
|
$('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title}));
|
||||||
|
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', async () => {await update({archived: !state.conversation.archived}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false);
|
||||||
|
async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');}
|
||||||
|
$('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message));
|
||||||
|
$('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = false;};
|
||||||
|
$('deleteConversation').onclick = () => dialog('Delete conversation?', '', async () => {await api('/api/conversations/' + enc(state.conversation.id), 'DELETE'); reset(); await list();}, 'This deletes the conversation, not your account memory.', false);
|
||||||
|
async function branch(message, content, send = false) {const data = await api('/api/conversations/' + enc(state.conversation.id) + '/branch', 'POST', {message_id: message.id, ...(content === undefined ? {} : {content})}); await open(data.conversation.id); if (send) {$('primaryInput').value = content; await submit();} }
|
||||||
|
function edit(message) {dialog('Edit in a new branch', message.content, content => branch(message, content, true), 'Your original conversation stays unchanged.');}
|
||||||
|
function upsert(message) {const index = state.messages.findIndex(m => m.id === message.id); if (index < 0) state.messages.push(message); else state.messages[index] = message;}
|
||||||
|
async function submit() {
|
||||||
|
const content = $('primaryInput').value.trim(); if (!content || window.HopeFeatures?.hasPendingUploads() || state.voiceActive || state.busy || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
|
||||||
|
state.busy = true; controls(); const epoch = state.epoch;
|
||||||
|
try {
|
||||||
|
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: content.slice(0, 80), ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation);}
|
||||||
|
// Retry an uncertain POST with the same key rather than creating another turn.
|
||||||
|
const attachmentIDs = window.HopeFeatures?.attachmentIDs() || [], mode = state.research ? $('researchMode').value : 'normal';
|
||||||
|
if (!state.pending || state.pending.content !== content || state.pending.mode !== mode || JSON.stringify(state.pending.attachment_ids) !== JSON.stringify(attachmentIDs)) state.pending = {content, mode, idempotency_key: crypto.randomUUID(), attachment_ids: attachmentIDs};
|
||||||
|
const data = await api('/api/conversations/' + enc(state.conversation.id) + '/messages', 'POST', state.pending); if (epoch !== state.epoch) return;
|
||||||
|
window.HopeFeatures?.clearAfterSend(state.pending.attachment_ids); state.pending = null; $('primaryInput').value = ''; upsert(data.message); state.run = data.run; render(); notice(''); stream(data.run); await list();
|
||||||
|
} catch (e) {if (epoch === state.epoch) notice(e.message + ' You can retry your message.');} finally {state.busy = false; controls();}
|
||||||
|
}
|
||||||
|
$('primaryComposer').onsubmit = event => {event.preventDefault(); submit();}; $('primaryInput').oninput = controls;
|
||||||
|
$('primaryInput').onkeydown = event => {if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {event.preventDefault(); submit();}};
|
||||||
|
async function stream(run) {
|
||||||
|
stopStream(); const controller = new AbortController(); state.stream = controller; const epoch = state.epoch; let after = 0, retries = 0;
|
||||||
|
try {
|
||||||
|
while (!controller.signal.aborted && epoch === state.epoch) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/runs/' + enc(run.id) + '/events?after=' + after, {credentials: 'same-origin', signal: controller.signal, headers: {Accept: 'text/event-stream'}});
|
||||||
|
if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Response stream unavailable (' + response.status + ').');
|
||||||
|
const reader = response.body.getReader(), decoder = new TextDecoder(); let buffer = '', done = false;
|
||||||
|
while (!done) {const part = await reader.read(); buffer += decoder.decode(part.value || new Uint8Array(), {stream: !part.done}).replace(/\r\n/g, '\n'); let boundary;
|
||||||
|
while ((boundary = buffer.indexOf('\n\n')) >= 0) {const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); let type = 'message', id = 0, raw = [];
|
||||||
|
for (const line of block.split('\n')) {if (line.startsWith('event:')) type = line.slice(6).trim(); else if (line.startsWith('id:')) id = Number(line.slice(3)); else if (line.startsWith('data:')) raw.push(line.slice(5).trimStart());}
|
||||||
|
if (!raw.length || (id && id <= after)) continue; const data = JSON.parse(raw.join('\n')); if (controller.signal.aborted || epoch !== state.epoch) return; if (id) after = id;
|
||||||
|
if (type === 'message') {upsert(data.message || data); render();}
|
||||||
|
if (type === 'run' || type === 'done') {state.run = data.run || data; controls();}
|
||||||
|
if (type === 'progress') $('runProgress').textContent = data.message || '';
|
||||||
|
if (type === 'approval') approval(data, run.id);
|
||||||
|
if (type === 'done') {notice(state.run?.status === 'cancelled' ? 'Response stopped.' : state.run?.status === 'failed' ? 'Response failed.' : 'Response ready.'); done = true; await reader.cancel(); break;}
|
||||||
|
}
|
||||||
|
if (part.done) done = true;
|
||||||
|
}
|
||||||
|
if (terminal(state.run)) break;
|
||||||
|
retries = 0;
|
||||||
|
} catch (error) {if (controller.signal.aborted) return; if (++retries >= 3) {notice('Connection interrupted. Reopen this conversation to resume the response.'); break;}}
|
||||||
|
await new Promise(resolve => {const timer = setTimeout(resolve, 1000); controller.signal.addEventListener('abort', () => {clearTimeout(timer); resolve();}, {once: true});});
|
||||||
|
}
|
||||||
|
} finally {if (state.stream === controller) {if (terminal(state.run) && epoch === state.epoch && !controller.signal.aborted) {try {await refreshSelected(false); await list();} catch (error) {if (epoch === state.epoch) notice('Response saved, but conversation details could not refresh. Reopen the conversation before changing it.');}} if (state.stream !== controller || epoch !== state.epoch) return; state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}}
|
||||||
|
}
|
||||||
|
function approval(data, runId) {const area = $('runProgress'); area.replaceChildren(); const text = document.createElement('p'); text.textContent = data.prompt || 'Hope needs your permission to continue.'; area.append(text); for (const decision of ['approve', 'deny']) area.append(button(decision === 'approve' ? 'Approve' : 'Deny', async () => {const result = await api('/api/runs/' + enc(runId) + '/approval', 'POST', {approval_id: data.approval_id, decision}); state.run = result.run; area.replaceChildren(); controls();}));}
|
||||||
|
$('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} };
|
||||||
|
function workspaceRows() {
|
||||||
|
$('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link'));
|
||||||
|
for (const workspace of state.workspaces) {
|
||||||
|
const row = document.createElement('div'); row.className = 'workspace-row';
|
||||||
|
row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'));
|
||||||
|
const menu = document.createElement('details'), summary = document.createElement('summary'), tools = document.createElement('div'); menu.className = 'workspace-menu'; summary.textContent = '•••'; summary.setAttribute('aria-label', workspace.name + ' workspace actions'); tools.className = 'workspace-tools';
|
||||||
|
tools.append(button('Instructions', () => {menu.open = false; workspaceInstructions(workspace);}), button('Rename', () => {menu.open = false; dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();});}), button('Remove', () => {menu.open = false; dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false);}));
|
||||||
|
menu.append(summary, tools); row.append(menu); $('workspaceList').append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function workspaces() {const epoch = state.epoch, data = await api('/api/workspaces'); if (epoch !== state.epoch) return; state.workspaces = data.workspaces; workspaceRows();}
|
||||||
|
function workspaceInstructions(workspace) {dialog('Workspace instructions', workspace.instructions || '', async instructions => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, instructions}); await workspaces();}, 'These instructions guide chats in this workspace. They do not isolate tool access.');}
|
||||||
|
$('newWorkspace').onclick = () => dialog('Create workspace', '', async name => {await api('/api/workspaces', 'POST', {name}); await workspaces();}, 'Group related chats. A workspace is not a separate tool sandbox.');
|
||||||
|
function theme(preferences) {document.documentElement.dataset.palette = preferences.palette; document.documentElement.dataset.appearance = preferences.appearance; $('paletteSelect').value = preferences.palette; $('appearanceSelect').value = preferences.appearance;}
|
||||||
|
window.HopeAppearance = {load: async () => {
|
||||||
|
const epoch = state.epoch, user = HopeAuth.user?.id, fields = [...$('preferencesForm').querySelectorAll('select, button[type=submit], button:not([type])')];
|
||||||
|
fields.forEach(field => field.disabled = true);
|
||||||
|
try {const data = await api('/api/preferences'); if (epoch !== state.epoch || user !== HopeAuth.user?.id) return; state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = '';}
|
||||||
|
finally {if (epoch === state.epoch && user === HopeAuth.user?.id) fields.forEach(field => field.disabled = false);}
|
||||||
|
}};
|
||||||
|
$('openPreferences').onclick = async () => {try {if (window.HopeSettings) {await HopeSettings.open('appearance'); return;} await HopeAppearance.load(); if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} };
|
||||||
|
$('closePreferences').onclick = () => $('preferencesDialog').close();
|
||||||
|
$('preferencesForm').onsubmit = async event => {event.preventDefault(); const epoch = state.epoch, user = HopeAuth.user?.id; try {if (!state.preferences || !user) return; const data = await api('/api/preferences', 'PUT', {revision: state.preferences.revision, palette: $('paletteSelect').value, appearance: $('appearanceSelect').value}); if (epoch !== state.epoch || user !== HopeAuth.user?.id) return; state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = 'Appearance saved.';} catch(e) {if (epoch === state.epoch && user === HopeAuth.user?.id) $('preferencesMessage').textContent = e.message;} };
|
||||||
|
async function refreshSelected(messages = true) {if (!state.conversation) return; const epoch = state.epoch, cid = state.conversation.id; const data = await api('/api/conversations/' + enc(cid)); if (epoch !== state.epoch || cid !== state.conversation?.id) return; selected(data.conversation); if (messages && terminal(state.run)) {const timeline = $('chatTimeline'), scroll = timeline.scrollTop; const incoming = new Map((data.messages || []).map(m => [m.id,m])); state.messages = state.messages.map(m => incoming.get(m.id) || m); for (const m of incoming.values()) if (!state.messages.some(old => old.id === m.id)) state.messages.push(m); render(); timeline.scrollTop = scroll;} }
|
||||||
|
async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}}
|
||||||
|
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
|
||||||
|
HopeAuth.onChange((user, changed) => {
|
||||||
|
if (changed || !user) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
|
||||||
|
controls(); if (!user || !changed) return;
|
||||||
|
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
|
||||||
|
Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);});
|
||||||
|
});
|
||||||
|
})();
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
// Account-scoped advanced chat controls. Server capability flags are authoritative.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const state = {epoch: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false};
|
||||||
|
const enc = encodeURIComponent, $ = id => document.getElementById(id);
|
||||||
|
let options = {}, toolbar, previews, panel, status, artifactEditor, taskEditor, memoryEditor;
|
||||||
|
const node = (tag, text, cls) => {const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (cls) el.className = cls; return el;};
|
||||||
|
const message = text => {if (status) status.textContent = text;};
|
||||||
|
function button(label, fn) {const el = node('button', label, 'btn btn-ghost'); el.type = 'button'; el.addEventListener('click', async () => {el.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message);} finally {el.disabled = false;}}); return el;}
|
||||||
|
async function request(path, method = 'GET', body, extra = {}) {
|
||||||
|
const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller);
|
||||||
|
try {
|
||||||
|
const result = await (options.request || HopeAuth.request)(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), ...extra, signal: extra.signal || controller.signal});
|
||||||
|
if (epoch !== state.epoch) throw new DOMException('Account changed', 'AbortError');
|
||||||
|
return result;
|
||||||
|
} finally {state.controllers.delete(controller);}
|
||||||
|
}
|
||||||
|
function changed() {renderAttachments(); window.dispatchEvent(new CustomEvent('hope:attachments-changed', {detail: {ids: attachmentIDs(), pending: hasPendingUploads()}}));}
|
||||||
|
function attachmentIDs() {return state.attachments.filter(item => item.attachment && item.status === 'ready').map(item => item.attachment.id);}
|
||||||
|
function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready');}
|
||||||
|
function dispose(item) {item.controller?.abort(); if (item.url) {URL.revokeObjectURL(item.url); state.urls.delete(item.url);} }
|
||||||
|
function clearAttachmentsAfterSend(ids = attachmentIDs()) {const selected = new Set(ids); state.attachments = state.attachments.filter(item => {if (selected.has(item.attachment?.id)) {dispose(item); return false;} return true;}); changed();}
|
||||||
|
function clearAll() {for (const item of state.attachments) dispose(item); state.attachments = []; changed();}
|
||||||
|
function mime(file) {return file.type || ({txt: 'text/plain', md: 'text/markdown', pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp'}[file.name.split('.').pop().toLowerCase()] || '');}
|
||||||
|
async function upload(item) {
|
||||||
|
const epoch = state.epoch; item.status = 'uploading'; item.error = ''; item.controller = new AbortController(); changed();
|
||||||
|
try {
|
||||||
|
const data = await request('/api/attachments', 'POST', undefined, {body: item.file, headers: {'Content-Type': mime(item.file), 'X-Filename': item.file.name}, signal: item.controller.signal});
|
||||||
|
if (epoch !== state.epoch || !state.attachments.includes(item)) return;
|
||||||
|
item.attachment = data.attachment;
|
||||||
|
const extraction = data.attachment.extraction_status;
|
||||||
|
if (['failed', 'unavailable', 'encrypted'].includes(extraction)) {item.status = 'blocked'; item.error = 'Text could not be extracted. Remove this file or use a UTF-8 text file.';}
|
||||||
|
else item.status = 'ready';
|
||||||
|
} catch (error) {if (epoch !== state.epoch || !state.attachments.includes(item)) return; item.status = 'failed'; item.error = error.name === 'AbortError' ? 'Upload cancelled. Retry or remove the file.' : error.message;}
|
||||||
|
finally {if (epoch === state.epoch) changed();}
|
||||||
|
}
|
||||||
|
async function addFiles(files) {
|
||||||
|
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.'); return;}
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const maximum = state.capabilities.attachments_per_message || 8;
|
||||||
|
if (state.attachments.length >= maximum) {message(`Attach at most ${maximum} files per message.`); break;}
|
||||||
|
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {message('Unsupported file type: ' + file.name); continue;}
|
||||||
|
if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {message('Image input is not available for the selected model.'); continue;}
|
||||||
|
if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {message('File is empty or exceeds the upload limit: ' + file.name); continue;}
|
||||||
|
if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {message('File name is not supported. Rename the file and retry.'); continue;}
|
||||||
|
const item = {file, status: 'uploading', attachment: null, error: ''};
|
||||||
|
if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);}
|
||||||
|
state.attachments.push(item); upload(item);
|
||||||
|
}
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
function renderAttachments() {
|
||||||
|
if (!previews) return; previews.replaceChildren();
|
||||||
|
for (const item of state.attachments) {
|
||||||
|
const row = node('div', undefined, 'attachment-preview');
|
||||||
|
if (item.url) {const image = node('img'); image.src = item.url; image.alt = 'Preview of ' + item.file.name; image.width = 64; image.height = 64; row.append(image);}
|
||||||
|
row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`));
|
||||||
|
if (item.error) row.append(node('p', item.error));
|
||||||
|
if (item.attachment?.extraction_status === 'truncated') row.append(node('p', 'Only the extracted text limit will be sent.'));
|
||||||
|
if (item.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort()));
|
||||||
|
if (item.status === 'failed') row.append(button('Retry upload', () => upload(item)));
|
||||||
|
row.append(button('Remove ' + item.file.name, () => {dispose(item); state.attachments = state.attachments.filter(candidate => candidate !== item); changed();}));
|
||||||
|
previews.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function field(form, label, kind = 'input', value = '') {const wrap = node('label', label), input = node(kind); input.value = value; wrap.append(input); form.append(wrap); return input;}
|
||||||
|
function section(title) {const detail = node('details'), summary = node('summary', title), body = node('div'); detail.append(summary, body); panel.append(detail); return {detail, body};}
|
||||||
|
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.');} finally {submit.disabled = false;}});}
|
||||||
|
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; form.append(el); return el;}
|
||||||
|
function confirmAction(container, label, description, fn) {const details = node('details'), summary = node('summary', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);}
|
||||||
|
function download(path, label) {const link = node('a', label, 'btn btn-ghost'); link.href = path; link.setAttribute('download', ''); return link;}
|
||||||
|
const filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
|
||||||
|
async function loadFiles() {
|
||||||
|
const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles));
|
||||||
|
if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private account until you delete them.'));
|
||||||
|
for (const file of data.attachments || []) {const row = node('div'); row.append(node('span', `${file.filename} · ${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename)); confirmAction(row, 'Delete ' + file.filename, "Deleting removes file bytes; old chat attachment references won't work.", async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message('File deleted.');}); body.append(row);}
|
||||||
|
}
|
||||||
|
async function loadArtifacts() {
|
||||||
|
const data = await request('/api/artifacts'); const body = artifactSection.value.body; body.replaceChildren(button('New artifact', () => editArtifact(null)), button('Refresh artifacts', loadArtifacts));
|
||||||
|
if (!data.artifacts?.length) body.append(node('p', 'Save a draft, note or code snippet to keep versions across devices.'));
|
||||||
|
for (const artifact of data.artifacts || []) {const row = node('div'); row.append(node('span', artifact.title), button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(row, 'Delete artifact', 'This removes the artifact and its saved versions.', async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message('Artifact deleted.');}); body.append(row);}
|
||||||
|
}
|
||||||
|
function editArtifact(artifact) {
|
||||||
|
artifactEditor.replaceChildren(); const form = node('form'), heading = node('h3', artifact ? 'Edit artifact' : 'New artifact');
|
||||||
|
const title = field(form, 'Title', 'input', artifact?.title || ''); title.required = true; title.maxLength = 160;
|
||||||
|
const kind = field(form, 'Format', 'select'); for (const format of ['markdown', 'text', 'code']) {const option = node('option', format); option.value = format; kind.append(option);} kind.value = artifact?.kind || 'markdown';
|
||||||
|
const content = field(form, 'Content', 'textarea', artifact?.content || ''); content.rows = 12;
|
||||||
|
const preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;});
|
||||||
|
submitButton(form, artifact ? 'Save new version' : 'Create artifact'); form.append(button('Close editor', () => artifactEditor.replaceChildren()));
|
||||||
|
if (artifact) {
|
||||||
|
const versions = field(form, 'Saved version', 'select'); for (let revision = artifact.revision; revision >= 1; revision--) {const option = node('option', 'Version ' + revision); option.value = revision; versions.append(option);}
|
||||||
|
const versionExport = download('/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value), 'Export selected saved version'); versions.addEventListener('change', () => {versionExport.href = '/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value);}); form.append(button('View saved version', async () => {const saved = (await request('/api/artifacts/' + enc(artifact.id) + '?revision=' + enc(versions.value))).artifact; preview.textContent = saved.content; message('Viewing version ' + saved.revision + '. Your current edits are unchanged.');}), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export latest saved version'), versionExport);
|
||||||
|
}
|
||||||
|
formSubmit(form, async () => {const data = {title: title.value.trim(), kind: kind.value, content: content.value}; if (new TextEncoder().encode(data.content).length > (state.capabilities.artifact_bytes || 512 * 1024)) throw new Error('Artifact exceeds the content limit.'); const saved = await request('/api/artifacts' + (artifact ? '/' + enc(artifact.id) : ''), artifact ? 'PATCH' : 'POST', {...data, ...(artifact ? {revision: artifact.revision} : {})}); message('Artifact saved as version ' + saved.artifact.revision + '.'); await loadArtifacts(); editArtifact(saved.artifact);});
|
||||||
|
artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus();
|
||||||
|
}
|
||||||
|
async function loadTasks() {
|
||||||
|
const data = await request('/api/tasks'), body = taskSection.value.body; body.replaceChildren(button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks));
|
||||||
|
if (!data.tasks?.length) body.append(node('p', 'Schedule a prompt for your private assistant. Results stay local.'));
|
||||||
|
for (const task of data.tasks || []) {const row = node('div'); row.append(node('h3', task.name), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt), button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');})); confirmAction(row, 'Delete task', 'This removes the scheduled task, not your conversations.', async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); body.append(row);}
|
||||||
|
}
|
||||||
|
function editTask(task) {
|
||||||
|
taskEditor.replaceChildren(); const form = node('form'), title = field(form, 'Task name', 'input', task?.name || ''), schedule = field(form, 'Schedule', 'input', task?.schedule || ''), prompt = field(form, 'Prompt', 'textarea', task?.prompt || ''), repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat || '');
|
||||||
|
title.maxLength = 200; schedule.maxLength = 200; prompt.maxLength = 5000; title.required = schedule.required = prompt.required = true; repeat.type = 'number'; repeat.min = 1; repeat.step = 1;
|
||||||
|
form.append(node('p', 'Use a schedule accepted by your assistant, such as a cron expression. Invalid schedules are rejected by the server. Delivery stays local.'));
|
||||||
|
const paused = field(form, task ? 'Enabled' : 'Create paused'); paused.type = 'checkbox'; paused.checked = task ? !!task.enabled : true;
|
||||||
|
submitButton(form, task ? 'Save task' : 'Create task'); form.append(button('Close task editor', () => taskEditor.replaceChildren()));
|
||||||
|
formSubmit(form, async () => {const data = {name: title.value.trim(), schedule: schedule.value.trim(), prompt: prompt.value.trim(), ...(task ? {enabled: paused.checked} : {paused: paused.checked})}; if (repeat.value) data.repeat = Number(repeat.value); await request('/api/tasks' + (task ? '/' + enc(task.id) : ''), task ? 'PATCH' : 'POST', data); await loadTasks(); taskEditor.replaceChildren(); message(task ? 'Task saved.' : 'Task created.');}); taskEditor.append(node('h3', task ? 'Edit scheduled task' : 'New scheduled task'), form); title.focus();
|
||||||
|
}
|
||||||
|
async function loadMemory() {
|
||||||
|
const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Saved memory changes may take effect in a later assistant session. Clearing a section does not delete your conversations.'));
|
||||||
|
for (const section of data.sections || []) {
|
||||||
|
const form = node('form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content); content.rows = 10;
|
||||||
|
submitButton(form, 'Save ' + label.toLowerCase()); confirmAction(form, 'Clear ' + label.toLowerCase(), 'This clears this saved section. Conversations and the other section stay unchanged.', async () => {const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: '', revision: section.revision}); section.revision = saved.revision; content.value = ''; message(label + ' cleared.');});
|
||||||
|
formSubmit(form, async () => {if (new TextEncoder().encode(content.value).length > 65536) throw new Error('Memory section exceeds 64 KB.'); const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: content.value, revision: section.revision}); section.revision = saved.revision; message(label + ' saved.');}); memoryEditor.append(form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if (!state.user) return;
|
||||||
|
const result = await request('/api/capabilities'); state.capabilities = result.features || result.capabilities || result;
|
||||||
|
toolbar.hidden = !state.user; const input = toolbar.querySelector('input[type="file"]'); input.accept = (state.capabilities.attachment_mimes || []).filter(type => !type.startsWith('image/') || state.capabilities.image_input === true).join(','); input.disabled = state.capabilities.attachments !== true;
|
||||||
|
toolbar.querySelector('[data-attach]').disabled = input.disabled;
|
||||||
|
filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true;
|
||||||
|
panel.hidden = !state.user;
|
||||||
|
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
function initialize(config = {}) {
|
||||||
|
options = {...options, ...config}; if (state.initialized) return; state.initialized = true;
|
||||||
|
toolbar = options.toolbar || $('featureActions'); previews = options.previews || $('attachmentPreviews');
|
||||||
|
if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);}
|
||||||
|
if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);}
|
||||||
|
previews.setAttribute('aria-live', 'polite');
|
||||||
|
const input = node('input'); input.type = 'file'; input.multiple = true; input.hidden = true; input.setAttribute('aria-label', 'Choose attachments');
|
||||||
|
const attach = button('Attach files', () => input.click()); attach.dataset.attach = ''; input.addEventListener('change', () => {addFiles(input.files); input.value = '';}); toolbar.append(attach, input);
|
||||||
|
panel = node('section'); panel.id = 'hopeFeatures'; panel.setAttribute('aria-label', 'Saved work and assistant controls'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel);
|
||||||
|
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
|
||||||
|
filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor);
|
||||||
|
taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor);
|
||||||
|
memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor);
|
||||||
|
if ($('activityContent')) $('activityContent').append(taskSection.value.detail);
|
||||||
|
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') message(error.message);});});
|
||||||
|
const drop = options.dropTarget || $('primaryComposer') || toolbar;
|
||||||
|
drop.addEventListener('dragover', event => {if (event.dataTransfer?.types.includes('Files')) event.preventDefault();});
|
||||||
|
drop.addEventListener('drop', event => {if (event.dataTransfer?.files.length) {event.preventDefault(); addFiles(event.dataTransfer.files);}});
|
||||||
|
window.addEventListener('hope:conversation-selected', event => {const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;});
|
||||||
|
HopeAuth.onChange((user, accountChanged) => {
|
||||||
|
if (accountChanged || !user) {state.epoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
|
||||||
|
state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function mountSection(name, target) {
|
||||||
|
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
|
||||||
|
if (area && target) target.append(area.detail);
|
||||||
|
return area?.detail || null;
|
||||||
|
}
|
||||||
|
function openSection(name) {
|
||||||
|
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
|
||||||
|
if (area) area.detail.open = true;
|
||||||
|
}
|
||||||
|
window.HopeFeatures = {initialize, attachmentIDs, hasPendingUploads, clearAttachmentsAfterSend, clearAfterSend: clearAttachmentsAfterSend, addFiles, refresh,
|
||||||
|
mountMemory: target => mountSection('memory', target), mountTasks: target => mountSection('tasks', target), openSection};
|
||||||
|
initialize();
|
||||||
|
})();
|
||||||
+1057
File diff suppressed because it is too large.
Load diff
+52
-153
@@ -4,17 +4,17 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#1C191F">
|
<meta name="theme-color" content="#1C191F">
|
||||||
<title>Hope · Voice companion</title>
|
<title>Hope · Your private companion</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="apple-touch-icon" href="brand/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="brand/apple-touch-icon.png">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="brand/hope-icon-192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="brand/hope-icon-192.png">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="brand/hope-icon-512.png">
|
<link rel="icon" type="image/png" sizes="512x512" href="brand/hope-icon-512.png">
|
||||||
|
<link rel="stylesheet" href="hope-themes.css">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-mode="voice">
|
<body data-mode="voice">
|
||||||
<a class="skip-link" href="#conversation">Skip to conversation</a>
|
<a class="skip-link" href="#chatTimeline">Skip to conversation</a>
|
||||||
|
|
||||||
<section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
|
<section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
|
||||||
<h1 id="loginHeading">Welcome to Hope</h1>
|
<h1 id="loginHeading">Welcome to Hope</h1>
|
||||||
<p>Sign in to your private voice and text companion.</p>
|
<p>Sign in to your private voice and text companion.</p>
|
||||||
@@ -26,133 +26,32 @@
|
|||||||
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
|
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
|
||||||
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
|
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
|
||||||
</section>
|
</section>
|
||||||
<main class="app-shell" id="authenticatedApp" hidden>
|
|
||||||
<header class="topbar">
|
|
||||||
<div class="brand">
|
|
||||||
<span class="brand-glyph" aria-hidden="true"><i></i></span>
|
|
||||||
<div>
|
|
||||||
<h1>Hope</h1>
|
|
||||||
<p>Voice and text companion</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topbar-actions" id="sessionActions" hidden>
|
<main class="app-shell" id="authenticatedApp" hidden>
|
||||||
<div class="mode-switch" role="group" aria-label="Conversation mode">
|
<aside id="chatSidebar" class="chat-sidebar" aria-label="Main navigation">
|
||||||
<button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true">
|
<div class="sidebar-brand"><span class="hope-mark" aria-hidden="true"></span><strong>Hope</strong><button id="drawerClose" class="btn btn-icon" aria-label="Close navigation">×</button></div>
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path></svg>
|
<button id="newConversation" class="btn new-chat">+ New conversation</button>
|
||||||
<span>Voice</span>
|
<label class="sr-only" for="conversationSearch">Search conversations</label><input id="conversationSearch" type="search" placeholder="Search conversations">
|
||||||
</button>
|
<nav aria-label="Chat history"><div class="nav-heading"><h2>Workspaces</h2><button id="newWorkspace" class="btn btn-icon" aria-label="Create workspace">+</button></div><div id="workspaceList"></div><h2>Pinned</h2><div id="pinnedConversations"></div><div class="nav-heading"><h2 id="recentHeading">Recent</h2><button id="showArchived" class="btn btn-ghost" aria-pressed="false">Archived</button></div><div id="recentConversations"></div><button id="loadMoreConversations" class="btn btn-ghost" hidden>Load more</button></nav>
|
||||||
<button id="textModeBtn" class="mode-option" type="button" aria-pressed="false">
|
<div class="sidebar-footer" id="sessionActions" hidden><button id="openLibrary" class="btn btn-ghost">▱ Library</button><button id="tasksBtn" class="btn btn-ghost">⌁ Activity <span id="tasksBadge" class="tasks-badge" hidden>0</span></button><button id="accountMenuBtn" data-settings-open="account" class="btn account-trigger"><span class="account-avatar" aria-hidden="true">◦</span><span id="identityChip"><strong id="identityName"></strong><small>Account & settings</small></span><span aria-hidden="true">⌄</span></button><button id="openPreferences" class="btn btn-ghost">Appearance</button></div>
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"></path></svg>
|
</aside><button id="drawerBackdrop" class="drawer-backdrop" aria-label="Close navigation" hidden></button>
|
||||||
<span>Text</span>
|
<section id="primaryChat" class="primary-chat" aria-labelledby="chatTitle">
|
||||||
</button>
|
<header class="chat-topbar"><button id="drawerOpen" class="btn btn-icon" aria-label="Open navigation" aria-expanded="false" aria-controls="chatSidebar">☰</button><div class="chat-heading"><h1 id="chatTitle">New conversation</h1><p>Your private conversation</p></div><span class="privacy-badge">Only you</span><button id="conversationMenu" class="btn btn-icon" popovertarget="conversationActions" aria-label="Conversation actions" hidden>•••</button></header>
|
||||||
</div>
|
<div id="conversationActions" class="conversation-actions" popover="auto"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div>
|
||||||
<div class="identity-chip" id="identityChip" aria-label="Current account">
|
<p id="accountStatus" role="status" class="chat-notice"></p><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div>
|
||||||
<span class="presence-dot" aria-hidden="true"></span>
|
<section id="legacyVoice" class="inline-voice workspace" hidden aria-labelledby="voiceHeading">
|
||||||
<span>Private to <strong id="identityName"></strong></span>
|
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Realtime conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div>
|
||||||
</div>
|
<div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button">Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" disabled>End</button><button id="settingsBtn" class="btn btn-ghost" type="button">Voice settings</button></div>
|
||||||
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Assistant activity" title="Assistant activity">
|
<div id="conversation" class="live-transcript"><h3 id="conversationHeading" class="sr-only">Live transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="messages" aria-live="polite"></div></div>
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h11M8 12h11M8 18h7"></path><path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2"></path></svg>
|
|
||||||
<span>Activity</span>
|
|
||||||
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="account-toolbar">
|
|
||||||
<p id="accountStatus" role="status"></p>
|
|
||||||
<button id="adminBtn" class="btn btn-secondary" hidden aria-expanded="false" aria-controls="adminSection">Manage accounts</button>
|
|
||||||
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
|
|
||||||
</div>
|
|
||||||
<section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
|
|
||||||
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
|
|
||||||
<p id="adminMessage" role="status">Loading accounts…</p>
|
|
||||||
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
|
|
||||||
<h3>Create account</h3>
|
|
||||||
<form id="createUserForm" class="create-user-form account-form">
|
|
||||||
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
|
|
||||||
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
|
|
||||||
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
|
|
||||||
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
|
|
||||||
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
|
|
||||||
</form><p id="createUserMessage" role="status"></p>
|
|
||||||
</section>
|
</section>
|
||||||
|
<div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div>
|
||||||
<div class="workspace">
|
<form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What’s on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><div id="featureActions" class="feature-actions"></div><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Standard</option><option value="research">Research</option><option value="deep_research">Deep research</option></select><div class="composer-right"><span>Talk</span><button id="talkToHope" class="btn lightwell-button" type="button" aria-label="Start realtime voice conversation" aria-controls="legacyVoice" disabled><span class="lightwell-symbol" aria-hidden="true"></span></button><button id="cancelRun" class="btn" type="button" hidden>Stop</button><button id="primarySend" class="btn btn-primary send-button" aria-label="Send message" disabled>↑</button></div></div></form>
|
||||||
<section class="voice-console" aria-labelledby="voiceHeading">
|
<p class="chat-footnote"><span>Your conversation, private to you.</span><span class="keyhint">Enter to send · Shift + Enter for a new line</span></p>
|
||||||
<div class="console-intro">
|
</section></main>
|
||||||
<h2 id="voiceHeading">I'm here.</h2>
|
<dialog id="settingsDialog" class="settings-window" aria-labelledby="settingsTitle"><div class="dialog-head"><div><h2 id="settingsTitle">Settings</h2><p>Your Hope, the way you like it.</p></div><button id="settingsClose" class="btn btn-icon" aria-label="Close settings">×</button></div><div class="settings-layout"><nav class="settings-nav" aria-label="Settings sections"><button class="btn selected" data-settings-target="account">Account</button><button class="btn" data-settings-target="appearance">Appearance</button><button class="btn" data-settings-target="preferences">Voice & model</button><button class="btn" data-settings-target="privacy">Memory & data</button><button class="btn" data-settings-target="security">Security</button><button id="adminBtn" class="btn admin-link" data-settings-target="admin" hidden aria-expanded="false" aria-controls="adminSection">Administration</button></nav><div class="settings-body">
|
||||||
<p id="modeDescription">Speak naturally. I’ll listen, remember, and answer in real time.</p>
|
<section data-settings-panel="account"><h3>Your account</h3><p>Personal settings belong here, separate from your conversations.</p><div class="account-heading"><strong id="settingsAccountName"></strong><small id="settingsAccountUsername"></small><small id="settingsAccountRole"></small></div><p id="settingsAccountMessage" role="status"></p><p>Your private assistant and saved conversations belong to this account.</p><button id="logoutBtn" class="btn btn-secondary">Sign out</button></section>
|
||||||
</div>
|
<section data-settings-panel="appearance" hidden><h3>Appearance</h3><p>One visual identity across text, voice, and your devices.</p><div id="settingsAppearanceContent"></div></section>
|
||||||
|
<section data-settings-panel="preferences" hidden><div id="settingsSheet" class="settings-sheet" hidden inert><div class="sheet-header"><h3>Voice & model</h3><button id="closeSheetBtn" class="btn btn-icon" aria-label="Close voice settings">×</button></div><div class="sheet-content">
|
||||||
<div class="orb-stage">
|
|
||||||
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true">
|
|
||||||
</div>
|
|
||||||
<div class="orb-labels" aria-live="polite">
|
|
||||||
<span class="state-label" id="stateLabel">Ready when you are</span>
|
|
||||||
<span class="thinking-chip" id="thinkingChip" hidden>Thinking…</span>
|
|
||||||
<span class="barge-hint" id="bargeHint" hidden>Speak to interrupt</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls" aria-label="Conversation controls">
|
|
||||||
<button id="startBtn" class="btn btn-primary">
|
|
||||||
<span class="button-spark" aria-hidden="true"></span>
|
|
||||||
Start conversation
|
|
||||||
</button>
|
|
||||||
<button id="stopBtn" class="btn btn-danger" disabled>End conversation</button>
|
|
||||||
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
|
|
||||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
|
|
||||||
</button>
|
|
||||||
<button id="settingsBtn" class="btn btn-icon" aria-label="Hope settings" title="Hope settings">
|
|
||||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="connection-strip">
|
|
||||||
<div class="status" id="status" role="status">Secure connection ready</div>
|
|
||||||
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="privacy-note">
|
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>
|
|
||||||
<span id="privacyIdentity"></span>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="transcript" id="conversation" aria-labelledby="conversationHeading">
|
|
||||||
<div class="transcript-header">
|
|
||||||
<div>
|
|
||||||
<h2 id="conversationHeading">Conversation</h2>
|
|
||||||
<p id="transcriptModeLabel">Live voice transcript</p>
|
|
||||||
</div>
|
|
||||||
<button id="clearBtn" class="btn btn-ghost" title="Clear conversation">Clear</button>
|
|
||||||
</div>
|
|
||||||
<div id="messages" aria-live="polite"></div>
|
|
||||||
<form class="chat-composer" id="chatComposer" aria-label="Send a text message">
|
|
||||||
<label class="sr-only" for="chatInput">Message Hope</label>
|
|
||||||
<textarea id="chatInput" rows="1" maxlength="2000" placeholder="Message Hope…" autocomplete="off"></textarea>
|
|
||||||
<button id="sendChatBtn" class="send-chat" type="submit" disabled aria-label="Send message">
|
|
||||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
|
|
||||||
</button>
|
|
||||||
<span class="composer-hint">Enter to send · Shift+Enter for a new line</span>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
|
|
||||||
<div class="settings-sheet" id="settingsSheet" hidden inert role="dialog" aria-modal="true" aria-label="Hope settings">
|
|
||||||
<div class="sheet-handle" aria-hidden="true"></div>
|
|
||||||
<div class="sheet-header">
|
|
||||||
<div>
|
|
||||||
<h2>Hope’s settings</h2>
|
|
||||||
<p>Your voice preference applies to this conversation.</p>
|
|
||||||
</div>
|
|
||||||
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-content">
|
|
||||||
<form id="modelSettingsForm" class="account-form model-settings" aria-labelledby="modelSettingsHeading" aria-describedby="modelSettingsScope">
|
<form id="modelSettingsForm" class="account-form model-settings" aria-labelledby="modelSettingsHeading" aria-describedby="modelSettingsScope">
|
||||||
<h3 id="modelSettingsHeading">Model and thinking</h3>
|
<h3 id="modelSettingsHeading">Model and thinking</h3>
|
||||||
<p id="modelSettingsScope" class="model-settings-note">Saved for your account. Applies to your next request, including in an active conversation, without interrupting it.</p>
|
<p id="modelSettingsScope" class="model-settings-note">Saved for your account. Applies to your next request, including in an active conversation, without interrupting it.</p>
|
||||||
@@ -177,35 +76,35 @@
|
|||||||
<p id="voiceCatalogStatus" class="model-settings-note" role="status" aria-live="polite">Ava Dragon HD Omni is the recommended female default.</p>
|
<p id="voiceCatalogStatus" class="model-settings-note" role="status" aria-live="polite">Ava Dragon HD Omni is the recommended female default.</p>
|
||||||
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
|
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
|
||||||
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
|
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
|
||||||
</div>
|
</div></div></section>
|
||||||
</div>
|
<section data-settings-panel="privacy" hidden><h3>Memory & data</h3><p id="privacyIdentity"></p><p>Memory controls are available only when supported by your private assistant. Conversation deletion does not erase account memory.</p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><button id="settingsOpenLibrary" class="btn btn-secondary">Open Library</button></section>
|
||||||
|
<section data-settings-panel="security" hidden><h3>Security</h3><p>Hope uses a secure cookie session. Sign out when you leave a shared device.</p><p>Password resets are managed by an administrator and revoke existing sessions.</p></section>
|
||||||
|
<section data-settings-panel="admin" hidden> <section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
|
||||||
|
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
|
||||||
|
<p id="adminMessage" role="status">Loading accounts…</p>
|
||||||
|
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
|
||||||
|
<h3>Create account</h3>
|
||||||
|
<form id="createUserForm" class="create-user-form account-form">
|
||||||
|
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
|
||||||
|
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
|
||||||
|
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
|
||||||
|
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
|
||||||
|
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
|
||||||
|
</form><p id="createUserMessage" role="status"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
|
</section>
|
||||||
<div class="tasks-header">
|
</div></div></dialog>
|
||||||
<div>
|
<div id="sheetBackdrop" class="sheet-backdrop" hidden></div>
|
||||||
<h2>Assistant activity</h2>
|
<dialog id="libraryDialog" aria-labelledby="libraryTitle"><div class="dialog-head"><h2 id="libraryTitle">Library</h2><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p>Files and saved work, private to your account.</p><div id="libraryContent"></div></dialog>
|
||||||
<p>Progress from your private assistant.</p>
|
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity"><div class="tasks-header"><div><h2>Activity</h2><p>Progress from your private assistant.</p></div><button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">×</button></div><div class="tasks-list" id="tasksList"><div class="tasks-empty"><strong>No activity yet</strong><span>Progress appears here while Hope works. Replies stay in your conversation.</span></div></div><div id="activityContent"></div></div>
|
||||||
</div>
|
<div hidden aria-hidden="true"><button id="voiceModeBtn" type="button">Voice</button><button id="textModeBtn" type="button">Text</button><button id="clearBtn" type="button">Clear</button><button id="openVoice" type="button">Voice</button><button id="backToChat" type="button">Back to chat</button><form id="chatComposer"><textarea id="chatInput"></textarea><button id="sendChatBtn" disabled>Send</button></form></div>
|
||||||
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tasks-list" id="tasksList">
|
|
||||||
<div class="tasks-empty">
|
|
||||||
<strong>No activity yet</strong>
|
|
||||||
<span>Progress appears here while Hope works. Replies stay in your conversation.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="livekit-client.umd.js"></script>
|
|
||||||
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
|
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
|
||||||
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
|
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
|
||||||
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
|
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
|
||||||
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
|
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script src="auth.js"></script>
|
<dialog id="chatActionDialog" aria-labelledby="chatActionTitle"><form id="chatActionForm" class="account-form"><h2 id="chatActionTitle"></h2><p id="chatActionDescription"></p><label id="chatActionLabel" for="chatActionValue">Name</label><textarea id="chatActionValue" rows="3" required></textarea><label id="chatWorkspaceLabel" for="chatWorkspaceSelect" hidden>Workspace</label><select id="chatWorkspaceSelect" hidden></select><p id="chatActionError" role="status"></p><div class="dialog-actions"><button id="chatActionCancel" type="button" class="btn">Cancel</button><button id="chatActionSave" class="btn btn-primary">Save</button></div></form></dialog><dialog id="preferencesDialog" aria-labelledby="preferencesTitle"><h2 id="preferencesTitle">Appearance</h2><form id="preferencesForm" class="account-form"><label for="paletteSelect">Palette</label><select id="paletteSelect"><option value="opal">Opal</option><option value="graphite">Graphite</option><option value="ocean">Ocean</option><option value="sage">Sage</option><option value="lavender">Lavender</option></select><label for="appearanceSelect">Appearance</label><select id="appearanceSelect"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><p id="preferencesMessage" role="status"></p><div class="dialog-actions"><button id="closePreferences" class="btn" type="button">Close</button><button class="btn btn-primary">Save appearance</button></div></form></dialog>
|
||||||
<script src="app.js"></script>
|
<script src="livekit-client.umd.js"></script><script src="auth.js"></script><script src="app.js"></script><script src="chat.js"></script><script src="features.js"></script><script src="settings.js"></script>
|
||||||
</body>
|
</body></html>
|
||||||
</html>
|
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
// Settings reuses the account, appearance, voice and memory owners.
|
||||||
|
// Moving nodes preserves state and handlers; this module never writes profiles or roles.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const sections = new Set(['account', 'appearance', 'preferences', 'privacy', 'security', 'admin']);
|
||||||
|
let initialized = false, dialog, current = 'account', opener = null, generation = 0;
|
||||||
|
let memory = null, statusHome = null;
|
||||||
|
const panel = section => dialog?.querySelector(`[data-settings-panel="${section}"]`);
|
||||||
|
const text = (id, value) => { if ($(id)) $(id).textContent = value; };
|
||||||
|
|
||||||
|
function restoreStatus() {
|
||||||
|
if (statusHome && $('featureStatus')) {
|
||||||
|
const {parent, next} = statusHome;
|
||||||
|
parent.insertBefore($('featureStatus'), next?.parentNode === parent ? next : null);
|
||||||
|
statusHome = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateMemoryStatus() {
|
||||||
|
text('settingsMemoryStatus', !memory || memory.hidden
|
||||||
|
? 'Memory controls are unavailable for this account. No memory has been changed.'
|
||||||
|
: 'Review or edit your saved memory below. Clearing memory does not delete conversations.');
|
||||||
|
}
|
||||||
|
function identity(user, changed) {
|
||||||
|
text('settingsAccountName', user?.display_name || user?.username || '');
|
||||||
|
text('settingsAccountUsername', user ? `@${user.username}` : '');
|
||||||
|
text('settingsAccountRole', user ? (user.role === 'admin' ? 'Administrator' : 'User') : '');
|
||||||
|
dialog.querySelectorAll('[data-settings-target="admin"], [data-settings-open="admin"]').forEach(button => { button.hidden = user?.role !== 'admin'; });
|
||||||
|
if (!user || changed) close(false);
|
||||||
|
else if (current === 'admin' && user.role !== 'admin') select('account');
|
||||||
|
updateMemoryStatus();
|
||||||
|
}
|
||||||
|
async function loadAppearance(version) {
|
||||||
|
const message = $('preferencesMessage');
|
||||||
|
if (message) message.textContent = 'Loading your saved appearance…';
|
||||||
|
try {
|
||||||
|
if (window.HopeAppearance?.load) await window.HopeAppearance.load();
|
||||||
|
else throw new Error('Appearance settings are unavailable. Reload Hope to try again.');
|
||||||
|
} catch (error) {
|
||||||
|
if (version === generation && window.HopeAuth?.user && message) message.textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function select(section) {
|
||||||
|
if (!sections.has(section)) section = 'account';
|
||||||
|
if (section === 'admin' && window.HopeAuth?.user?.role !== 'admin') section = 'account';
|
||||||
|
current = section; const version = ++generation;
|
||||||
|
restoreStatus();
|
||||||
|
dialog.querySelectorAll('[data-settings-panel]').forEach(item => { item.hidden = item.dataset.settingsPanel !== section; });
|
||||||
|
dialog.querySelectorAll('[data-settings-target]').forEach(button => {
|
||||||
|
const active = button.dataset.settingsTarget === section;
|
||||||
|
button.classList.toggle('selected', active);
|
||||||
|
if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
// auth.js keeps its polling and provisioning handlers; never copy them.
|
||||||
|
if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
|
||||||
|
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
|
||||||
|
if (section === 'admin') $('refreshUsers')?.click();
|
||||||
|
if (section === 'appearance') loadAppearance(version);
|
||||||
|
if (section === 'preferences') {
|
||||||
|
if (window.HopeVoiceSettings?.load) window.HopeVoiceSettings.load();
|
||||||
|
else {
|
||||||
|
if (typeof renderVoiceList === 'function') renderVoiceList();
|
||||||
|
if (typeof loadVoiceCatalog === 'function') loadVoiceCatalog();
|
||||||
|
if (typeof loadModelSettings === 'function') loadModelSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (section === 'privacy') {
|
||||||
|
if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
||||||
|
const status = $('featureStatus');
|
||||||
|
if (status && status.parentNode !== panel('privacy')) {
|
||||||
|
statusHome = {parent: status.parentNode, next: status.nextSibling};
|
||||||
|
panel('privacy').append(status);
|
||||||
|
}
|
||||||
|
updateMemoryStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function open(section = 'account') {
|
||||||
|
initialize();
|
||||||
|
if (!dialog || !window.HopeAuth?.user) return;
|
||||||
|
if (!dialog.open) opener = document.activeElement;
|
||||||
|
if ($('chatSidebar')?.classList.contains('drawer-open')) $('drawerClose')?.click();
|
||||||
|
select(section);
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
$('settingsClose')?.focus();
|
||||||
|
}
|
||||||
|
function close(restoreFocus = true) {
|
||||||
|
generation++; restoreStatus();
|
||||||
|
if (!dialog?.open) return;
|
||||||
|
dialog.close();
|
||||||
|
if ($('adminSection')) $('adminSection').hidden = true;
|
||||||
|
$('adminBtn')?.setAttribute('aria-expanded', 'false');
|
||||||
|
if (restoreFocus) {
|
||||||
|
const target = [opener, $('settingsBtn'), $('drawerOpen')].find(item => item?.isConnected && !item.closest('[hidden], [inert]') && item.getClientRects().length);
|
||||||
|
target?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function initialize() {
|
||||||
|
if (initialized) return;
|
||||||
|
dialog = $('settingsDialog');
|
||||||
|
if (!dialog || !window.HopeAuth) return;
|
||||||
|
initialized = true;
|
||||||
|
const sheet = $('settingsSheet');
|
||||||
|
if (sheet && panel('preferences')) {
|
||||||
|
panel('preferences').append(sheet);
|
||||||
|
sheet.hidden = false; sheet.inert = false;
|
||||||
|
sheet.classList.remove('open');
|
||||||
|
sheet.removeAttribute('role'); sheet.removeAttribute('aria-modal');
|
||||||
|
}
|
||||||
|
if ($('preferencesForm') && panel('appearance')) panel('appearance').append($('preferencesForm'));
|
||||||
|
if ($('adminSection') && panel('admin')) panel('admin').append($('adminSection'));
|
||||||
|
for (const id of ['closeSheetBtn', 'closePreferences']) {
|
||||||
|
if ($(id)) $(id).addEventListener('click', () => close(), true);
|
||||||
|
}
|
||||||
|
$('settingsClose')?.addEventListener('click', () => close());
|
||||||
|
dialog.addEventListener('cancel', event => { event.preventDefault(); close(); });
|
||||||
|
dialog.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'Tab' || $('accountDialog')?.open) return;
|
||||||
|
const controls = [...dialog.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')].filter(item => !item.disabled && item.tabIndex >= 0 && !item.closest('[hidden], [inert]') && item.getClientRects().length);
|
||||||
|
const first = controls[0], last = controls[controls.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||||
|
});
|
||||||
|
dialog.addEventListener('close', () => {
|
||||||
|
// Native close events are queued; a later open must not be dismantled.
|
||||||
|
if (dialog.open) return;
|
||||||
|
restoreStatus();
|
||||||
|
if ($('adminSection')) $('adminSection').hidden = true;
|
||||||
|
$('adminBtn')?.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
dialog.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-settings-target]');
|
||||||
|
if (!button || !dialog.contains(button)) return;
|
||||||
|
event.preventDefault(); event.stopPropagation(); select(button.dataset.settingsTarget);
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-settings-open], #adminBtn, #openPreferences');
|
||||||
|
if (!button || dialog.contains(button)) return;
|
||||||
|
event.preventDefault(); event.stopImmediatePropagation();
|
||||||
|
open(button.dataset.settingsOpen || (button.id === 'adminBtn' ? 'admin' : 'appearance'));
|
||||||
|
}, true);
|
||||||
|
dialog.querySelectorAll('[data-settings-signout]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => { close(false); $('logoutBtn')?.click(); });
|
||||||
|
});
|
||||||
|
memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
||||||
|
if (memory) new MutationObserver(updateMemoryStatus).observe(memory, {attributes: true, attributeFilter: ['hidden']});
|
||||||
|
window.HopeAuth.onChange(identity);
|
||||||
|
}
|
||||||
|
window.HopeSettings = {open, close, initialize, get section() {return current;}, get isOpen() {return !!dialog?.open;}};
|
||||||
|
initialize();
|
||||||
|
})();
|
||||||
+1078
-1424
File diff suppressed because it is too large.
Load diff
+22
-2
@@ -60,6 +60,22 @@ async def token(request: Request):
|
|||||||
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
||||||
if origin not in allowed:
|
if origin not in allowed:
|
||||||
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
control_headers = {"X-Control-Secret": CONTROL_SECRET}
|
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}"
|
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
|
||||||
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
|
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
|
||||||
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
|
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
|
||||||
"session_token": credential})
|
"session_token": credential, "conversation_id": conversation_id})
|
||||||
grant.raise_for_status()
|
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,
|
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)
|
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
|
||||||
except (httpx.HTTPError, ValueError, KeyError):
|
except (httpx.HTTPError, ValueError, KeyError):
|
||||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||||
|
|||||||
Reference in new issue
Block a user