@bitseek/hermes-webui 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- package/vendor/agent-frontend-shell/static/workspace.js +680 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Default-off Hermes Gateway bridge for browser-originated chat turns."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from api.config import (
|
|
14
|
+
CANCEL_FLAGS,
|
|
15
|
+
STREAMS,
|
|
16
|
+
STREAMS_LOCK,
|
|
17
|
+
STREAM_LAST_EVENT_ID,
|
|
18
|
+
STREAM_LIVE_TOOL_CALLS,
|
|
19
|
+
STREAM_PARTIAL_TEXT,
|
|
20
|
+
STREAM_REASONING_TEXT,
|
|
21
|
+
_get_session_agent_lock,
|
|
22
|
+
register_active_run,
|
|
23
|
+
unregister_active_run,
|
|
24
|
+
update_active_run,
|
|
25
|
+
)
|
|
26
|
+
from api.helpers import _redact_text, redact_session_data
|
|
27
|
+
from api.models import get_session
|
|
28
|
+
from api.run_journal import RunJournalWriter
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND"
|
|
33
|
+
_WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
|
|
34
|
+
_WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY"
|
|
35
|
+
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def webui_chat_backend_mode(config_data=None, environ: dict[str, str] | None = None) -> str:
|
|
39
|
+
"""Return the explicitly selected browser chat backend.
|
|
40
|
+
|
|
41
|
+
The default remains the in-process WebUI runtime. Only explicit gateway
|
|
42
|
+
values opt browser chat into the Hermes API server bridge; generic truthy
|
|
43
|
+
strings are deliberately ignored so deployments do not change execution
|
|
44
|
+
ownership by accident.
|
|
45
|
+
"""
|
|
46
|
+
source = os.environ if environ is None else environ
|
|
47
|
+
cfg = config_data if isinstance(config_data, dict) else {}
|
|
48
|
+
raw = str(
|
|
49
|
+
source.get(_WEBUI_CHAT_BACKEND_ENV)
|
|
50
|
+
or cfg.get("webui_chat_backend")
|
|
51
|
+
or ""
|
|
52
|
+
).strip().lower()
|
|
53
|
+
if raw in _GATEWAY_CHAT_BACKENDS:
|
|
54
|
+
return "gateway"
|
|
55
|
+
return "legacy"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def webui_gateway_chat_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
|
|
59
|
+
return webui_chat_backend_mode(config_data, environ) == "gateway"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _gateway_base_url(config_data=None, environ: dict[str, str] | None = None) -> str:
|
|
63
|
+
source = os.environ if environ is None else environ
|
|
64
|
+
cfg = config_data if isinstance(config_data, dict) else {}
|
|
65
|
+
raw = str(
|
|
66
|
+
source.get(_WEBUI_GATEWAY_BASE_URL_ENV)
|
|
67
|
+
or cfg.get("webui_gateway_base_url")
|
|
68
|
+
or "http://127.0.0.1:8642"
|
|
69
|
+
).strip()
|
|
70
|
+
return raw.rstrip("/") or "http://127.0.0.1:8642"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
|
|
74
|
+
source = os.environ if environ is None else environ
|
|
75
|
+
return str(
|
|
76
|
+
source.get(_WEBUI_GATEWAY_API_KEY_ENV)
|
|
77
|
+
or source.get("API_SERVER_KEY")
|
|
78
|
+
or ""
|
|
79
|
+
).strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
|
|
83
|
+
"""Return redacted Gateway-backed chat configuration status."""
|
|
84
|
+
mode = webui_chat_backend_mode(config_data, environ)
|
|
85
|
+
base_url = _gateway_base_url(config_data, environ)
|
|
86
|
+
return {
|
|
87
|
+
"enabled": mode == "gateway",
|
|
88
|
+
"backend": mode,
|
|
89
|
+
"base_url_configured": bool(base_url),
|
|
90
|
+
"api_key_configured": bool(_gateway_api_key(environ)),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _gateway_http_error_event(exc: urllib.error.HTTPError, err_body: str, *, api_key_configured: bool) -> dict:
|
|
95
|
+
safe = _redact_text(err_body or str(exc))[:500]
|
|
96
|
+
if exc.code == 401:
|
|
97
|
+
return {
|
|
98
|
+
"label": "Gateway authentication failed",
|
|
99
|
+
"type": "gateway_auth_error",
|
|
100
|
+
"message": "Gateway rejected the WebUI API key (HTTP 401).",
|
|
101
|
+
"hint": (
|
|
102
|
+
"Set HERMES_WEBUI_GATEWAY_API_KEY to the same value as the Hermes Gateway "
|
|
103
|
+
"API_SERVER_KEY, or disable HERMES_WEBUI_CHAT_BACKEND=gateway."
|
|
104
|
+
if not api_key_configured
|
|
105
|
+
else "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
"label": "Gateway request failed",
|
|
110
|
+
"type": "gateway_http_error",
|
|
111
|
+
"message": f"Gateway returned HTTP {exc.code}.",
|
|
112
|
+
"hint": safe or "Check the configured Gateway API server.",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _gateway_sse_delta(payload: dict) -> str:
|
|
117
|
+
"""Extract assistant text from an OpenAI-compatible streaming chunk."""
|
|
118
|
+
try:
|
|
119
|
+
choices = payload.get("choices") or []
|
|
120
|
+
if not choices:
|
|
121
|
+
return ""
|
|
122
|
+
choice = choices[0] or {}
|
|
123
|
+
delta = choice.get("delta") or {}
|
|
124
|
+
content = delta.get("content")
|
|
125
|
+
if isinstance(content, str):
|
|
126
|
+
return content
|
|
127
|
+
message = choice.get("message") or {}
|
|
128
|
+
content = message.get("content")
|
|
129
|
+
return content if isinstance(content, str) else ""
|
|
130
|
+
except Exception:
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _gateway_stream_usage(payload: dict) -> dict:
|
|
135
|
+
usage = payload.get("usage") if isinstance(payload, dict) else None
|
|
136
|
+
if not isinstance(usage, dict):
|
|
137
|
+
return {}
|
|
138
|
+
return {
|
|
139
|
+
"input_tokens": int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0),
|
|
140
|
+
"output_tokens": int(usage.get("completion_tokens") or usage.get("output_tokens") or 0),
|
|
141
|
+
"estimated_cost": usage.get("estimated_cost") or usage.get("estimated_cost_usd") or 0,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _gateway_tool_progress_event(payload: dict) -> tuple[str, dict] | None:
|
|
146
|
+
"""Translate Hermes Gateway tool-progress SSE payloads to WebUI events."""
|
|
147
|
+
if not isinstance(payload, dict):
|
|
148
|
+
return None
|
|
149
|
+
name = str(payload.get("tool") or payload.get("name") or payload.get("function_name") or "").strip()
|
|
150
|
+
if not name or name.startswith("_"):
|
|
151
|
+
return None
|
|
152
|
+
status = str(payload.get("status") or "running").strip().lower()
|
|
153
|
+
tid = payload.get("toolCallId") or payload.get("tool_call_id") or payload.get("id")
|
|
154
|
+
is_complete = status in {"completed", "complete", "success", "error", "failed"}
|
|
155
|
+
event_payload = {
|
|
156
|
+
"event_type": "tool.completed" if is_complete else "tool.started",
|
|
157
|
+
"name": name,
|
|
158
|
+
"preview": payload.get("label") or payload.get("preview"),
|
|
159
|
+
"args": payload.get("args") if isinstance(payload.get("args"), dict) else {},
|
|
160
|
+
"is_error": status in {"error", "failed"},
|
|
161
|
+
}
|
|
162
|
+
if tid:
|
|
163
|
+
event_payload["tid"] = str(tid)
|
|
164
|
+
return ("tool_complete" if is_complete else "tool"), event_payload
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
|
|
168
|
+
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
|
|
172
|
+
if not _stream_writeback_is_current(session, stream_id):
|
|
173
|
+
return
|
|
174
|
+
session.active_stream_id = None
|
|
175
|
+
session.pending_user_message = None
|
|
176
|
+
session.pending_attachments = None
|
|
177
|
+
session.pending_started_at = None
|
|
178
|
+
session.save()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _run_gateway_chat_streaming(
|
|
182
|
+
session_id,
|
|
183
|
+
msg_text,
|
|
184
|
+
model,
|
|
185
|
+
workspace,
|
|
186
|
+
stream_id,
|
|
187
|
+
attachments=None,
|
|
188
|
+
*,
|
|
189
|
+
model_provider=None,
|
|
190
|
+
):
|
|
191
|
+
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
|
|
192
|
+
|
|
193
|
+
This default-off path keeps the browser contract unchanged: /api/chat/start
|
|
194
|
+
still returns a local stream_id and /api/chat/stream still receives WebUI SSE
|
|
195
|
+
event names. The worker translates OpenAI-compatible streaming chunks from
|
|
196
|
+
the configured Gateway API server into those local events and persists the
|
|
197
|
+
final user/assistant turn back into the WebUI session.
|
|
198
|
+
"""
|
|
199
|
+
q = STREAMS.get(stream_id)
|
|
200
|
+
if q is None:
|
|
201
|
+
return
|
|
202
|
+
register_active_run(
|
|
203
|
+
stream_id,
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
started_at=time.time(),
|
|
206
|
+
phase="gateway-starting",
|
|
207
|
+
workspace=str(workspace),
|
|
208
|
+
model=model,
|
|
209
|
+
provider=model_provider,
|
|
210
|
+
backend="gateway",
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
run_journal = RunJournalWriter(session_id, stream_id)
|
|
214
|
+
except Exception:
|
|
215
|
+
run_journal = None
|
|
216
|
+
logger.debug("Failed to initialize gateway run journal for stream %s", stream_id, exc_info=True)
|
|
217
|
+
cancel_event = threading.Event()
|
|
218
|
+
with STREAMS_LOCK:
|
|
219
|
+
CANCEL_FLAGS[stream_id] = cancel_event
|
|
220
|
+
STREAM_PARTIAL_TEXT[stream_id] = ""
|
|
221
|
+
STREAM_REASONING_TEXT[stream_id] = ""
|
|
222
|
+
STREAM_LIVE_TOOL_CALLS[stream_id] = []
|
|
223
|
+
|
|
224
|
+
def put_gateway_event(event, data):
|
|
225
|
+
if cancel_event.is_set() and event not in ("cancel", "error", "apperror"):
|
|
226
|
+
return
|
|
227
|
+
if run_journal is not None:
|
|
228
|
+
try:
|
|
229
|
+
journaled = run_journal.append_sse_event(event, data)
|
|
230
|
+
event_id = (journaled or {}).get("event_id") if isinstance(journaled, dict) else None
|
|
231
|
+
if event_id:
|
|
232
|
+
STREAM_LAST_EVENT_ID[stream_id] = event_id
|
|
233
|
+
except Exception:
|
|
234
|
+
logger.debug("Failed to append gateway event %s for stream %s", event, stream_id, exc_info=True)
|
|
235
|
+
try:
|
|
236
|
+
q.put_nowait((event, data))
|
|
237
|
+
except Exception:
|
|
238
|
+
logger.debug("Failed to put gateway event to queue")
|
|
239
|
+
|
|
240
|
+
s = None
|
|
241
|
+
final_text = ""
|
|
242
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0}
|
|
243
|
+
try:
|
|
244
|
+
s = get_session(session_id)
|
|
245
|
+
from api.config import get_config # imported lazily to avoid config-cycle churn
|
|
246
|
+
|
|
247
|
+
cfg = get_config()
|
|
248
|
+
try:
|
|
249
|
+
from api.streaming import (
|
|
250
|
+
_load_webui_prefill_context,
|
|
251
|
+
_prefill_messages_with_webui_context,
|
|
252
|
+
_public_prefill_context_status,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
prefill_context = _load_webui_prefill_context(cfg)
|
|
256
|
+
prefill_messages = _prefill_messages_with_webui_context(prefill_context, cfg)
|
|
257
|
+
put_gateway_event("context_status", {
|
|
258
|
+
"session_id": session_id,
|
|
259
|
+
"prefill": _public_prefill_context_status(prefill_context),
|
|
260
|
+
})
|
|
261
|
+
except Exception:
|
|
262
|
+
logger.debug("Failed to load WebUI gateway prefill context", exc_info=True)
|
|
263
|
+
prefill_messages = []
|
|
264
|
+
base_url = _gateway_base_url(cfg)
|
|
265
|
+
api_key = _gateway_api_key()
|
|
266
|
+
url = f"{base_url}/v1/chat/completions"
|
|
267
|
+
headers = {
|
|
268
|
+
"Content-Type": "application/json",
|
|
269
|
+
"Accept": "text/event-stream",
|
|
270
|
+
"X-Hermes-Session-Id": session_id,
|
|
271
|
+
}
|
|
272
|
+
if api_key:
|
|
273
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
274
|
+
# Scope Gateway long-term continuity to this WebUI conversation
|
|
275
|
+
# without exposing the browser's auth cookie or CSRF material.
|
|
276
|
+
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
|
277
|
+
message_content: Any = str(msg_text or "")
|
|
278
|
+
if attachments:
|
|
279
|
+
try:
|
|
280
|
+
from api.streaming import _build_native_multimodal_message
|
|
281
|
+
|
|
282
|
+
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
|
|
283
|
+
except Exception:
|
|
284
|
+
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
|
|
285
|
+
message_content = str(msg_text or "")
|
|
286
|
+
body = {
|
|
287
|
+
"model": model or "default",
|
|
288
|
+
"stream": True,
|
|
289
|
+
"messages": [*prefill_messages, {"role": "user", "content": message_content}],
|
|
290
|
+
}
|
|
291
|
+
if model_provider:
|
|
292
|
+
body["provider"] = model_provider
|
|
293
|
+
req = urllib.request.Request(
|
|
294
|
+
url,
|
|
295
|
+
data=json.dumps(body).encode("utf-8"),
|
|
296
|
+
headers=headers,
|
|
297
|
+
method="POST",
|
|
298
|
+
)
|
|
299
|
+
update_active_run(stream_id, phase="gateway-request")
|
|
300
|
+
last_payload = {}
|
|
301
|
+
sse_event = "message"
|
|
302
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
303
|
+
for raw_line in resp:
|
|
304
|
+
if cancel_event.is_set():
|
|
305
|
+
put_gateway_event("cancel", {"message": "Cancelled by user"})
|
|
306
|
+
return
|
|
307
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
308
|
+
if not line:
|
|
309
|
+
sse_event = "message"
|
|
310
|
+
continue
|
|
311
|
+
if line.startswith("event:"):
|
|
312
|
+
sse_event = line[6:].strip() or "message"
|
|
313
|
+
continue
|
|
314
|
+
if not line.startswith("data:"):
|
|
315
|
+
continue
|
|
316
|
+
data = line[5:].strip()
|
|
317
|
+
if data == "[DONE]":
|
|
318
|
+
break
|
|
319
|
+
try:
|
|
320
|
+
payload = json.loads(data)
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
continue
|
|
323
|
+
if sse_event == "hermes.tool.progress":
|
|
324
|
+
translated = _gateway_tool_progress_event(payload)
|
|
325
|
+
if translated:
|
|
326
|
+
event_name, event_payload = translated
|
|
327
|
+
if stream_id in STREAM_LIVE_TOOL_CALLS:
|
|
328
|
+
if event_name == "tool":
|
|
329
|
+
STREAM_LIVE_TOOL_CALLS[stream_id].append({
|
|
330
|
+
"name": event_payload.get("name"),
|
|
331
|
+
"args": event_payload.get("args") or {},
|
|
332
|
+
"done": False,
|
|
333
|
+
**({"tid": event_payload.get("tid")} if event_payload.get("tid") else {}),
|
|
334
|
+
})
|
|
335
|
+
else:
|
|
336
|
+
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
|
|
337
|
+
if shared_tc.get("done"):
|
|
338
|
+
continue
|
|
339
|
+
if (
|
|
340
|
+
event_payload.get("tid") and shared_tc.get("tid") == event_payload.get("tid")
|
|
341
|
+
) or shared_tc.get("name") == event_payload.get("name"):
|
|
342
|
+
shared_tc["done"] = True
|
|
343
|
+
shared_tc["is_error"] = bool(event_payload.get("is_error"))
|
|
344
|
+
break
|
|
345
|
+
put_gateway_event(event_name, event_payload)
|
|
346
|
+
update_active_run(stream_id, phase="gateway-tool", latest_tool=event_payload.get("name"))
|
|
347
|
+
sse_event = "message"
|
|
348
|
+
continue
|
|
349
|
+
last_payload = payload
|
|
350
|
+
delta = _gateway_sse_delta(payload)
|
|
351
|
+
if delta:
|
|
352
|
+
final_text += delta
|
|
353
|
+
if stream_id in STREAM_PARTIAL_TEXT:
|
|
354
|
+
STREAM_PARTIAL_TEXT[stream_id] += delta
|
|
355
|
+
put_gateway_event("token", {"text": delta})
|
|
356
|
+
usage.update({k: v for k, v in _gateway_stream_usage(payload).items() if v})
|
|
357
|
+
usage.update({k: v for k, v in _gateway_stream_usage(last_payload).items() if v})
|
|
358
|
+
assistant_text = final_text.strip()
|
|
359
|
+
if not assistant_text:
|
|
360
|
+
put_gateway_event("apperror", {
|
|
361
|
+
"label": "Gateway returned no response",
|
|
362
|
+
"type": "gateway_empty_response",
|
|
363
|
+
"message": "Gateway returned no assistant message for this turn.",
|
|
364
|
+
"hint": "Check that Hermes Gateway API server is running and reachable.",
|
|
365
|
+
})
|
|
366
|
+
return
|
|
367
|
+
with _get_session_agent_lock(session_id):
|
|
368
|
+
s = get_session(session_id)
|
|
369
|
+
if not _stream_writeback_is_current(s, stream_id):
|
|
370
|
+
return
|
|
371
|
+
now = time.time()
|
|
372
|
+
# Preserve subsecond ordering for gateway-backed turns. Using an
|
|
373
|
+
# integer seconds timestamp gives the user and assistant rows the
|
|
374
|
+
# same sort key; later transcript merges can then fall back to
|
|
375
|
+
# role/content ordering instead of turn order.
|
|
376
|
+
assistant_ts = now + 0.000001
|
|
377
|
+
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
|
|
378
|
+
if attachments:
|
|
379
|
+
user_msg["attachments"] = list(attachments)
|
|
380
|
+
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
|
|
381
|
+
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
|
|
382
|
+
s.context_messages = previous_context + [user_msg, assistant_msg]
|
|
383
|
+
display = list(getattr(s, "messages", None) or [])
|
|
384
|
+
# Avoid duplicating the eager-save checkpointed user message.
|
|
385
|
+
if display:
|
|
386
|
+
latest = display[-1]
|
|
387
|
+
if isinstance(latest, dict) and latest.get("role") == "user":
|
|
388
|
+
latest_text = " ".join(str(latest.get("content") or "").split())
|
|
389
|
+
msg_norm = " ".join(str(msg_text or "").split())
|
|
390
|
+
if latest_text == msg_norm:
|
|
391
|
+
display = display[:-1]
|
|
392
|
+
s.messages = display + [user_msg, assistant_msg]
|
|
393
|
+
s.active_stream_id = None
|
|
394
|
+
s.pending_user_message = None
|
|
395
|
+
s.pending_attachments = None
|
|
396
|
+
s.pending_started_at = None
|
|
397
|
+
s.workspace = str(workspace)
|
|
398
|
+
s.model = model
|
|
399
|
+
s.model_provider = model_provider
|
|
400
|
+
s.save()
|
|
401
|
+
gateway_session_payload = s.compact() | {"messages": s.messages, "tool_calls": []}
|
|
402
|
+
put_gateway_event("done", {"session": redact_session_data(gateway_session_payload), "usage": usage})
|
|
403
|
+
put_gateway_event("stream_end", {"session_id": session_id})
|
|
404
|
+
except urllib.error.HTTPError as exc:
|
|
405
|
+
try:
|
|
406
|
+
err_body = exc.read(2048).decode("utf-8", errors="replace")
|
|
407
|
+
except Exception:
|
|
408
|
+
err_body = ""
|
|
409
|
+
put_gateway_event(
|
|
410
|
+
"apperror",
|
|
411
|
+
_gateway_http_error_event(exc, err_body, api_key_configured=bool(_gateway_api_key())),
|
|
412
|
+
)
|
|
413
|
+
except Exception as exc:
|
|
414
|
+
safe = _redact_text(str(exc))[:500]
|
|
415
|
+
put_gateway_event("apperror", {
|
|
416
|
+
"label": "Gateway request failed",
|
|
417
|
+
"type": "gateway_error",
|
|
418
|
+
"message": safe or "Gateway request failed.",
|
|
419
|
+
"hint": "Check HERMES_WEBUI_GATEWAY_BASE_URL and Gateway API server health.",
|
|
420
|
+
})
|
|
421
|
+
finally:
|
|
422
|
+
if s is not None:
|
|
423
|
+
try:
|
|
424
|
+
with _get_session_agent_lock(session_id):
|
|
425
|
+
_clear_gateway_pending_state(get_session(session_id), stream_id)
|
|
426
|
+
except Exception:
|
|
427
|
+
logger.debug("Failed to clear gateway stream state", exc_info=True)
|
|
428
|
+
with STREAMS_LOCK:
|
|
429
|
+
CANCEL_FLAGS.pop(stream_id, None)
|
|
430
|
+
STREAM_PARTIAL_TEXT.pop(stream_id, None)
|
|
431
|
+
STREAM_REASONING_TEXT.pop(stream_id, None)
|
|
432
|
+
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)
|
|
433
|
+
STREAM_LAST_EVENT_ID.pop(stream_id, None)
|
|
434
|
+
STREAMS.pop(stream_id, None)
|
|
435
|
+
unregister_active_run(stream_id)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hermes Web UI -- Gateway session watcher.
|
|
3
|
+
|
|
4
|
+
Background daemon thread that polls state.db every 5 seconds for changes
|
|
5
|
+
to gateway sessions (telegram, discord, slack, etc.). When changes are
|
|
6
|
+
detected, it pushes notifications to all subscribed SSE clients.
|
|
7
|
+
|
|
8
|
+
This enables real-time session list updates in the sidebar without
|
|
9
|
+
requiring any changes to hermes-agent.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import queue
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from api.config import HOME
|
|
21
|
+
from api.agent_sessions import read_importable_agent_session_rows
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── State hash tracking ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def _snapshot_hash(sessions: list) -> str:
|
|
29
|
+
"""Create a lightweight hash of session IDs and timestamps for change detection."""
|
|
30
|
+
key = '|'.join(
|
|
31
|
+
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
|
|
32
|
+
for s in sorted(sessions, key=lambda x: x['session_id'])
|
|
33
|
+
)
|
|
34
|
+
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
|
|
38
|
+
|
|
39
|
+
def _get_state_db_path() -> Path:
|
|
40
|
+
"""Resolve state.db path for the active profile."""
|
|
41
|
+
try:
|
|
42
|
+
from api.profiles import get_active_hermes_home
|
|
43
|
+
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
|
44
|
+
except Exception:
|
|
45
|
+
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
|
46
|
+
return hermes_home / 'state.db'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_agent_sessions_from_db() -> list:
|
|
50
|
+
"""Read all non-webui sessions from state.db.
|
|
51
|
+
Returns list of session dicts, or empty list on any error.
|
|
52
|
+
"""
|
|
53
|
+
db_path = _get_state_db_path()
|
|
54
|
+
if not db_path.exists():
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
sessions = []
|
|
59
|
+
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
|
|
60
|
+
sessions.append({
|
|
61
|
+
'session_id': row['id'],
|
|
62
|
+
'title': row['title'] or 'Agent Session',
|
|
63
|
+
'model': row['model'] or None,
|
|
64
|
+
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
|
65
|
+
'created_at': row['started_at'],
|
|
66
|
+
'updated_at': row['last_activity'] or row['started_at'],
|
|
67
|
+
'source': row['source'] or 'cli',
|
|
68
|
+
'raw_source': row.get('raw_source'),
|
|
69
|
+
'session_source': row.get('session_source'),
|
|
70
|
+
'source_label': row.get('source_label'),
|
|
71
|
+
})
|
|
72
|
+
return sessions
|
|
73
|
+
except Exception:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── GatewayWatcher ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
class GatewayWatcher:
|
|
80
|
+
"""Background thread that polls state.db for agent session changes.
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
watcher = GatewayWatcher()
|
|
84
|
+
watcher.start()
|
|
85
|
+
q = watcher.subscribe()
|
|
86
|
+
# ... receive change events via q.get() ...
|
|
87
|
+
watcher.unsubscribe(q)
|
|
88
|
+
watcher.stop()
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
POLL_INTERVAL = 5 # seconds between polls
|
|
92
|
+
SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
|
|
93
|
+
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self._subscribers: list[queue.Queue] = []
|
|
96
|
+
self._sub_lock = threading.Lock()
|
|
97
|
+
self._stop_event = threading.Event()
|
|
98
|
+
self._thread: threading.Thread | None = None
|
|
99
|
+
self._last_hash: str = ''
|
|
100
|
+
self._last_sessions: list = []
|
|
101
|
+
|
|
102
|
+
def start(self):
|
|
103
|
+
"""Start the watcher daemon thread."""
|
|
104
|
+
if self._thread and self._thread.is_alive():
|
|
105
|
+
return
|
|
106
|
+
self._stop_event.clear()
|
|
107
|
+
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
|
|
108
|
+
self._thread.start()
|
|
109
|
+
|
|
110
|
+
def is_alive(self) -> bool:
|
|
111
|
+
"""Return True when the poll thread is running.
|
|
112
|
+
|
|
113
|
+
Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
|
|
114
|
+
the live SSE handler to detect a watcher instance whose poll thread
|
|
115
|
+
died silently (e.g. uncaught exception in ``_poll_loop``). Callers
|
|
116
|
+
use this to decide whether to return 503 and trigger the client-side
|
|
117
|
+
polling fallback, instead of handing out an SSE connection that would
|
|
118
|
+
never emit events.
|
|
119
|
+
"""
|
|
120
|
+
t = self._thread
|
|
121
|
+
return t is not None and t.is_alive()
|
|
122
|
+
|
|
123
|
+
def stop(self):
|
|
124
|
+
"""Stop the watcher thread."""
|
|
125
|
+
self._stop_event.set()
|
|
126
|
+
# Wake up any subscribers
|
|
127
|
+
with self._sub_lock:
|
|
128
|
+
for q in self._subscribers:
|
|
129
|
+
try:
|
|
130
|
+
q.put(None) # sentinel
|
|
131
|
+
except Exception:
|
|
132
|
+
logger.debug("Failed to send sentinel to subscriber")
|
|
133
|
+
if self._thread:
|
|
134
|
+
self._thread.join(timeout=3)
|
|
135
|
+
self._thread = None
|
|
136
|
+
|
|
137
|
+
def subscribe(self) -> queue.Queue:
|
|
138
|
+
"""Subscribe to change events. Returns a queue.Queue.
|
|
139
|
+
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
|
|
140
|
+
A None sentinel means the watcher is stopping.
|
|
141
|
+
"""
|
|
142
|
+
q = queue.Queue(maxsize=10)
|
|
143
|
+
with self._sub_lock:
|
|
144
|
+
self._subscribers.append(q)
|
|
145
|
+
return q
|
|
146
|
+
|
|
147
|
+
def unsubscribe(self, q: queue.Queue):
|
|
148
|
+
"""Remove a subscriber queue."""
|
|
149
|
+
with self._sub_lock:
|
|
150
|
+
try:
|
|
151
|
+
self._subscribers.remove(q)
|
|
152
|
+
except ValueError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def _notify_subscribers(self, sessions: list):
|
|
156
|
+
"""Push change event to all subscribers."""
|
|
157
|
+
event = {
|
|
158
|
+
'type': 'sessions_changed',
|
|
159
|
+
'sessions': sessions,
|
|
160
|
+
}
|
|
161
|
+
with self._sub_lock:
|
|
162
|
+
dead = []
|
|
163
|
+
for q in self._subscribers:
|
|
164
|
+
try:
|
|
165
|
+
q.put_nowait(event)
|
|
166
|
+
except queue.Full:
|
|
167
|
+
dead.append(q) # remove slow consumers
|
|
168
|
+
except Exception:
|
|
169
|
+
dead.append(q)
|
|
170
|
+
for q in dead:
|
|
171
|
+
try:
|
|
172
|
+
self._subscribers.remove(q)
|
|
173
|
+
except ValueError:
|
|
174
|
+
pass
|
|
175
|
+
# Send a None sentinel so the SSE handler unblocks, closes,
|
|
176
|
+
# and lets the browser's EventSource auto-reconnect.
|
|
177
|
+
try:
|
|
178
|
+
q.put_nowait(None)
|
|
179
|
+
except Exception:
|
|
180
|
+
logger.debug("Failed to send sentinel to dead subscriber")
|
|
181
|
+
|
|
182
|
+
def _poll_loop(self):
|
|
183
|
+
"""Main polling loop. Runs in a daemon thread."""
|
|
184
|
+
while not self._stop_event.is_set():
|
|
185
|
+
try:
|
|
186
|
+
sessions = _get_agent_sessions_from_db()
|
|
187
|
+
current_hash = _snapshot_hash(sessions)
|
|
188
|
+
|
|
189
|
+
if current_hash != self._last_hash:
|
|
190
|
+
self._last_hash = current_hash
|
|
191
|
+
self._last_sessions = sessions
|
|
192
|
+
self._notify_subscribers(sessions)
|
|
193
|
+
except Exception:
|
|
194
|
+
logger.debug("Error in gateway watcher poll loop", exc_info=True)
|
|
195
|
+
|
|
196
|
+
# Sleep in small increments so we can stop promptly
|
|
197
|
+
for _ in range(self.POLL_INTERVAL * 10):
|
|
198
|
+
if self._stop_event.is_set():
|
|
199
|
+
return
|
|
200
|
+
time.sleep(0.1)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Module-level singleton ─────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
_watcher: GatewayWatcher | None = None
|
|
206
|
+
_watcher_lock = threading.Lock()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def start_watcher():
|
|
210
|
+
"""Start the global gateway watcher (idempotent)."""
|
|
211
|
+
global _watcher
|
|
212
|
+
with _watcher_lock:
|
|
213
|
+
if _watcher is None:
|
|
214
|
+
_watcher = GatewayWatcher()
|
|
215
|
+
_watcher.start()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def stop_watcher():
|
|
219
|
+
"""Stop the global gateway watcher."""
|
|
220
|
+
global _watcher
|
|
221
|
+
with _watcher_lock:
|
|
222
|
+
if _watcher is not None:
|
|
223
|
+
_watcher.stop()
|
|
224
|
+
_watcher = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_watcher() -> GatewayWatcher | None:
|
|
228
|
+
"""Get the global watcher instance (or None if not started)."""
|
|
229
|
+
with _watcher_lock:
|
|
230
|
+
return _watcher
|