feat(chat): add WebUI prefill script hook
This commit is contained in:
1 parent
e50d6ccc8c
commit
fa57868431
5 files changed
+196
-15
No files matched your search
@@ -122,6 +122,9 @@ Environment variables controlling behavior:
|
||||
HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default
|
||||
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
|
||||
HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard
|
||||
HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context
|
||||
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or text prefill context
|
||||
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)
|
||||
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
|
||||
|
||||
Test isolation environment variables (set by conftest.py):
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- WebUI can now opt into a `webui_prefill_messages_script` / `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` hook for dynamic browser-turn prefill context from local notes or recall systems. The script output is normalized to ephemeral prefill messages and browser status still hides message bodies while redacting script errors.
|
||||
|
||||
## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -121,6 +121,38 @@ For self-hosted VM or homelab installs, `ctl.sh` wraps the common daemon lifecyc
|
||||
|
||||
`ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`.
|
||||
|
||||
### Optional session recall prefill
|
||||
|
||||
WebUI can attach ephemeral prefill messages to new browser-originated
|
||||
agent turns. This is useful when a deployment already has a local recall script
|
||||
for Joplin, Obsidian, Notion, llm-wiki, or another third-party notes source and
|
||||
wants the browser chat to receive the same high-level context as other Hermes
|
||||
surfaces.
|
||||
|
||||
Static JSON remains supported through `prefill_messages_file` or
|
||||
`HERMES_PREFILL_MESSAGES_FILE`. For dynamic recall, opt in explicitly with a
|
||||
WebUI-specific script hook:
|
||||
|
||||
```yaml
|
||||
webui_prefill_messages_script:
|
||||
- python3
|
||||
- /path/to/notes_recall.py
|
||||
webui_prefill_messages_script_timeout: 5
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT="python3 /path/to/notes_recall.py" \
|
||||
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 \
|
||||
./ctl.sh restart
|
||||
```
|
||||
|
||||
The script may print either an OpenAI-style JSON message list, a JSON object with
|
||||
a `messages` list, or plain text; plain text is wrapped as one `system` prefill
|
||||
message. The browser only receives a compact status event (`source`, `label`,
|
||||
message count, and redacted errors), never the prefill message bodies.
|
||||
|
||||
The bootstrap will:
|
||||
|
||||
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
||||
|
||||
+92
-14
@@ -10,7 +10,9 @@ import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -285,29 +287,105 @@ def _resolve_prefill_path(raw: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _prefill_not_configured() -> dict:
|
||||
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
|
||||
|
||||
|
||||
def _load_prefill_messages_file(file_raw: str, *, source: str = "file", status: str = "loaded") -> dict:
|
||||
path = _resolve_prefill_path(file_raw)
|
||||
label = path.name or "prefill file"
|
||||
if not path.exists():
|
||||
return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"}
|
||||
try:
|
||||
messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8")))
|
||||
return {"status": status, "source": source, "label": label, "messages": messages, "message_count": len(messages)}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
|
||||
|
||||
|
||||
def _prefill_script_timeout(config_data: dict) -> float:
|
||||
raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT", "") or str(config_data.get("webui_prefill_messages_script_timeout") or "")
|
||||
try:
|
||||
return max(0.1, min(float(raw or 5), 30.0))
|
||||
except Exception:
|
||||
return 5.0
|
||||
|
||||
|
||||
def _prefill_script_command(raw) -> list[str]:
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return [str(part) for part in raw if str(part)]
|
||||
parts = shlex.split(str(raw or ""))
|
||||
if not parts:
|
||||
return []
|
||||
# A single script path mirrors prefill_messages_file path resolution. More
|
||||
# complex commands keep their argv untouched so admins can pass arguments.
|
||||
if len(parts) == 1:
|
||||
parts[0] = str(_resolve_prefill_path(parts[0]))
|
||||
return parts
|
||||
|
||||
|
||||
def _messages_from_prefill_script_output(text: str) -> list[dict]:
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(stripped)
|
||||
except Exception:
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
payload = payload.get("messages")
|
||||
messages = _valid_prefill_messages(payload)
|
||||
if messages:
|
||||
return messages
|
||||
return [{"role": "system", "content": stripped}]
|
||||
|
||||
|
||||
def _load_prefill_messages_script(config_data: dict) -> dict:
|
||||
script_raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT", "") or config_data.get("webui_prefill_messages_script")
|
||||
if not script_raw:
|
||||
return _prefill_not_configured()
|
||||
command = _prefill_script_command(script_raw)
|
||||
label = Path(command[0]).name if command else "prefill script"
|
||||
if not command:
|
||||
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script is empty"}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=_prefill_script_timeout(config_data),
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script timed out"}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
|
||||
if proc.returncode != 0:
|
||||
err = _redact_prefill_status_text(proc.stderr or proc.stdout or f"prefill script exited {proc.returncode}")
|
||||
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": err}
|
||||
messages = _messages_from_prefill_script_output(proc.stdout)
|
||||
return {"status": "loaded", "source": "script", "label": label, "messages": messages, "message_count": len(messages)}
|
||||
|
||||
|
||||
def _load_webui_prefill_context(
|
||||
config_data: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Load configured WebUI session prefill messages.
|
||||
|
||||
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does
|
||||
not execute a configured prefill script here; session recall that requires
|
||||
code execution should go through the normal MCP/tool path instead of an
|
||||
always-on per-turn subprocess before SSE starts.
|
||||
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI also
|
||||
supports its own explicitly opt-in script hook so admins can bridge Joplin,
|
||||
Obsidian, Notion, llm-wiki, or another local notes source into ephemeral
|
||||
turn context without baking any one note provider into the WebUI.
|
||||
"""
|
||||
cfg = config_data if isinstance(config_data, dict) else get_config()
|
||||
script_context = _load_prefill_messages_script(cfg)
|
||||
if script_context.get("status") != "not_configured":
|
||||
return script_context
|
||||
file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
|
||||
if file_raw:
|
||||
path = _resolve_prefill_path(file_raw)
|
||||
label = path.name or "prefill file"
|
||||
if not path.exists():
|
||||
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"}
|
||||
try:
|
||||
messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8")))
|
||||
return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
|
||||
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
|
||||
return _load_prefill_messages_file(file_raw)
|
||||
return _prefill_not_configured()
|
||||
|
||||
|
||||
def _public_prefill_context_status(prefill_context: dict) -> dict:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
|
||||
@@ -32,7 +34,7 @@ def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
|
||||
]
|
||||
|
||||
|
||||
def test_prefill_script_config_is_ignored_in_webui(tmp_path):
|
||||
def test_prefill_script_config_is_not_used_without_webui_opt_in(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
script = tmp_path / "recall.py"
|
||||
@@ -49,6 +51,68 @@ def test_prefill_script_config_is_ignored_in_webui(tmp_path):
|
||||
}
|
||||
|
||||
|
||||
def test_webui_prefill_script_loads_json_messages(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
script = tmp_path / "recall.py"
|
||||
script.write_text(
|
||||
"import json\n"
|
||||
"print(json.dumps([{'role': 'system', 'content': 'Joplin recall'}, {'role': 'tool', 'content': 'drop me'}]))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]})
|
||||
|
||||
assert result["status"] == "loaded"
|
||||
assert result["source"] == "script"
|
||||
assert result["label"] == Path(sys.executable).name
|
||||
assert result["messages"] == [{"role": "system", "content": "Joplin recall"}]
|
||||
|
||||
|
||||
def test_webui_prefill_script_wraps_plain_text_for_any_notes_source(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
script = tmp_path / "obsidian_recall.py"
|
||||
script.write_text("print('Obsidian project note context')\n", encoding="utf-8")
|
||||
|
||||
result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]})
|
||||
|
||||
assert result["status"] == "loaded"
|
||||
assert result["source"] == "script"
|
||||
assert result["messages"] == [{"role": "system", "content": "Obsidian project note context"}]
|
||||
|
||||
|
||||
def test_webui_prefill_script_errors_are_redacted(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
script = tmp_path / "bad_recall.py"
|
||||
script.write_text("import sys; print('token=redaction-test-placeholder', file=sys.stderr); raise SystemExit(2)\n", encoding="utf-8")
|
||||
|
||||
result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]})
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["source"] == "script"
|
||||
assert "redaction-test-placeholder" not in result["error"]
|
||||
assert "[REDACTED]" in result["error"]
|
||||
|
||||
|
||||
def test_webui_prefill_script_takes_precedence_over_static_file(tmp_path):
|
||||
from api.streaming import _load_webui_prefill_context
|
||||
|
||||
prefill = tmp_path / "prefill.json"
|
||||
prefill.write_text(json.dumps([{"role": "system", "content": "static"}]), encoding="utf-8")
|
||||
script = tmp_path / "recall.py"
|
||||
script.write_text("print('dynamic')\n", encoding="utf-8")
|
||||
|
||||
result = _load_webui_prefill_context({
|
||||
"prefill_messages_file": str(prefill),
|
||||
"webui_prefill_messages_script": [sys.executable, str(script)],
|
||||
})
|
||||
|
||||
assert result["source"] == "script"
|
||||
assert result["messages"] == [{"role": "system", "content": "dynamic"}]
|
||||
|
||||
|
||||
def test_public_prefill_status_strips_message_bodies():
|
||||
from api.streaming import _public_prefill_context_status
|
||||
|
||||
|
||||
Reference in new issue
Block a user