merge: upgrade to upstream v0.50.95 + keep custom additions

Upstream v0.50.95 features merged (Russian localization, slash commands,
mic toggle fix, gateway sync fix, KaTeX/Prism.js, etc.)

Custom additions preserved:
- Tier-2 agent switching commands in commands.js
- MC panel in index.html + MC CSS
- _resolve_cli_toolsets() in config.py
- Custom routes.py, server.py, boot.js, i18n.js, messages.js, workspace.js

Files with conflict resolution (took upstream, custom code in other files):
- CHANGELOG.md, config.py, commands.js, index.html, panels.js, style.css, ui.js
This commit is contained in:
Rose
2026-04-19 10:06:28 +02:00
parent 067d96bb30
commit 3bdf430413
12 changed files with 1736 additions and 2361 deletions

View File

@@ -165,7 +165,6 @@ else:
# ── Config file (reloadable -- supports profile switching) ──────────────────
_cfg_cache = {}
_cfg_lock = threading.Lock()
_cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded
def _get_config_path() -> Path:
@@ -190,7 +189,6 @@ def get_config() -> dict:
def reload_config() -> None:
"""Reload config.yaml from the active profile's directory."""
global _cfg_mtime
with _cfg_lock:
_cfg_cache.clear()
config_path = _get_config_path()
@@ -198,13 +196,9 @@ def reload_config() -> None:
import yaml as _yaml
if config_path.exists():
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
loaded = _yaml.safe_load(config_path.read_text())
if isinstance(loaded, dict):
_cfg_cache.update(loaded)
try:
_cfg_mtime = Path(config_path).stat().st_mtime
except OSError:
_cfg_mtime = 0.0
except Exception:
logger.debug("Failed to load yaml config from %s", config_path)
@@ -282,7 +276,7 @@ def _discover_default_workspace() -> Path:
DEFAULT_WORKSPACE = _discover_default_workspace()
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "") # Empty = use provider default; avoids showing unavailable OpenAI model to non-OpenAI users (#646)
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "openai/gpt-5.4-mini")
# ── Startup diagnostics ───────────────────────────────────────────────────────
@@ -386,10 +380,6 @@ MIME_MAP = {
".bmp": "image/bmp",
".pdf": "application/pdf",
".json": "application/json",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
@@ -426,35 +416,31 @@ CLI_TOOLSETS = _resolve_cli_toolsets()
# ── Model / provider discovery ───────────────────────────────────────────────
# Hardcoded fallback models (used when no config.yaml or agent is available)
# Also used as the OpenRouter model list — keep this curated to current, widely-used models.
_FALLBACK_MODELS = [
# OpenAI
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"provider": "OpenAI", "id": "openai/gpt-5.4", "label": "GPT-5.4"},
# Anthropic — 4.6 flagship + 4.5 generation
{"provider": "Anthropic", "id": "anthropic/claude-opus-4.6", "label": "Claude Opus 4.6"},
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
{"provider": "Anthropic", "id": "anthropic/claude-haiku-4-5", "label": "Claude Haiku 4.5"},
# Google — 3.x (latest preview) + 2.5 (stable GA)
{"provider": "Google", "id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
{"provider": "Google", "id": "google/gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
{"provider": "Google", "id": "google/gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
{"provider": "Google", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{"provider": "Google", "id": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
# DeepSeek
{"provider": "DeepSeek", "id": "deepseek/deepseek-chat-v3-0324", "label": "DeepSeek V3"},
{"provider": "DeepSeek", "id": "deepseek/deepseek-r1", "label": "DeepSeek R1"},
# Qwen (Alibaba) — strong coding and general models
{"provider": "Qwen", "id": "qwen/qwen3-coder", "label": "Qwen3 Coder"},
{"provider": "Qwen", "id": "qwen/qwen3.6-plus", "label": "Qwen3.6 Plus"},
# xAI
{"provider": "xAI", "id": "x-ai/grok-4.20", "label": "Grok 4.20"},
# Mistral
{"provider": "Mistral", "id": "mistralai/mistral-large-latest", "label": "Mistral Large"},
# MiniMax
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7", "label": "MiniMax M2.7"},
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"},
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"provider": "OpenAI", "id": "openai/o4-mini", "label": "o4-mini"},
{
"provider": "Anthropic",
"id": "anthropic/claude-sonnet-4.6",
"label": "Claude Sonnet 4.6",
},
{
"provider": "Anthropic",
"id": "anthropic/claude-sonnet-4-5",
"label": "Claude Sonnet 4.5",
},
{
"provider": "Anthropic",
"id": "anthropic/claude-haiku-4-5",
"label": "Claude Haiku 4.5",
},
{"provider": "Other", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{
"provider": "Other",
"id": "deepseek/deepseek-chat-v3-0324",
"label": "DeepSeek V3",
},
{"provider": "Other", "id": "meta-llama/llama-4-scout", "label": "Llama 4 Scout"},
]
# Provider display names for known Hermes provider IDs
@@ -477,9 +463,6 @@ _PROVIDER_DISPLAY = {
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"lmstudio": "LM Studio",
"mistralai": "Mistral",
"qwen": "Qwen",
"x-ai": "xAI",
}
# Well-known models per provider (used to populate dropdown for direct API providers)
@@ -492,7 +475,7 @@ _PROVIDER_MODELS = {
],
"openai": [
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"id": "gpt-5.4", "label": "GPT-5.4"},
{"id": "o4-mini", "label": "o4-mini"},
],
"openai-codex": [
{"id": "gpt-5.4", "label": "GPT-5.4"},
@@ -504,11 +487,7 @@ _PROVIDER_MODELS = {
{"id": "codex-mini-latest", "label": "Codex Mini (latest)"},
],
"google": [
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
],
"deepseek": [
{"id": "deepseek-chat-v3-0324", "label": "DeepSeek V3"},
@@ -518,7 +497,7 @@ _PROVIDER_MODELS = {
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"},
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"},
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini (via Nous)"},
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro (via Nous)"},
],
"zai": [
{"id": "glm-5.1", "label": "GLM-5.1"},
@@ -548,7 +527,7 @@ _PROVIDER_MODELS = {
{"id": "gpt-4o", "label": "GPT-4o"},
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6"},
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
],
# OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits)
"opencode-zen": [
@@ -575,11 +554,8 @@ _PROVIDER_MODELS = {
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
{"id": "claude-3-5-haiku", "label": "Claude 3.5 Haiku"},
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
{"id": "gemini-3.1-pro", "label": "Gemini 3.1 Pro"},
{"id": "gemini-3-flash", "label": "Gemini 3 Flash"},
{"id": "glm-5.1", "label": "GLM-5.1"},
{"id": "glm-5", "label": "GLM-5"},
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
@@ -599,29 +575,17 @@ _PROVIDER_MODELS = {
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
],
# 'gemini' is the hermes_cli provider ID for Google AI Studio
# Model IDs are bare — sent directly to:
# https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
"gemini": [
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
],
# Mistral — prefix used in OpenRouter model IDs (mistralai/mistral-large-latest)
"mistralai": [
{"id": "mistral-large-latest", "label": "Mistral Large"},
{"id": "mistral-small-latest", "label": "Mistral Small"},
],
# Qwen (Alibaba) — prefix used in OpenRouter model IDs (qwen/qwen3-coder)
"qwen": [
{"id": "qwen3-coder", "label": "Qwen3 Coder"},
{"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"},
],
# xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
"x-ai": [
{"id": "grok-4.20", "label": "Grok 4.20"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{"id": "gemini-2.0-flash", "label": "Gemini 2.0 Flash"},
],
# 'lmstudio' — empty list here; the build loop detects live models via
# provider_model_ids() when this list is empty. Ollama uses the same
# pattern. Custom /v1/models fetching is handled separately for base_url
# configs; this entry only exists so the "elif pid in _PROVIDER_MODELS"
# branch is taken (enabling live model injection) rather than the generic
# "unknown provider" fallback.
"lmstudio": [],
}
@@ -692,14 +656,10 @@ def resolve_model_provider(model_id: str) -> tuple:
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
# The user has explicitly pointed at a base_url, so trust their routing config.
if config_base_url:
# Only strip the provider prefix when it's a known provider namespace
# (e.g. "openai/gpt-5.4" → "gpt-5.4" for a custom OpenAI-compatible proxy).
# Unknown prefixes (e.g. "zai-org/GLM-5.1" on DeepInfra) are intrinsic to
# the model ID and must be preserved — stripping them causes model_not_found.
if prefix in _PROVIDER_MODELS:
return bare, config_provider, config_base_url
# Unknown prefix (not a named provider) — pass full model_id through.
return model_id, config_provider, config_base_url
# Strip provider prefix (e.g. 'openai/gpt-5.4' -> 'gpt-5.4') so prefixed
# model IDs from previous sessions don't break custom endpoint routing.
bare_model = model_id.split('/', 1)[-1]
return bare_model, config_provider, config_base_url
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
@@ -725,15 +685,9 @@ def get_available_models() -> dict:
'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
}
"""
# Reload config from disk if config.yaml has changed since last load.
# This ensures CLI model changes are picked up on page refresh without
# a server restart, while avoiding clearing in-memory mocks during tests. (#585)
try:
_current_mtime = Path(_get_config_path()).stat().st_mtime
except OSError:
_current_mtime = 0.0
if _current_mtime != _cfg_mtime:
reload_config()
# Reload config on every request so config.yaml changes take effect immediately
reload_config()
active_provider = None
default_model = DEFAULT_MODEL
groups = []
@@ -770,7 +724,7 @@ def get_available_models() -> dict:
try:
import json as _j
auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8"))
auth_store = _j.loads(auth_store_path.read_text())
active_provider = auth_store.get("active_provider")
except Exception:
logger.debug("Failed to load auth store from %s", auth_store_path)
@@ -818,7 +772,7 @@ def get_available_models() -> dict:
env_keys = {}
if hermes_env_path.exists():
try:
for line in hermes_env_path.read_text(encoding="utf-8").splitlines():
for line in hermes_env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
@@ -831,14 +785,11 @@ def get_available_models() -> dict:
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GLM_API_KEY",
"KIMI_API_KEY",
"DEEPSEEK_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
):
val = os.getenv(k)
if val:
@@ -851,8 +802,6 @@ def get_available_models() -> dict:
detected_providers.add("openrouter")
if all_env.get("GOOGLE_API_KEY"):
detected_providers.add("google")
if all_env.get("GEMINI_API_KEY"):
detected_providers.add("gemini")
if all_env.get("GLM_API_KEY"):
detected_providers.add("zai")
if all_env.get("KIMI_API_KEY"):
@@ -866,7 +815,20 @@ def get_available_models() -> dict:
if all_env.get("OPENCODE_GO_API_KEY"):
detected_providers.add("opencode-go")
# 3. Fetch models from custom endpoint if base_url is configured
# 3. Auto-detect LM Studio if it's running on localhost:1234 (no API key needed).
# This ensures lmstudio appears in the dropdown even when authenticated=False.
# Also auto-detect Ollama on localhost:11434.
for _host, _port, _pid in [("localhost", 1234, "lmstudio"), ("localhost", 11434, "ollama")]:
if _pid not in detected_providers:
import socket as _socket
try:
with _socket.create_connection((_host, _port), timeout=2):
detected_providers.add(_pid)
logger.debug("Auto-detected %s on %s:%d", _pid, _host, _port)
except OSError:
pass
# 3b. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
if cfg_base_url:
try:
@@ -1005,37 +967,18 @@ def get_available_models() -> dict:
# 3b. Include models from custom_providers config entries.
# These are explicitly configured and should always appear even when the
# /v1/models endpoint is unreachable or returns a subset.
#
# Each entry may carry a `name` field (e.g. "Agent37"). When present we
# use it as the dropdown section header instead of the generic "Custom"
# label. Internally we key these providers as "custom:<slug>" so that
# multiple named custom providers can coexist as separate groups.
_custom_providers_cfg = cfg.get("custom_providers", [])
# Maps "custom:<slug>" -> (display_name, [model_dicts])
_named_custom_groups: dict = {}
if isinstance(_custom_providers_cfg, list):
_seen_custom_ids = {m["id"] for m in auto_detected_models}
for _cp in _custom_providers_cfg:
if not isinstance(_cp, dict):
continue
_cp_model = _cp.get("model", "")
_cp_name = (_cp.get("name") or "").strip()
if _cp_model and _cp_model not in _seen_custom_ids:
_cp_label = _cp_model.split("/")[-1] if "/" in _cp_model else _cp_model
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
_seen_custom_ids.add(_cp_model)
if _cp_name:
# Named custom provider — own group keyed by slug
_slug = "custom:" + _cp_name.lower().replace(" ", "-")
if _slug not in _named_custom_groups:
_named_custom_groups[_slug] = (_cp_name, [])
detected_providers.add(_slug)
_named_custom_groups[_slug][1].append(
{"id": _cp_model, "label": _cp_label}
)
else:
# Unnamed — falls into the generic "Custom" bucket
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
detected_providers.add("custom")
detected_providers.add("custom")
# If the user configured a real model.provider, the base_url belongs to
# THAT provider, not to a separate "Custom" group. hermes_cli reports
@@ -1047,34 +990,10 @@ def get_available_models() -> dict:
_has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
if active_provider and active_provider != "custom" and not _has_custom_providers:
detected_providers.discard("custom")
# Also drop named custom slugs when active provider is a real named one
# and there are no custom_providers entries to show.
for _slug in list(detected_providers):
if _slug.startswith("custom:") and not _has_custom_providers:
detected_providers.discard(_slug)
elif active_provider == "custom" and _has_custom_providers:
# When the active provider is 'custom' and all custom_providers entries
# are named (i.e. every entry produced a "custom:<slug>" key), the bare
# "custom" bucket is empty noise — discard it so the dropdown only shows
# the named groups. We keep "custom" if there are unnamed entries (they
# were added to auto_detected_models and will render under the generic
# "Custom" header via the else branch in the group builder).
_has_unnamed = any(
isinstance(_cp, dict) and not (_cp.get("name") or "").strip()
for _cp in _custom_providers_cfg
)
if not _has_unnamed:
detected_providers.discard("custom")
# 5. Build model groups
if detected_providers:
for pid in sorted(detected_providers):
if pid.startswith("custom:") and pid in _named_custom_groups:
# Named custom provider — use the stored display name and its own model list
_nc_display, _nc_models = _named_custom_groups[pid]
if _nc_models:
groups.append({"provider": _nc_display, "models": _nc_models})
continue
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
if pid == "openrouter":
# OpenRouter uses provider/model format -- show the fallback list
@@ -1087,29 +1006,41 @@ def get_available_models() -> dict:
],
}
)
elif pid in _PROVIDER_MODELS or pid in cfg.get("providers", {}):
elif pid in _PROVIDER_MODELS:
# For non-default providers, prefix model IDs with @provider:model
# so resolve_model_provider() routes through that specific provider
# via resolve_runtime_provider(requested=provider).
# The default provider's models keep bare names for direct API routing.
raw_models = _PROVIDER_MODELS.get(pid, [])
# Override or merge from config.yaml if user specified explicit models
provider_cfg = cfg.get("providers", {}).get(pid, {})
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
cfg_models = provider_cfg["models"]
if isinstance(cfg_models, dict):
# config format is usually models: { "gpt-5.4": { context_length: ... } }
raw_models = [{"id": k, "label": k} for k in cfg_models.keys()]
elif isinstance(cfg_models, list):
raw_models = [{"id": k, "label": k} for k in cfg_models]
raw_models = _PROVIDER_MODELS[pid]
# Special case: _PROVIDER_MODELS[pid] is an empty list but we may have
# live models available (e.g. lmstudio on localhost:1234). Try to fetch
# them via the agent's provider_model_ids() before falling back to [].
if not raw_models and pid in ("lmstudio",):
try:
import sys as _sys, os as _os
_agent_dir = os.getenv(
"HERMES_WEBUI_AGENT_DIR",
str(HOME / ".hermes" / "hermes-agent"),
)
_agent_dir = Path(_agent_dir).expanduser().resolve()
if _agent_dir not in _sys.path:
_sys.path.insert(0, str(_agent_dir))
from hermes_cli.models import provider_model_ids as _pmi
_live_ids = _pmi(pid)
if _live_ids:
raw_models = [{"id": mid, "label": mid.split("/")[-1] if "/" in mid else mid} for mid in _live_ids]
logger.debug("Live models fetched for %s: %s", pid, _live_ids)
except Exception as _e:
logger.debug("Could not fetch live models for %s: %s", pid, _e)
_active = (active_provider or "").lower()
if _active and pid != _active:
models = []
for m in raw_models:
mid = m["id"]
# Don't double-prefix; use @provider: hint for bare names
if mid.startswith("@") or "/" in mid:
# Don't double-prefix if already @provider: tagged.
# NOTE: "/" in model name does NOT mean it's a provider/model path —
# local providers like LM Studio use "owner/model" names (e.g. "qwen/qwen3.5-35b-a3b").
if mid.startswith("@"):
models.append({"id": mid, "label": m["label"]})
else:
models.append({"id": f"@{pid}:{mid}", "label": m["label"]})
@@ -1123,9 +1054,7 @@ def get_available_models() -> dict:
)
else:
# Unknown provider -- use auto-detected models if available,
# otherwise skip it for the model dropdown. Do NOT inject the
# global default_model here: that would incorrectly imply the
# provider can serve the default model (e.g. Alibaba -> gpt-5.4-mini).
# otherwise fall back to default model placeholder
if auto_detected_models:
groups.append(
{
@@ -1133,15 +1062,26 @@ def get_available_models() -> dict:
"models": auto_detected_models,
}
)
else:
groups.append(
{
"provider": provider_name,
"models": [
{
"id": default_model,
"label": default_model.split("/")[-1],
}
],
}
)
else:
# No providers detected. Show only the configured default model so the user
# can at least send messages with their current setting. Avoid showing a
# generic multi-provider list — those models wouldn't be routable anyway.
if default_model:
label = default_model.split("/")[-1] if "/" in default_model else default_model
groups.append(
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
)
label = default_model.split("/")[-1] if "/" in default_model else default_model
groups.append(
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
)
# Ensure the user's configured default_model always appears in the dropdown.
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
@@ -1175,14 +1115,7 @@ def get_available_models() -> dict:
injected = True
break
if not injected and groups:
# Keep the default isolated rather than polluting the first
# detected provider group.
groups.append(
{
"provider": "Default",
"models": [{"id": default_model, "label": label}],
}
)
groups[0]["models"].insert(0, {"id": default_model, "label": label})
elif not groups:
groups.append(
{
@@ -1246,8 +1179,7 @@ _SETTINGS_DEFAULTS = {
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
"theme": "dark", # light | dark | system
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
"theme": "dark", # active UI theme name (no enum gate -- allows custom themes)
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
@@ -1258,76 +1190,12 @@ _SETTINGS_DEFAULTS = {
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
}
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
_SETTINGS_SKIN_VALUES = {
"default",
"ares",
"mono",
"slate",
"poseidon",
"sisyphus",
"charizard",
}
_SETTINGS_LEGACY_THEME_MAP = {
# Legacy full themes now map onto the closest supported theme + accent skin pair.
"slate": ("dark", "slate"),
"solarized": ("dark", "poseidon"),
"monokai": ("dark", "sisyphus"),
"nord": ("dark", "slate"),
"oled": ("dark", "default"),
}
def _normalize_appearance(theme, skin) -> tuple[str, str]:
"""Normalize a (theme, skin) pair, migrating legacy theme names.
Legacy migration table (from `_SETTINGS_LEGACY_THEME_MAP`):
slate → ("dark", "slate")
solarized → ("dark", "poseidon")
monokai → ("dark", "sisyphus")
nord → ("dark", "slate")
oled → ("dark", "default")
Unknown / custom theme names fall back to ("dark", "default"). This is a
behavior change vs. the pre-PR-#627 state, where the `theme` field was
open-ended ("no enum gate -- allows custom themes"). Users who set a
custom CSS theme via `data-theme` will need to re-apply via skin or
custom CSS — see CHANGELOG entry for details.
The same mapping is mirrored in `static/boot.js` (`_LEGACY_THEME_MAP`)
so client and server normalize identically; keep them in sync.
"""
raw_theme = theme.strip().lower() if isinstance(theme, str) else ""
raw_skin = skin.strip().lower() if isinstance(skin, str) else ""
legacy = _SETTINGS_LEGACY_THEME_MAP.get(raw_theme)
if legacy:
next_theme, legacy_skin = legacy
elif raw_theme in _SETTINGS_THEME_VALUES:
next_theme, legacy_skin = raw_theme, "default"
else:
# Unknown themes used to exist; default to dark so upgrades stay visually stable.
next_theme, legacy_skin = "dark", "default"
next_skin = (
raw_skin
if raw_skin in _SETTINGS_SKIN_VALUES
else legacy_skin
)
return next_theme, next_skin
def load_settings() -> dict:
"""Load settings from disk, merging with defaults for any missing keys."""
settings = dict(_SETTINGS_DEFAULTS)
stored = None
try:
settings_exists = SETTINGS_FILE.exists()
except OSError:
# PermissionError or other OS-level error (e.g. UID mismatch in Docker)
# Treat as missing — start with defaults rather than crashing.
logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE)
settings_exists = False
if settings_exists:
if SETTINGS_FILE.exists():
try:
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(stored, dict):
@@ -1340,10 +1208,6 @@ def load_settings() -> dict:
)
except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
settings["theme"], settings["skin"] = _normalize_appearance(
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
)
return settings
@@ -1368,10 +1232,6 @@ _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8}
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
current = load_settings()
pending_theme = current.get("theme")
pending_skin = current.get("skin")
theme_was_explicit = False
skin_was_explicit = False
# Handle _set_password: hash and store as password_hash
raw_pw = settings.pop("_set_password", None)
if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
@@ -1384,16 +1244,6 @@ def save_settings(settings: dict) -> dict:
current["password_hash"] = None
for k, v in settings.items():
if k in _SETTINGS_ALLOWED_KEYS:
if k == "theme":
if isinstance(v, str) and v.strip():
pending_theme = v
theme_was_explicit = True
continue
if k == "skin":
if isinstance(v, str) and v.strip():
pending_skin = v
skin_was_explicit = True
continue
# Validate enum-constrained keys
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
continue
@@ -1406,13 +1256,6 @@ def save_settings(settings: dict) -> dict:
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
current[k] = v
theme_value = pending_theme
skin_value = pending_skin
if theme_was_explicit and not skin_was_explicit:
raw_theme = pending_theme.strip().lower() if isinstance(pending_theme, str) else ""
if raw_theme not in _SETTINGS_THEME_VALUES:
skin_value = None
current["theme"], current["skin"] = _normalize_appearance(theme_value, skin_value)
current["default_workspace"] = str(
resolve_default_workspace(current.get("default_workspace"))
@@ -1431,22 +1274,13 @@ def save_settings(settings: dict) -> dict:
# Apply saved settings on startup (override env-derived defaults)
# Exception: if HERMES_WEBUI_DEFAULT_WORKSPACE is explicitly set in the
# environment, it wins over whatever settings.json has stored. Persisted
# config must never shadow an explicit env-var override (Docker deployments
# rely on this — otherwise deleting settings.json is the only escape).
_startup_settings = load_settings()
try:
_settings_file_exists = SETTINGS_FILE.exists()
except OSError:
_settings_file_exists = False
if _settings_file_exists:
if SETTINGS_FILE.exists():
if _startup_settings.get("default_model"):
DEFAULT_MODEL = _startup_settings["default_model"]
if not os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
DEFAULT_WORKSPACE = resolve_default_workspace(
_startup_settings.get("default_workspace")
)
DEFAULT_WORKSPACE = resolve_default_workspace(
_startup_settings.get("default_workspace")
)
if _startup_settings.get("default_workspace") != str(DEFAULT_WORKSPACE):
_startup_settings["default_workspace"] = str(DEFAULT_WORKSPACE)
try:

View File

@@ -53,6 +53,7 @@ from api.helpers import (
redact_session_data,
_redact_text,
)
from api import mc as _mc
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re
@@ -755,6 +756,20 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/memory":
return _handle_memory_read(handler)
# ── Mission Control (GET) ──
if parsed.path == "/api/mc/status":
return j(handler, _mc.get_dashboard_status())
if parsed.path == "/api/mc/priorities":
return j(handler, {"priorities": _mc.get_priorities()})
if parsed.path == "/api/mc/tasks":
return j(handler, {"tasks": _mc.get_tasks()})
if parsed.path == "/api/mc/feed":
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, {"feed": _mc.get_feed(limit=limit)})
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
from api.profiles import list_profiles_api, get_active_profile_name
@@ -772,6 +787,52 @@ def handle_get(handler, parsed) -> bool:
{"name": get_active_profile_name(), "path": str(get_active_hermes_home())},
)
# ── Commands API (dynamic from Hermes COMMAND_REGISTRY) ──
if parsed.path == "/api/commands":
import sys as _sys
from pathlib import Path as _P
# Add hermes-agent to path so we can import the registry
_agent_path = (_P(__file__).parent.parent / "hermes-agent").resolve()
if str(_agent_path) not in _sys.path:
_sys.path.insert(0, str(_agent_path))
try:
from hermes_cli.commands import COMMAND_REGISTRY
# Map Hermes category names to WebUI category labels
_cat_map = {
"Session": "Session",
"Config": "Configuration",
"Tool": "Tools & Skills",
"Skill": "Tools & Skills",
"Info": "Info",
"Agent": "Agents",
"System": "System",
}
_by_cat: dict[str, list] = {}
for cmd in COMMAND_REGISTRY:
if getattr(cmd, "cli_only", False):
continue # skip CLI-only commands
cat = _cat_map.get(cmd.category, "Info")
if cat not in _by_cat:
_by_cat[cat] = []
_by_cat[cat].append({
"name": cmd.name,
"desc": cmd.description,
"arg": getattr(cmd, "args_hint", None) or "(none)",
"aliases": list(getattr(cmd, "aliases", []) or []),
})
# Always include Agents section even if empty (Tier-2 agents are added client-side)
if "Agents" not in _by_cat:
_by_cat["Agents"] = []
return j(handler, {"categories": _by_cat})
except Exception as e:
return j(handler, {"error": str(e)}, status=500)
return False # 404
@@ -1047,6 +1108,67 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/memory/write":
return _handle_memory_write(handler, body)
# ── Mission Control (POST) ──
if parsed.path == "/api/mc/priority/create":
name = body.get("name", "").strip()
if not name:
return bad(handler, "name is required")
priority = _mc.create_priority(name, body.get("color", "#808080"))
return j(handler, {"ok": True, "priority": priority})
if parsed.path == "/api/mc/priority/update":
pid = body.get("id")
if not pid:
return bad(handler, "id is required")
updated = _mc.update_priority(
pid,
name=body.get("name"),
color=body.get("color"),
done=body.get("done"),
)
if not updated:
return bad(handler, "priority not found", 404)
return j(handler, {"ok": True, "priority": updated})
if parsed.path == "/api/mc/priority/delete":
pid = body.get("id")
if not pid:
return bad(handler, "id is required")
deleted = _mc.delete_priority(pid)
return j(handler, {"ok": deleted})
if parsed.path == "/api/mc/task/create":
title = body.get("title", "").strip()
if not title:
return bad(handler, "title is required")
task = _mc.create_task(
title,
priority=body.get("priority", 1),
status=body.get("status", "backlog"),
)
return j(handler, {"ok": True, "task": task})
if parsed.path == "/api/mc/task/update":
tid = body.get("id")
if not tid:
return bad(handler, "id is required")
updated = _mc.update_task(
tid,
title=body.get("title"),
priority=body.get("priority"),
status=body.get("status"),
)
if not updated:
return bad(handler, "task not found", 404)
return j(handler, {"ok": True, "task": updated})
if parsed.path == "/api/mc/task/delete":
tid = body.get("id")
if not tid:
return bad(handler, "id is required")
deleted = _mc.delete_task(tid)
return j(handler, {"ok": deleted})
# ── Profile API (POST) ──
if parsed.path == "/api/profile/switch":
name = body.get("name", "").strip()
@@ -1113,6 +1235,57 @@ def handle_post(handler, parsed) -> bool:
except RuntimeError as e:
return bad(handler, str(e), 409)
# ── Gateway API ──
if parsed.path == "/api/gateways":
# GET - list all gateways
from api.gateways import list_gateways_api
return j(handler, {"gateways": list_gateways_api()})
if parsed.path == "/api/gateway/start":
name = body.get("name", "").strip()
if not name:
return bad(handler, "name is required")
from api.gateways import start_gateway_api
try:
result = start_gateway_api(name)
return j(handler, result)
except (ValueError, RuntimeError) as e:
return bad(handler, str(e))
if parsed.path == "/api/gateway/stop":
name = body.get("name", "").strip()
if not name:
return bad(handler, "name is required")
from api.gateways import stop_gateway_api
try:
result = stop_gateway_api(name)
return j(handler, result)
except (ValueError, RuntimeError) as e:
return bad(handler, str(e))
if parsed.path == "/api/gateway/restart":
name = body.get("name", "").strip()
if not name:
return bad(handler, "name is required")
from api.gateways import restart_gateway_api
try:
result = restart_gateway_api(name)
return j(handler, result)
except (ValueError, RuntimeError) as e:
return bad(handler, str(e))
if parsed.path == "/api/gateway/add":
name = body.get("name", "").strip()
if not name:
return bad(handler, "name is required")
gw_type = body.get("type", "telegram").strip()
from api.gateways import add_gateway_api
try:
result = add_gateway_api(name, gw_type)
return j(handler, result)
except (ValueError, RuntimeError, FileExistsError) as e:
return bad(handler, str(e))
# ── Settings (POST) ──
if parsed.path == "/api/settings":
from api.auth import (