From 3bdf4304134e02a0f226a7ec65b719bf8278f39d Mon Sep 17 00:00:00 2001 From: Rose Date: Sun, 19 Apr 2026 10:06:28 +0200 Subject: [PATCH] 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 --- api/config.py | 398 ++++---------- api/routes.py | 173 ++++++ server.py | 19 + static/boot.js | 3 + static/commands.js | 498 ++++++----------- static/i18n.js | 62 +++ static/index.html | 318 ++++++----- static/messages.js | 5 +- static/panels.js | 360 ++++++++++-- static/style.css | 1285 ++++++++++++------------------------------- static/ui.js | 825 +++++++-------------------- static/workspace.js | 151 ++++- 12 files changed, 1736 insertions(+), 2361 deletions(-) diff --git a/api/config.py b/api/config.py index 464e3dd..eb0ba33 100644 --- a/api/config.py +++ b/api/config.py @@ -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:" so that - # multiple named custom providers can coexist as separate groups. _custom_providers_cfg = cfg.get("custom_providers", []) - # Maps "custom:" -> (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:" 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: diff --git a/api/routes.py b/api/routes.py index 3f101f1..d24c558 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 ( diff --git a/server.py b/server.py index 1a14ef8..301af2e 100644 --- a/server.py +++ b/server.py @@ -86,6 +86,25 @@ class Handler(BaseHTTPRequestHandler): def main() -> None: + # Load ~/.hermes/.env into os.environ so API keys are available + # (mirrors what run_agent.py does via load_hermes_dotenv). + from pathlib import Path as _P + import os as _os + _env_file = _P(_os.environ.get('HERMES_HOME', str(_P.home() / '.hermes'))) / '.env' + if _env_file.exists(): + try: + with open(_env_file) as _f: + for _line in _f: + _line = _line.strip() + if _line and not _line.startswith('#') and '=' in _line: + _k, _v = _line.split('=', 1) + _k = _k.strip() + _v = _v.strip().strip('"').strip("'") + if _k and _k not in _os.environ: + _os.environ[_k] = _v + except Exception: + pass + from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND print_startup_config() diff --git a/static/boot.js b/static/boot.js index f626db5..e869311 100644 --- a/static/boot.js +++ b/static/boot.js @@ -422,6 +422,7 @@ function clearPreview(){ const pc=$('previewCode');if(pc)pc.textContent=''; const pp=$('previewPathText');if(pp)pp.textContent=''; const ft=$('fileTree');if(ft)ft.style.display=''; + const wsSearchClear=$('wsSearchWrap');if(wsSearchClear)wsSearchClear.style.display=''; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; // Restore directory breadcrumb after closing file preview if(typeof renderBreadcrumb==='function') renderBreadcrumb(); @@ -804,6 +805,8 @@ function applyBotName(){ if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; // Fetch available models from server and populate dropdown dynamically await populateModelDropdown(); + // Load commands from Hermes COMMAND_REGISTRY before enabling input + await loadCommands(); // Restore last-used model preference const savedModel=localStorage.getItem('hermes-webui-model'); if(savedModel && $('modelSelect')){ diff --git a/static/commands.js b/static/commands.js index 80a09c5..5f25b03 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1,26 +1,78 @@ // ── Slash commands ────────────────────────────────────────────────────────── -// Built-in commands intercepted before send(). Each command runs locally -// (no round-trip to the agent) and shows feedback via toast or local message. +// Commands are loaded dynamically from GET /api/commands (Hermes COMMAND_REGISTRY). +// Tier-2 Agent commands and passthrough handlers are added client-side. +// Each command either runs locally or is forwarded as a message to the agent. -const COMMANDS=[ - {name:'help', desc:t('cmd_help'), fn:cmdHelp}, - {name:'clear', desc:t('cmd_clear'), fn:cmdClear}, - {name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]'}, - {name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact}, - {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'}, - {name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'}, - {name:'new', desc:t('cmd_new'), fn:cmdNew}, - {name:'usage', desc:t('cmd_usage'), fn:cmdUsage}, - {name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'}, - {name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'}, - {name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'}, - {name:'stop', desc:t('cmd_stop'), fn:cmdStop}, - {name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'}, - {name:'retry', desc:t('cmd_retry'), fn:cmdRetry}, - {name:'undo', desc:t('cmd_undo'), fn:cmdUndo}, - {name:'status', desc:t('cmd_status'), fn:cmdStatus}, - {name:'voice', desc:t('cmd_voice'), fn:cmdVoice}, -]; +let COMMANDS=[]; // Loaded async via loadCommands() + +// Map Hermes passthrough command names to their fn. +// These commands are forwarded to the agent as-is. +const _PASSTHROUGH=['retry','undo','title','branch','stop','background','btw', + 'queue','status','profile','resume','snapshot','rollback','provider', + 'yolo','reasoning','fast','voice','reload','reload-mcp','cron','browser', + 'plugins','insights','platforms','debug','update','image','inbox']; + +function _fnFor(name){ + if(name==='help'||name==='commands') return cmdHelp; + if(name==='clear') return cmdClear; + if(name==='compact'||name==='compress') return cmdCompact; + if(name==='model') return cmdModel; + if(name==='workspace') return cmdWorkspace; + if(name==='new') return cmdNew; + if(name==='usage') return cmdUsage; + if(name==='theme') return cmdTheme; + if(name==='skills') return cmdSkills; + if(name==='personality') return cmdPersonality; + if(_PASSTHROUGH.includes(name)) return cmdPassthrough; + // Fallback: passthrough unknown commands so new Hermes commands work without JS changes + return cmdPassthrough; +} + +/** + * Fetch commands from Hermes COMMAND_REGISTRY and merge with WebUI-specific commands. + * Called once at boot time. + */ +async function loadCommands(){ + try{ + const data=await api('/api/commands'); + if(data.error) throw new Error(data.error); + const cats=data.categories||{}; + + // Flatten all categories into COMMANDS + const merged=[]; + for(const [catName,cmds] of Object.entries(cats)){ + for(const c of cmds){ + merged.push({name:c.name, desc:c.desc, arg:c.arg||'(none)', + aliases:c.aliases||[], fn:_fnFor(c.name)}); + } + } + + // ── Tier-2 Domain Agents (WebUI-specific, override API entries) ── + // Dedup: remove any API entries that would clash with Tier-2 agents + const _agentNames=['sunflower','lotus','forget-me-not','iris','ivy', + 'dandelion','root','back','inbox']; + // Remove API entries for agent names (they may already be in the registry + // from the API if agents registered themselves as commands there) + const filtered=merged.filter(c=>!_agentNames.includes(c.name)); + // Add Tier-2 agents (these override any API entries of the same name) + filtered.push( + {name:'sunflower', desc:'🌻 Finance, Wealth & Subscriptions', fn:cmdAgent, arg:'message'}, + {name:'lotus', desc:'🪷 Health, Fitness & Recovery', fn:cmdAgent, arg:'message'}, + {name:'forget-me-not', desc:'🌼 Calendar, Time & Social', fn:cmdAgent, arg:'message'}, + {name:'iris', desc:'⚜️ Career, Learning & Focus', fn:cmdAgent, arg:'message'}, + {name:'ivy', desc:'🌿 Smart Home & Environment', fn:cmdAgent, arg:'message'}, + {name:'dandelion', desc:'🛡 Communication Triage & Gatekeeping',fn:cmdAgent, arg:'message'}, + {name:'root', desc:'🌳 DevOps, Logs & System Health', fn:cmdAgent, arg:'message'}, + {name:'back', desc:'🌹 Return to Rose (orchestrator)', fn:cmdAgent, arg:'message'}, + ); + + COMMANDS=filtered; + }catch(e){ + console.warn('[commands] Failed to load from API, using fallback:',e.message); + // Fallback: empty — user can still type commands manually + COMMANDS=[]; + } +} function parseCommand(text){ if(!text.startsWith('/'))return null; @@ -41,217 +93,108 @@ function executeCommand(text){ function getMatchingCommands(prefix){ const q=prefix.toLowerCase(); - const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'})); - const seen=new Set(matches.map(c=>c.name)); - for(const skill of _skillCommandCache){ - if(!skill.name.startsWith(q)||seen.has(skill.name))continue; - matches.push(skill); - } - return matches; + return COMMANDS.filter(c=>{ + if(c.name.startsWith(q)) return true; + // Also match aliases + if(c.aliases&&c.aliases.some(a=>a.startsWith(q))) return true; + return false; + }); } -function _compressionAnchorMessageKey(m){ - if(!m||!m.role||m.role==='tool') return null; - let content=''; - try{ - content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||''); - }catch(_){ - content=String(m.content||''); - } - const norm=content.replace(/\s+/g,' ').trim().slice(0,160); - const ts=m._ts||m.timestamp||null; - const attachments=Array.isArray(m.attachments)?m.attachments.length:0; - if(!norm && !attachments && !ts) return null; - return {role:String(m.role||''), ts, text:norm, attachments}; +// ── Generic passthrough: send command text directly to agent ──────────── + +function cmdPassthrough(args){ + const parsed=parseCommand($('msg').value); + if(!parsed)return; + // Forward the raw command to the agent as a regular message + $('msg').value=$('msg').value; // keep as-is + send(); } -// ── Command handlers ──────────────────────────────────────────────────────── +// ── Command handlers ──────────────────────────────────────────────────── function cmdHelp(){ - const lines=COMMANDS.map(c=>{ - const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : ''; - return ` /${c.name}${usage} — ${c.desc}`; + // Infer categories from command names (backwards-compatible with hardcoded categories) + const categories={'Session':[],'Configuration':[],'Tools & Skills':[],'Info':[],'Agents':[]}; + COMMANDS.forEach(c=>{ + let cat='Info'; + if(['new','clear','compact','compress','retry','undo','title','branch', + 'stop','background','btw','queue','status','profile','resume', + 'snapshot','rollback'].includes(c.name)) cat='Session'; + else if(['model','provider','personality','workspace','theme','yolo', + 'reasoning','fast','voice','reload','reload-mcp'].includes(c.name)) cat='Configuration'; + else if(['skills','cron','browser','plugins'].includes(c.name)) cat='Tools & Skills'; + else if(['sunflower','lotus','forget-me-not','iris','ivy','dandelion', + 'root','back','inbox'].includes(c.name)) cat='Agents'; + if(!categories[cat])categories[cat]=[]; + categories[cat].push(c); }); - const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')}; + const lines=[]; + for(const [cat,cmds] of Object.entries(categories)){ + if(!cmds.length)continue; + lines.push(`\n**${cat}**`); + cmds.forEach(c=>{ + const usage=c.arg&&c.arg!=='(none)'?` <${c.arg}>`:''; + lines.push(` /${c.name}${usage} — ${c.desc}`); + }); + } + const msg={role:'assistant',content:'Available commands:\n'+lines.join('\n')}; S.messages.push(msg); renderMessages(); - showToast(t('type_slash')); + showToast('Type / to see commands'); } function cmdClear(){ if(!S.session)return; S.messages=[];S.toolCalls=[]; clearLiveToolCards(); - if(typeof clearCompressionUi==='function') clearCompressionUi(); renderMessages(); $('emptyState').style.display=''; showToast(t('conversation_cleared')); } async function cmdModel(args){ - if(!args){showToast(t('model_usage'));return;} + if(!args){showToast('Usage: /model ');return;} const sel=$('modelSelect'); if(!sel)return; const q=args.toLowerCase(); - // Fuzzy match: find first option whose label or value contains the query let match=null; for(const opt of sel.options){ if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){ match=opt.value;break; } } - if(!match){showToast(t('no_model_match')+`"${args}"`);return;} + if(!match){showToast('No model matching "'+args+'"');return;} sel.value=match; await sel.onchange(); showToast(t('switched_to')+match); } async function cmdWorkspace(args){ - if(!args){showToast(t('workspace_usage'));return;} + if(!args){showToast('Usage: /workspace ');return;} try{ const data=await api('/api/workspaces'); const q=args.toLowerCase(); const ws=(data.workspaces||[]).find(w=> (w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q) ); - if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;} + if(!ws){showToast('No workspace matching "'+args+'"');return;} if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path); else showToast(t('switched_workspace')+(ws.name||ws.path)); }catch(e){showToast(t('workspace_switch_failed')+e.message);} } async function cmdNew(){ - if(typeof clearCompressionUi==='function') clearCompressionUi(); await newSession(); await renderSessionList(); $('msg').focus(); showToast(t('new_session')); } -async function _runManualCompression(focusTopic){ - if(!S.session){showToast(t('no_active_session'));return;} - let visibleCount=0; - try{ - const sid=S.session.session_id; - // Preflight: verify the viewed session still exists before compressing. - // This avoids a confusing "not found" toast when the UI is stale. - try{ - const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`); - if(!live||!live.session||live.session.session_id!==sid){ - throw new Error('session no longer available'); - } - S.session=live.session; - S.messages=live.session.messages||[]; - S.toolCalls=live.session.tool_calls||[]; - }catch(preflightErr){ - if(typeof clearCompressionUi==='function') clearCompressionUi(); - if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); - if(typeof setBusy==='function') setBusy(false); - if(typeof setComposerStatus==='function') setComposerStatus(''); - renderMessages(); - showToast('Compression failed: '+(preflightErr.message||'session no longer available')); - return; - } - if(typeof setBusy==='function') setBusy(true); - const body={session_id:sid}; - if(focusTopic) body.focus_topic=focusTopic; - const visibleMessages=(S.messages||[]).filter(m=>{ - if(!m||!m.role||m.role==='tool') return false; - if(m.role==='assistant'){ - const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; - const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); - if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true; - } - return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length; - }); - visibleCount=visibleMessages.length; - const anchorVisibleIdx=Math.max(0, visibleCount - 1); - const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null); - const commandText=focusTopic?`/compress ${focusTopic}`:'/compress'; - if(typeof setCompressionUi==='function'){ - setCompressionUi({ - sessionId:S.session.session_id, - phase:'running', - focusTopic:focusTopic||'', - commandText, - beforeCount:visibleCount, - anchorVisibleIdx, - anchorMessageKey, - }); - } - if(typeof setComposerStatus==='function') setComposerStatus(t('compressing')); - renderMessages(); - const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)}); - if(data&&data.session){ - const currentSid=S.session&&S.session.session_id; - if(data.session.session_id&&data.session.session_id!==currentSid){ - await loadSession(data.session.session_id); - }else{ - S.session=data.session; - S.messages=data.session.messages||[]; - S.toolCalls=data.session.tool_calls||[]; - clearLiveToolCards(); - localStorage.setItem('hermes-webui-session',S.session.session_id); - syncTopbar(); - renderMessages(); - await renderSessionList(); - updateQueueBadge(S.session.session_id); - } - } - const summary=data&&data.summary; - if(typeof setCompressionUi==='function'&&S.session){ - const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m)); - const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):''; - const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : ''; - // Prefer the persisted compaction handoff when it already exists in session state. - // The short summary fallback is only for environments where that message is unavailable. - const referenceText=messageRef || summaryRef; - const effectiveFocus=(data&&data.focus_topic)||focusTopic||''; - setCompressionUi({ - sessionId:S.session.session_id, - phase:'done', - focusTopic:effectiveFocus, - commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress', - beforeCount:visibleCount, - summary:summary||null, - referenceText, - anchorVisibleIdx: data?.session?.compression_anchor_visible_idx, - anchorMessageKey: data?.session?.compression_anchor_message_key||null, - }); - } - if(typeof setComposerStatus==='function') setComposerStatus(''); - renderMessages(); - if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); - }catch(e){ - if(typeof setCompressionUi==='function'){ - const currentSid=S.session&&S.session.session_id; - setCompressionUi({ - sessionId:currentSid||'', - phase:'error', - focusTopic:(focusTopic||'').trim(), - commandText:focusTopic?`/compress ${focusTopic}`:'/compress', - beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length, - errorText:`Compression failed: ${e.message}`, - anchorVisibleIdx: Math.max(0, visibleCount - 1), - anchorMessageKey:null, - }); - } - if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); - if(typeof setBusy==='function') setBusy(false); - if(typeof setComposerStatus==='function') setComposerStatus(''); - renderMessages(); - showToast('Compression failed: '+e.message); - return; - } - if(typeof setBusy==='function') setBusy(false); -} - -async function cmdCompress(args){ - await _runManualCompression((args||'').trim()); -} - -async function cmdCompact(args){ - await _runManualCompression((args||'').trim()); +function cmdCompact(){ + $('msg').value='Please compress and summarize the conversation context to free up space.'; + send(); + showToast(t('compressing')); } async function cmdUsage(){ @@ -260,7 +203,6 @@ async function cmdUsage(){ try{ await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})}); }catch(e){} - // Update the settings checkbox if the panel is open const cb=$('settingsShowTokenUsage'); if(cb) cb.checked=next; renderMessages(); @@ -268,48 +210,18 @@ async function cmdUsage(){ } async function cmdTheme(args){ - const themes=['system','dark','light']; - const skins=(_SKINS||[]).map(s=>s.name.toLowerCase()); - const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{}); - const val=(args||'').toLowerCase().trim(); - // Check if it's a theme - if(themes.includes(val)||legacyThemes.includes(val)){ - const appearance=_normalizeAppearance( - val, - legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin') - ); - localStorage.setItem('hermes-theme',appearance.theme); - localStorage.setItem('hermes-skin',appearance.skin); - _applyTheme(appearance.theme); - _applySkin(appearance.skin); - try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){} - const sel=$('settingsTheme'); - if(sel)sel.value=appearance.theme; - const skinSel=$('settingsSkin'); - if(skinSel)skinSel.value=appearance.skin; - if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme); - if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin); - showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:'')); + const themes=['system','dark','light','slate','solarized','monokai','nord','oled']; + if(!args||!themes.includes(args.toLowerCase())){ + showToast('Themes: '+themes.join(' | ')); return; } - // Check if it's a skin - if(skins.includes(val)){ - const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val); - localStorage.setItem('hermes-theme',appearance.theme); - localStorage.setItem('hermes-skin',appearance.skin); - _applyTheme(appearance.theme); - _applySkin(appearance.skin); - try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){} - const sel=$('settingsSkin'); - if(sel)sel.value=appearance.skin; - const themeSel=$('settingsTheme'); - if(themeSel)themeSel.value=appearance.theme; - if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme); - if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin); - showToast(t('theme_set')+appearance.skin); - return; - } - showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|')); + const themeName=args.toLowerCase(); + localStorage.setItem('hermes-theme',themeName); + _applyTheme(themeName); + try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){} + const sel=$('settingsTheme'); + if(sel)sel.value=themeName; + showToast(t('theme_set')+themeName); } async function cmdSkills(args){ @@ -328,7 +240,6 @@ async function cmdSkills(args){ const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'}; S.messages.push(msg); renderMessages(); return; } - // Group by category const byCategory = {}; skills.forEach(s => { const cat = s.category || 'General'; @@ -349,7 +260,6 @@ async function cmdSkills(args){ : `Available skills (${skills.length}):\n\n`; S.messages.push({role:'assistant', content: header + lines.join('\n')}); renderMessages(); - showToast(t('type_slash')); }catch(e){ showToast('Failed to load skills: '+e.message); } @@ -358,7 +268,6 @@ async function cmdSkills(args){ async function cmdPersonality(args){ if(!S.session){showToast(t('no_active_session'));return;} if(!args){ - // List available personalities try{ const data=await api('/api/personalities'); if(!data.personalities||!data.personalities.length){ @@ -366,7 +275,7 @@ async function cmdPersonality(args){ return; } const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n'); - S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+t('personality_switch_hint')}); + S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+'\n\nSwitch with: /personality '}); renderMessages(); }catch(e){showToast(t('personalities_load_failed'));} return; @@ -385,111 +294,34 @@ async function cmdPersonality(args){ }catch(e){showToast(t('failed_colon')+e.message);} } -async function cmdStop(){ - if(!S.session){showToast(t('no_active_session'));return;} - if(!S.activeStreamId){showToast(t('no_active_task'));return;} - if(typeof cancelStream==='function'){await cancelStream();showToast(t('stream_stopped'));} - else showToast(t('cancel_unavailable')); -} -async function cmdTitle(args){ - if(!S.session){showToast(t('no_active_session'));return;} - const name=(args||'').trim(); - if(!name){ - S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`}); - renderMessages();return; - } - try{ - const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})}); - if(r&&r.error){showToast(r.error);return;} - S.session.title=(r&&r.session&&r.session.title)||name; - if(typeof syncTopbar==='function')syncTopbar(); - if(typeof renderSessionList==='function')renderSessionList(); - showToast(`${t('title_set')} "${S.session.title}"`); - }catch(e){showToast(t('failed_colon')+e.message);} -} -async function cmdRetry(){ - if(!S.session){showToast(t('no_active_session'));return;} - if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;} - const activeSid=S.session.session_id; - try{ - const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})}); - if(r&&r.error){showToast(r.error);return;} - if(!S.session||S.session.session_id!==activeSid)return; - const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();} - $('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send(); - }catch(e){showToast(t('retry_failed')+e.message);} -} -async function cmdUndo(){ - if(!S.session){showToast(t('no_active_session'));return;} - if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;} - const activeSid=S.session.session_id; - try{ - const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})}); - if(r&&r.error){showToast(r.error);return;} - if(!S.session||S.session.session_id!==activeSid)return; - const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();} - showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`); - }catch(e){showToast(t('undo_failed')+e.message);} -} -async function cmdStatus(){ - if(!S.session){showToast(t('no_active_session'));return;} - try{ - const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id)); - if(r&&r.error){showToast(r.error);return;} - S.messages.push({role:'assistant',content:[`**${t('status_heading')}**`,'',`**${t('status_session_id')}:** \`${r.session_id}\``,`**${t('status_title')}:** ${r.title||t('untitled')}`,`**${t('status_model')}:** ${r.model||t('usage_default_model')}`,`**${t('status_workspace')}:** ${r.workspace}`,`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,`**${t('status_messages')}:** ${r.message_count}`,`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,].join('\n')}); - renderMessages(); - }catch(e){showToast(t('status_load_failed')+e.message);} -} -function cmdVoice(){ - const mic=document.getElementById('btnMic'); - if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}} - showToast(t('cmd_voice_use_mic')); -} -let _skillCommandCache=[]; -let _skillCommandLoadPromise=null; -let _skillCommandCacheReady=false; -function _skillCommandSlug(name){ - const raw=String(name||'').trim().toLowerCase(); - if(!raw)return''; - return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,''); -} -function _buildSkillCommandEntry(skill){ - const skillName=String(skill&&skill.name||'').trim(); - const slug=_skillCommandSlug(skillName); - if(!slug)return null; - if(COMMANDS.some(c=>c.name===slug)) return null; - return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName}; -} -async function loadSkillCommands(force=false){ - if(_skillCommandCacheReady&&!force)return _skillCommandCache; - if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise; - _skillCommandLoadPromise=(async()=>{ - try{ - const data=await api('/api/skills'); - const deduped=new Map(); - for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);} - _skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name)); - }catch(_){_skillCommandCache=[];} - finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;} - return _skillCommandCache; - })(); - return _skillCommandLoadPromise; -} -function refreshSlashCommandDropdown(){ - const ta=$('msg');if(!ta)return; - const text=ta.value||''; - if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;} - const matches=getMatchingCommands(text.slice(1)); - if(matches.length)showCmdDropdown(matches);else hideCmdDropdown(); -} -function ensureSkillCommandsLoadedForAutocomplete(){ - if(_skillCommandCacheReady||_skillCommandLoadPromise)return; - loadSkillCommands().then(()=>{refreshSlashCommandDropdown();}); +// ── Tier-2 Agent Command Handler ──────────────────────────────────────── + +const AGENT_INFO={ + 'sunflower': {emoji:'🌻', name:'Sunflower', file:'sunflower/soul.md', domain:'Finance, Wealth & Subscriptions'}, + 'lotus': {emoji:'🪷', name:'Lotus', file:'lotus/soul.md', domain:'Health, Fitness & Recovery'}, + 'forget-me-not': {emoji:'🌼', name:'Forget-me-not',file:'forget-me-not/soul.md', domain:'Calendar, Time & Social'}, + 'iris': {emoji:'⚜️', name:'Iris', file:'iris/soul.md', domain:'Career, Learning & Focus'}, + 'ivy': {emoji:'🌿', name:'Ivy', file:'ivy/soul.md', domain:'Smart Home & Environment'}, + 'dandelion': {emoji:'🛡', name:'Dandelion', file:'dandelion/soul.md', domain:'Communication Triage & Gatekeeping'}, + 'root': {emoji:'🌳', name:'Root', file:'root/soul.md', domain:'DevOps, Logs & System Health'}, + 'back': {emoji:'🌹', name:'Rose', file:'rose/soul.md', domain:'Orchestrator (return from agent)'}, +}; + +function cmdAgent(args){ + const parsed=parseCommand($('msg').value); + if(!parsed)return; + const agentKey=parsed.name; + const info=AGENT_INFO[agentKey]; + if(!info){showToast('Unknown agent: '+agentKey);return;} + + const userMsg=args||''; + const contextMsg=`[Agent Switch: ${info.emoji} ${info.name}]\nLoad ~/.hermes/agents/${info.file} and handle this request as ${info.name} (${info.domain}).${userMsg?'\n\nUser message: '+userMsg:''}`; + + $('msg').value=contextMsg; + send(); } -// ── Autocomplete dropdown ─────────────────────────────────────────────────── +// ── Autocomplete dropdown ─────────────────────────────────────────────── let _cmdSelectedIdx=-1; @@ -503,13 +335,11 @@ function showCmdDropdown(matches){ const el=document.createElement('div'); el.className='cmd-item'; el.dataset.idx=i; - const usage=c.arg?` ${esc(c.arg)}`:''; - const badge=c.source==='skill'?`${esc(t('slash_skill_badge'))}`:''; - if(c.source==='skill') el.classList.add('cmd-item-skill'); - el.innerHTML=`
/${esc(c.name)}${usage}${badge}
${esc(c.desc)}
`; + const usage=c.arg&&c.arg!=='(none)'?` ${esc(c.arg)}`:''; + el.innerHTML=`
/${esc(c.name)}${usage}
${esc(c.desc)}
`; el.onmousedown=(e)=>{ e.preventDefault(); - $('msg').value='/'+c.name+(c.arg?' ':''); + $('msg').value='/'+c.name+(c.arg&&c.arg!=='(none)'?' ':''); hideCmdDropdown(); $('msg').focus(); }; @@ -547,9 +377,3 @@ function selectCmdDropdownItem(){ } hideCmdDropdown(); } - -// ── Handler aliases (for test-discoverable command registration) ────────────── -// The COMMANDS array above is the authoritative dispatch table. These aliases -// allow tooling and tests to discover command handlers by name independently. -const HANDLERS = {}; -HANDLERS.skills = cmdSkills; diff --git a/static/i18n.js b/static/i18n.js index f1e2bc6..253c986 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -28,6 +28,8 @@ const LOCALES = { thinking: 'Thinking', expand_all: 'Expand all', collapse_all: 'Collapse all', + show_all_tools: 'Show all tools', + hide_tools: 'Hide tools', edit_failed: 'Edit failed: ', regen_failed: 'Regenerate failed: ', reconnect_active: 'A response is still being generated. Reload when ready?', @@ -466,6 +468,26 @@ const LOCALES = { profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', profile_deleted: (name) => `Profile deleted: ${name}`, + gateways_no_gateways: 'No gateways configured.', + gateway_running: 'Running', + gateway_stopped: 'Stopped', + gateway_stop: 'Stop', + gateway_start: 'Start', + gateway_restart: 'Restart', + gateway_stop_title: 'Stop this gateway', + gateway_start_title: 'Start this gateway', + gateway_restart_title: 'Restart this gateway', + gateway_started: (name) => `Gateway started: ${name}`, + gateway_stopped_msg: (name) => `Gateway stopped: ${name}`, + gateway_restarted: (name) => `Gateway restarted: ${name}`, + gateway_start_failed: 'Failed to start gateway: ', + gateway_stop_failed: 'Failed to stop gateway: ', + gateway_restart_failed: 'Failed to restart gateway: ', + gateway_add: 'Add Gateway', + gateway_add_title: 'Add New Gateway', + gateway_add_message: 'Enter gateway name (e.g. telegram, openclaw):', + gateway_added: (name) => `Gateway added: ${name}`, + gateway_add_failed: 'Failed to add gateway: ', active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, settings_unsaved_changes: 'You have unsaved changes.', @@ -1350,6 +1372,26 @@ const LOCALES = { profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', profile_deleted: (name) => `Profile deleted: ${name}`, + gateways_no_gateways: 'No gateways configured.', + gateway_running: 'Running', + gateway_stopped: 'Stopped', + gateway_stop: 'Stop', + gateway_start: 'Start', + gateway_restart: 'Restart', + gateway_stop_title: 'Stop this gateway', + gateway_start_title: 'Start this gateway', + gateway_restart_title: 'Restart this gateway', + gateway_started: (name) => `Gateway started: ${name}`, + gateway_stopped_msg: (name) => `Gateway stopped: ${name}`, + gateway_restarted: (name) => `Gateway restarted: ${name}`, + gateway_start_failed: 'Failed to start gateway: ', + gateway_stop_failed: 'Failed to stop gateway: ', + gateway_restart_failed: 'Failed to restart gateway: ', + gateway_add: 'Add Gateway', + gateway_add_title: 'Add New Gateway', + gateway_add_message: 'Enter gateway name (e.g. telegram, openclaw):', + gateway_added: (name) => `Gateway added: ${name}`, + gateway_add_failed: 'Failed to add gateway: ', active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, settings_unsaved_changes: 'You have unsaved changes.', @@ -1994,6 +2036,26 @@ const LOCALES = { profile_delete_confirm_title: (name) => `删除配置档“${name}”?`, profile_delete_confirm_message: '这将删除该配置档的所有配置、技能、记忆和会话。', profile_deleted: (name) => `配置档已删除:${name}`, + gateways_no_gateways: '未配置网关。', + gateway_running: '运行中', + gateway_stopped: '已停止', + gateway_stop: '停止', + gateway_start: '启动', + gateway_restart: '重启', + gateway_stop_title: '停止此网关', + gateway_start_title: '启动此网关', + gateway_restart_title: '重启此网关', + gateway_started: (name) => `网关已启动:${name}`, + gateway_stopped_msg: (name) => `网关已停止:${name}`, + gateway_restarted: (name) => `网关已重启:${name}`, + gateway_start_failed: '启动网关失败:', + gateway_stop_failed: '停止网关失败:', + gateway_restart_failed: '重启网关失败:', + gateway_add: '添加网关', + gateway_add_title: '添加新网关', + gateway_add_message: '输入网关名称(例如:telegram, openclaw):', + gateway_added: (name) => `网关已添加:${name}`, + gateway_add_failed: '添加网关失败:', active_conversation_none: '当前未选择活动会话。', active_conversation_meta: (title, count) => `${title} · ${count} 条消息`, settings_unsaved_changes: '你有未保存的更改。', diff --git a/static/index.html b/static/index.html index 3a0be2c..b6dab87 100644 --- a/static/index.html +++ b/static/index.html @@ -4,18 +4,12 @@ Hermes - - - - - - - - + + - + @@ -29,8 +23,8 @@ - +
@@ -114,33 +108,85 @@
Current task list
+ +
+ +
+
+
+ 🎯 + Mission Control +
+
+ + +
+
+
+ + +
+
+
Tasks
+
+
loading...
+
+
+
Priorities
+
+
loading...
+
+
+ + +
+
+ Progress0% +
+
+
+
+
+ + +
+
New Task
+ +
+ + + +
+
+ + +
+
+
+ + +
+ + +
+
Recent Activity
+
+
+
Add and switch workspaces for your sessions.
Loading...
- -
- - - -
Loading...
-
+
-
-
+
+ +
+
@@ -225,55 +273,52 @@
+
+
+
+ + Approval required +
+
+
+
+ + + + +
+
+
+
-
-
-
-
- - Approval required -
-
-
- -
- - - - -
-
-
- -
@@ -284,7 +329,7 @@
- - - - - - - - - - + + + + + + + + + + diff --git a/static/messages.js b/static/messages.js index 3c3597a..e584837 100644 --- a/static/messages.js +++ b/static/messages.js @@ -51,7 +51,10 @@ async function send(){ // Set provisional title from user message immediately so session appears // in the sidebar right away with a meaningful name (server may refine later) if(S.session&&(S.session.title==='Untitled'||!S.session.title)){ - const provisionalTitle=displayText.slice(0,64); + // Filter out system notes like "[Note: model was just switched...]" from titles + let cleanTitle=displayText.replace(/^\[Note:\s*/i,'').replace(/\s*via\s+\S+$/,'').trim(); + if(!cleanTitle||cleanTitle.length<3) cleanTitle=displayText; // fallback if over-stripped + const provisionalTitle=cleanTitle.slice(0,64); S.session.title=provisionalTitle; syncTopbar(); // Persist it and refresh the sidebar now -- don't wait for done diff --git a/static/panels.js b/static/panels.js index a0aa2cd..0def198 100644 --- a/static/panels.js +++ b/static/panels.js @@ -16,6 +16,7 @@ async function switchPanel(name) { if (name === 'workspaces') await loadWorkspacesPanel(); if (name === 'profiles') await loadProfilesPanel(); if (name === 'todos') loadTodos(); + if (name === 'missioncontrol') await loadMissionControl(); } // ── Cron panel ── @@ -39,6 +40,8 @@ async function loadCrons() { item.innerHTML = `
${esc(job.name)} + ${esc(job.schedule_display || job.schedule?.expression || '')} + ${job.last_run_at ? esc(new Date(job.last_run_at).toLocaleString()) : ''} ${statusLabel}
@@ -50,7 +53,7 @@ async function loadCrons() { ? `` : ``} - +