Hands-free voice mode had no language signal at all, so every spoken reply was synthesized with the English voice no matter what the user actually said. The multilingual Piper work (PR #26) added server-side routing for a "language" field but nothing ever sent one. Carry the language the private Jetson Whisper service already detects through to the TTS request for the reply that speech produced, and only for that reply. hermes-stt returns {text, model, language}, accepted only as a bare ISO-639 token; hermes_stt_client.py writes a <stem>.language sidecar next to the .txt transcript Hermes reads, leaving the local-command contract intact; the patched local-command envelope and /api/transcribe re-validate it and surface it; atlas-voice.js binds it to the voice-mode generation token and chat session, consumes it exactly once, and clears it on cancellation, restart, session change, empty transcript or transcription error; /api/tts honours it only from the fixed en/ru/es allow-list and otherwise sends English. A client "voice" field is never read at any hop, and typed messages, the manual read-aloud button, and any reply not produced by a spoken turn carry no trusted signal and stay on the English voice. The two WebUI-side and one agent-side edits are fail-closed replace_exact patches; both patch roots are now env-overridable so the contract can be verified offline without a GPU or an image build.
164 lines
8.5 KiB
Docker
164 lines
8.5 KiB
Docker
# syntax=docker/dockerfile:1
|
|
# dockerfiles/Dockerfile.hermes-webui
|
|
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
|
|
|
|
FROM registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
|
|
|
USER root
|
|
|
|
# Keep WebUI and Hermes pinned together. The WebUI imports Hermes internals,
|
|
# while the gateway remains the only process that owns an agent conversation.
|
|
COPY --from=webui /apptoo /opt/hermes-webui
|
|
|
|
# The account policy caps user-selected reasoning at xhigh even when a provider
|
|
# advertises a newer, more expensive level.
|
|
RUN /opt/hermes/.venv/bin/python - <<'PY'
|
|
from pathlib import Path
|
|
|
|
config = Path("/opt/hermes-webui/api/config.py")
|
|
source = config.read_text(encoding="utf-8")
|
|
before = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")'
|
|
after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")'
|
|
if before not in source:
|
|
raise SystemExit("Hermes WebUI reasoning-effort patch context changed")
|
|
config.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|
|
|
index = Path("/opt/hermes-webui/static/index.html")
|
|
source = index.read_text(encoding="utf-8")
|
|
before = ' <div class="reasoning-option" data-effort="max">Max</div>\n'
|
|
if before not in source:
|
|
raise SystemExit("Hermes WebUI xhigh UI patch context changed")
|
|
index.write_text(source.replace(before, "", 1), encoding="utf-8")
|
|
|
|
# oauth2-proxy returns 401 for browser API and health probes when the secure
|
|
# session expires. Re-enter OIDC with the complete return path instead of
|
|
# presenting an endless, inaccurate "connection lost" loop.
|
|
ui = Path("/opt/hermes-webui/static/ui.js")
|
|
source = ui.read_text(encoding="utf-8")
|
|
before = ''' const res=await fetcher(_offlineHealthUrl(),opts);
|
|
return !!(res&&res.ok);
|
|
'''
|
|
after = ''' const res=await fetcher(_offlineHealthUrl(),opts);
|
|
if(res&&(res.status===401||res.status===403)){
|
|
const rd=window.location.pathname+window.location.search+window.location.hash;
|
|
window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
|
|
return false;
|
|
}
|
|
return !!(res&&res.ok);
|
|
'''
|
|
if source.count(before) != 1:
|
|
raise SystemExit("Hermes WebUI auth-recovery patch context changed")
|
|
ui.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|
|
|
# Make delegated session hierarchy obvious and collapsible in the sidebar.
|
|
sessions = Path("/opt/hermes-webui/static/sessions.js")
|
|
source = sessions.read_text(encoding="utf-8")
|
|
before = ''' const childLabel=t('session_meta_children', childCount);
|
|
childCountEl.textContent=childLabel;
|
|
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
|
'''
|
|
after = ''' const childLabel=t('session_meta_children', childCount);
|
|
const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
|
|
childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
|
|
childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
|
|
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
|
'''
|
|
if source.count(before) != 1:
|
|
raise SystemExit("Hermes WebUI child-session toggle patch context changed")
|
|
sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|
|
|
# A profile's model is only its default; a session-level selector can override
|
|
# it. Label the scope so the dropdown does not contradict the effective model.
|
|
panels = Path("/opt/hermes-webui/static/panels.js")
|
|
source = panels.read_text(encoding="utf-8")
|
|
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
|
|
after = ''' if (typeof p.model === 'string' && p.model) {
|
|
const routeLabels = {
|
|
'atlas/auto/fast': 'Automatic · Fast',
|
|
'atlas/auto/balanced': 'Automatic · Balanced',
|
|
'atlas/auto/deep': 'Automatic · Deep',
|
|
'atlas/auto/maximum': 'Automatic · Maximum',
|
|
};
|
|
meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
|
|
}
|
|
'''
|
|
if source.count(before) != 2:
|
|
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
|
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
|
|
PY
|
|
|
|
# Add the Atlas voice bridge as a narrow integration layer. It activates only
|
|
# when a tenant's server-side STT capability reports the private Jetson route.
|
|
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
|
|
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
|
|
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
|
|
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
|
|
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
|
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
|
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py
|
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py
|
|
|
|
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
|
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
|
|
/opt/hermes-webui/api/config.py \
|
|
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
|
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
|
|
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
|
&& grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \
|
|
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
|
|
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
|
|
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
|
|
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
|
|
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
|
|
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
|
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
|
|
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
|
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
|
|
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
|
|
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
|
|
&& grep -Fq '"language": _atlas_tts_language(data)' /opt/hermes-webui/api/routes.py \
|
|
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
|
|
&& /opt/hermes/.venv/bin/python -m py_compile \
|
|
/opt/hermes-webui/api/routes.py \
|
|
/opt/hermes-webui/api/upload.py \
|
|
/opt/hermes-webui/api/gateway_chat.py \
|
|
/opt/hermes/tools/transcription_tools.py
|
|
|
|
# Exercise the real server process in the target architecture before publish.
|
|
RUN set -eu; \
|
|
mkdir -p /tmp/hermes-webui-smoke/home /tmp/hermes-webui-smoke/state /tmp/hermes-webui-smoke/workspace; \
|
|
HERMES_HOME=/tmp/hermes-webui-smoke/home \
|
|
HOME=/tmp/hermes-webui-smoke/home \
|
|
HERMES_WEBUI_STATE_DIR=/tmp/hermes-webui-smoke/state \
|
|
HERMES_WEBUI_DEFAULT_WORKSPACE=/tmp/hermes-webui-smoke/workspace \
|
|
HERMES_WEBUI_HOST=127.0.0.1 \
|
|
HERMES_WEBUI_PORT=18787 \
|
|
HERMES_WEBUI_SKIP_ONBOARDING=1 \
|
|
/opt/hermes/.venv/bin/python /opt/hermes-webui/server.py >/tmp/hermes-webui-smoke.log 2>&1 & \
|
|
server_pid=$!; \
|
|
ready=0; \
|
|
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do \
|
|
if /opt/hermes/.venv/bin/python -c 'from urllib.request import urlopen; urlopen("http://127.0.0.1:18787/health", timeout=2).read()' >/dev/null 2>&1; then ready=1; break; fi; \
|
|
sleep 1; \
|
|
done; \
|
|
kill "${server_pid}" 2>/dev/null || true; \
|
|
wait "${server_pid}" 2>/dev/null || true; \
|
|
if [ "${ready}" != "1" ]; then cat /tmp/hermes-webui-smoke.log; exit 1; fi; \
|
|
rm -rf /tmp/hermes-webui-smoke /tmp/hermes-webui-smoke.log
|
|
|
|
ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \
|
|
HERMES_WEBUI_HOST=0.0.0.0 \
|
|
HERMES_WEBUI_PORT=8787 \
|
|
HERMES_WEBUI_CHAT_BACKEND=gateway \
|
|
HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:8642 \
|
|
HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
|
|
HERMES_WEBUI_SKIP_ONBOARDING=1 \
|
|
HERMES_WEBUI_SECURE=1 \
|
|
PYTHONDONTWRITEBYTECODE=1 \
|
|
PYTHONUNBUFFERED=1
|
|
|
|
WORKDIR /opt/hermes-webui
|
|
USER 10000:10000
|
|
EXPOSE 8787
|
|
ENTRYPOINT ["/opt/hermes/.venv/bin/python", "/opt/hermes-webui/server.py"]
|