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 (

View File

@@ -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()

View File

@@ -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')){

View File

@@ -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 <model_name>');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 <name>');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 <name>'});
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?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
if(c.source==='skill') el.classList.add('cmd-item-skill');
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
const usage=c.arg&&c.arg!=='(none)'?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
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;

View File

@@ -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: '你有未保存的更改。',

View File

@@ -4,18 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title>
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
<link rel="shortcut icon" href="static/favicon.ico">
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css">
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<link rel="stylesheet" href="/static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
</head>
@@ -29,8 +23,8 @@
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg></button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab" data-panel="missioncontrol" data-label="MC" onclick="switchPanel('missioncontrol')" title="Mission Control" data-i18n-title="tab_mc"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
@@ -114,33 +108,85 @@
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Mission Control panel -->
<div class="panel-view" id="panelMissioncontrol">
<!-- Header -->
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:16px">🎯</span>
<span style="font-size:14px;font-weight:700;color:var(--text)">Mission Control</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span id="mcHealthBadge" style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:12px;background:rgba(0,0,0,.06)"></span>
<button onclick="refreshMC()" title="Refresh" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px"></button>
</div>
</div>
</div>
<!-- Stats Cards -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px 16px;flex-shrink:0">
<div style="background:var(--surface);border-radius:10px;padding:10px 12px;border:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);margin-bottom:2px">Tasks</div>
<div style="font-size:18px;font-weight:700;color:var(--blue)" id="mcTasksCount"></div>
<div style="font-size:9px;color:var(--muted)" id="mcTasksLabel">loading...</div>
</div>
<div style="background:var(--surface);border-radius:10px;padding:10px 12px;border:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);margin-bottom:2px">Priorities</div>
<div style="font-size:18px;font-weight:700;color:var(--gold)" id="mcPrioritiesCount"></div>
<div style="font-size:9px;color:var(--muted)" id="mcPrioritiesLabel">loading...</div>
</div>
</div>
<!-- Progress Bar -->
<div style="padding:0 16px 10px;flex-shrink:0">
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--muted);margin-bottom:4px">
<span>Progress</span><span id="mcProgressPct">0%</span>
</div>
<div style="background:var(--border);border-radius:6px;height:8px;overflow:hidden">
<div id="mcProgressBar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--blue),var(--accent));border-radius:6px;transition:width .4s ease"></div>
</div>
</div>
<!-- Add Task -->
<div style="padding:0 16px 10px;flex-shrink:0">
<div style="font-size:10px;font-weight:600;color:var(--muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">New Task</div>
<input id="mcNewTaskTitle" placeholder="What needs to be done?" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text);outline:none;margin-bottom:6px" onkeydown="if(event.key==='Enter')createMCTask()">
<div style="display:grid;grid-template-columns:1fr 1fr 80px;gap:6px">
<select id="mcNewTaskPriority" style="background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:11px;color:var(--text);outline:none">
<option value="1">🔴 Critical</option>
<option value="2" selected>🟠 High</option>
<option value="3">🟡 Medium</option>
<option value="4">🟢 Low</option>
</select>
<select id="mcNewTaskStatus" style="background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:11px;color:var(--text);outline:none">
<option value="backlog" selected>○ Backlog</option>
<option value="progress">◐ In Progress</option>
</select>
<button onclick="createMCTask()" style="background:var(--accent);border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:600;color:#fff;cursor:pointer">Add</button>
</div>
</div>
<!-- Priority Filter -->
<div style="padding:0 16px 6px;flex-shrink:0">
<div style="display:flex;gap:4px;flex-wrap:wrap" id="mcPriorityFilters"></div>
</div>
<!-- Tasks List -->
<div style="flex:1;overflow-y:auto;padding:0 16px" id="mcTasksList"></div>
<!-- Feed -->
<div style="padding:8px 16px 4px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">Recent Activity</div>
</div>
<div id="mcFeed" style="flex-shrink:0;max-height:100px;overflow-y:auto;padding:0 16px 12px;font-size:10px;color:var(--muted)"></div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Profiles panel -->
<div class="panel-view" id="panelProfiles">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></button>
</div>
<!-- Profile create form (hidden by default) -->
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="profileFormName" data-i18n-placeholder="profile_name_placeholder" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> <span data-i18n="profile_clone_label">Clone config from active profile</span>
</label>
<input id="profileFormBaseUrl" data-i18n-placeholder="profile_base_url_placeholder" placeholder="Base URL (optional, e.g. http://localhost:11434)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<input id="profileFormApiKey" type="password" data-i18n-placeholder="profile_api_key_placeholder" placeholder="API key (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()" data-i18n="create">Create</button>
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<div class="sidebar-bottom">
<button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">
<span class="hermes-launch-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
@@ -180,7 +226,6 @@
</div>
</div>
<div class="messages" id="messages">
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" aria-label="Scroll to bottom" onclick="scrollToBottom()" style="display:none"></button>
<div class="empty-state" id="emptyState">
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
<defs>
@@ -208,8 +253,11 @@
</div>
</div>
<div class="messages-inner" id="msgInner"></div>
<div id="liveCompressionCards" class="live-compression-cards"></div>
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
<div class="tool-cards-toggle" id="toolCardToggleBtn" style="margin:4px 0 2px 40px;display:flex;gap:8px">
<button onclick="toggleShowAllTools()" id="btnShowAllTools" data-i18n="show_all_tools">Show all tools</button>
</div>
<button class="scroll-bottom-fab" id="scrollBottomFab" onclick="scrollToBottom()" title="Scroll to bottom"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<div class="update-banner" id="updateBanner">
<span id="updateMsg"></span>
@@ -225,55 +273,52 @@
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="composer-flyout">
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<div class="composer-box" id="composerBox">
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
@@ -284,7 +329,7 @@
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx" style="display:none">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
<button class="icon-btn" id="btnAttach" title="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
@@ -307,14 +352,14 @@
<div class="composer-ws-wrap">
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="composer-workspace-label" id="composerWorkspaceLabel"></span>
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-model-wrap">
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-model-label" id="composerModelLabel"></span>
<span class="composer-model-label" id="composerModelLabel">Model</span>
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
@@ -330,8 +375,7 @@
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
</optgroup>
<optgroup label="Other">
<option value="google/gemini-3.1-pro-preview">Gemini 3.1 Pro Preview</option>
<option value="google/gemini-3-flash-preview">Gemini 3 Flash Preview</option>
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
@@ -382,15 +426,19 @@
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn" id="btnSearchFiles" title="Search files" onclick="toggleWsSearch()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
</div>
</div>
<div class="ws-search-wrap" id="wsSearchWrap">
<input type="text" id="wsSearchInput" placeholder="Files suchen..." oninput="filterWsFiles()">
<button class="ws-search-clear" id="wsSearchClear" onclick="clearWsSearch()">&times;</button>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>
@@ -443,10 +491,6 @@
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="settings-tab-title">Conversation</span>
</button>
<button class="settings-tab" id="settingsTabAppearance" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneAppearance" onclick="switchSettingsSection('appearance')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span class="settings-tab-title">Appearance</span>
</button>
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span class="settings-tab-title">Preferences</span>
@@ -455,6 +499,10 @@
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span class="settings-tab-title">System</span>
</button>
<button class="settings-tab" id="settingsTabGateways" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneGateways" onclick="switchSettingsSection('gateways')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<span class="settings-tab-title">Gateways</span>
</button>
</div>
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
@@ -472,45 +520,6 @@
</div>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
<div class="settings-pane" id="settingsPaneAppearance" role="tabpanel" aria-labelledby="settingsTabAppearance">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Appearance</div>
<div class="settings-section-meta">Theme, accent colors, and visual style.</div>
</div>
</div>
<div class="settings-field">
<label data-i18n="settings_label_theme">Theme</label>
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
<button type="button" data-theme-val="light" onclick="_pickTheme('light')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:#fff;border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#999" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">Light</span>
</button>
<button type="button" data-theme-val="dark" onclick="_pickTheme('dark')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:#1a1a2e;border:1px solid rgba(255,255,255,.1);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#666" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">Dark</span>
</button>
<button type="button" data-theme-val="system" onclick="_pickTheme('system')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:linear-gradient(to right,#fff,#1a1a2e);border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#888" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">System</span>
</button>
</div>
<input type="hidden" id="settingsTheme" value="dark">
</div>
<div class="settings-field">
<label data-i18n="settings_label_skin">Skin</label>
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
</div>
<input type="hidden" id="settingsSkin" value="default">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
<div class="settings-section-head">
<div>
@@ -529,6 +538,19 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="_applyTheme(this.value)">
<option value="system">System (auto)</option>
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
@@ -595,7 +617,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.87</span>
<span class="settings-version-badge">v0.50.48</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
@@ -605,6 +627,18 @@
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
</div>
<div class="settings-pane" id="settingsPaneGateways" role="tabpanel" aria-labelledby="settingsTabGateways">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Gateways</div>
<div class="settings-section-meta">Manage Hermes gateway connections (Telegram, OpenClaw, etc.)</div>
</div>
</div>
<div style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Active connections and gateway control.</div>
<div id="gatewaysPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div>
</div>
</div>
</div>
</div>
@@ -629,15 +663,15 @@
</div>
</div>
<div class="toast" id="toast"></div>
<script src="static/i18n.js"></script>
<script src="static/icons.js"></script>
<script src="static/ui.js"></script>
<script src="static/workspace.js"></script>
<script src="static/sessions.js"></script>
<script src="static/commands.js"></script>
<script src="static/messages.js"></script>
<script src="static/panels.js"></script>
<script src="static/onboarding.js"></script>
<script src="static/boot.js"></script>
<script src="/static/i18n.js"></script>
<script src="/static/icons.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/onboarding.js"></script>
<script src="/static/boot.js"></script>
</body>
</html>

View File

@@ -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

View File

@@ -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 = `
<div class="cron-header" onclick="toggleCron('${job.id}')">
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-schedule-inline">${esc(job.schedule_display || job.schedule?.expression || '')}</span>
<span class="cron-last-inline">${job.last_run_at ? esc(new Date(job.last_run_at).toLocaleString()) : ''}</span>
<span class="cron-status ${statusClass}">${statusLabel}</span>
</div>
<div class="cron-body" id="cron-body-${job.id}">
@@ -50,7 +53,7 @@ async function loadCrons() {
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} ${esc(t('cron_resume'))}</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} ${esc(t('cron_pause'))}</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} ${esc(t('edit'))}</button>
<button class="cron-btn" style="border-color:var(--accent-bg-strong);color:var(--accent-text)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('delete_title'))}</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('delete_title'))}</button>
</div>
<!-- Inline edit form, hidden by default -->
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
@@ -323,7 +326,12 @@ function loadTodos() {
}
}
if (!todos.length) {
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>`;
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>
<div style="margin-top:10px;padding:12px;border:1px dashed var(--border2);border-radius:8px;font-size:12px;color:var(--muted);line-height:1.6;background:rgba(124,185,255,.03)">
<div style="font-weight:600;margin-bottom:6px;color:var(--text);opacity:.8">${li('lightbulb',12)} Tipp</div>
<div style="opacity:.7">Frag im Chat nach einer Todo-Liste, z.B.:</div>
<div style="margin-top:6px;padding:6px 8px;background:rgba(255,255,255,.04);border-radius:6px;font-style:italic;opacity:.6">"Plane mein Wochenende: Freitags einkaufen, samtags Sport, sonntags relaxen"</div>
</div>`;
return;
}
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
@@ -375,7 +383,7 @@ function renderSkills(skills) {
// Group by category
const cats = {};
for (const s of filtered) {
const cat = s.category || '(general)';
const cat = s.category || 'general';
if (!cats[cat]) cats[cat] = [];
cats[cat].push(s);
}
@@ -385,7 +393,8 @@ function renderSkills(skills) {
for (const [cat, items] of Object.entries(cats).sort()) {
const sec = document.createElement('div');
sec.className = 'skills-category';
sec.innerHTML = `<div class="skills-cat-header">${li('folder',12)} ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
const displayName = cat === 'general' ? 'General' : cat;
sec.innerHTML = `<div class="skills-cat-header">${li('folder',12)} ${esc(displayName)} <span style="opacity:.5">(${items.length})</span></div>`;
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
const el = document.createElement('div');
el.className = 'skill-item';
@@ -405,8 +414,6 @@ async function openSkill(name, el) {
// Highlight active skill
document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active'));
if (el) el.classList.add('active');
// Ensure the workspace panel is open so the skill content is actually visible (#643)
if (typeof ensureWorkspacePreviewVisible === 'function') ensureWorkspacePreviewVisible();
try {
const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`);
// Show skill content in right panel preview
@@ -436,6 +443,7 @@ async function openSkill(name, el) {
});
$('previewArea').classList.add('visible');
$('fileTree').style.display = 'none';
const wsSearchSkill=$('wsSearchWrap');if(wsSearchSkill)wsSearchSkill.style.display='none';
} catch(e) { setStatus(t('skill_load_failed') + e.message); }
}
@@ -451,12 +459,39 @@ async function openSkillFile(skillName, filePath) {
$('previewMd').innerHTML = renderMd(data.content || '');
} else {
showPreview('code');
$('previewCode').textContent = data.content || '';
if (['yml','yaml'].includes(ext)) {
$('previewCode').textContent = '';
$('previewCode').className = 'preview-code hl-yaml';
$('previewCode').innerHTML = highlightYAML(data.content || '');
} else {
$('previewCode').className = 'preview-code';
$('previewCode').textContent = data.content || '';
}
requestAnimationFrame(() => highlightCode());
}
} catch(e) { setStatus(t('skill_file_load_failed') + e.message); }
}
// ── YAML syntax highlighter with line numbers ──
function highlightYAML(text) {
const lines = text.split('\n');
return lines.map(raw => {
// Escape HTML first
let line = esc(raw);
// Highlight full-line comments
if (/^\s*#/.test(line)) {
return '<span class="code-line"><span class="hl-comment">' + line + '</span></span>';
}
// Highlight inline comments (after a value)
line = line.replace(/(#.*)$/, '<span class="hl-comment">$1</span>');
// Highlight strings (double or single quoted)
line = line.replace(/(&quot;[^&]*?&quot;|&#39;[^&]*?&#39;)/g, '<span class="hl-string">$1</span>');
// Highlight key: value pairs — key before the colon
line = line.replace(/^(\s*)([\w._-]+)(:)/, '$1<span class="hl-key">$2</span><span class="hl-value">$3</span>');
return '<span class="code-line">' + line + '</span>';
}).join('\n');
}
// ── Skill create/edit form ──
let _editingSkillName = null;
@@ -549,9 +584,7 @@ function syncWorkspaceDisplays(){
const composerLabel=$('composerWorkspaceLabel');
const composerDropdown=$('composerWsDropdown');
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
// Only show workspace label once boot has finished to prevent
// flash of "No workspace" before the saved session finishes loading.
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
if(composerLabel) composerLabel.textContent=label;
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:t('no_workspace');
@@ -819,11 +852,10 @@ async function loadProfilesPanel() {
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
const isActive = p.name === data.active;
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
const defaultBadge = p.is_default ? ` <span style="opacity:.5">${esc(t('profile_default_label'))}</span>` : '';
card.innerHTML = `
<div class="profile-card-header">
<div style="min-width:0;flex:1">
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${defaultBadge}${activeBadge}</div>
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`}
</div>
<div class="profile-card-actions">
@@ -834,7 +866,7 @@ async function loadProfilesPanel() {
panel.appendChild(card);
}
} catch (e) {
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`;
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;
}
}
@@ -852,8 +884,7 @@ function renderProfileDropdown(data) {
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
const defaultBadge = p.is_default ? ` <span style="opacity:.5;font-weight:400">${esc(t('profile_default_label'))}</span>` : '';
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${defaultBadge}${checkmark}</div>` +
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` +
(meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');
opt.onclick = async () => {
closeProfileDropdown();
@@ -1034,6 +1065,97 @@ async function deleteProfile(name) {
} catch (e) { showToast(t('delete_failed') + e.message); }
}
// ── Gateway panel ──
let _gatewaysCache = null;
async function loadGatewaysPanel() {
const panel = $('gatewaysPanel');
if (!panel) return;
try {
const data = await api('/api/gateways');
_gatewaysCache = data;
panel.innerHTML = '';
if (!data.gateways || !data.gateways.length) {
panel.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('gateways_no_gateways'))}</div>`;
return;
}
for (const gw of data.gateways) {
const card = document.createElement('div');
card.className = 'profile-card';
const statusColor = gw.running ? 'var(--link)' : 'var(--muted)';
const statusDot = gw.running
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--link);margin-right:6px"></span>`
: `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--muted);margin-right:6px"></span>`;
const statusText = gw.running ? esc(t('gateway_running')) : esc(t('gateway_stopped'));
const typeMeta = gw.type ? `<span style="opacity:.7">${esc(gw.type)}</span>` : '';
card.innerHTML = `
<div class="profile-card-header">
<div style="min-width:0;flex:1">
<div class="profile-card-name">${statusDot}${esc(gw.name)}</div>
<div class="profile-card-meta">${statusText}${typeMeta ? ' · ' + typeMeta : ''}</div>
</div>
<div class="profile-card-actions">
${gw.running
? `<button class="ws-action-btn danger" onclick="stopGateway('${esc(gw.name)}')" title="${esc(t('gateway_stop_title'))}">${esc(t('gateway_stop'))}</button>`
: `<button class="ws-action-btn" onclick="startGateway('${esc(gw.name)}')" title="${esc(t('gateway_start_title'))}">${esc(t('gateway_start'))}</button>`
}
<button class="ws-action-btn" onclick="restartGateway('${esc(gw.name)}')" title="${esc(t('gateway_restart_title'))}">${esc(t('gateway_restart'))}</button>
</div>
</div>
${gw.info ? `<div style="font-size:11px;color:var(--muted);padding:4px 0 8px">${esc(gw.info)}</div>` : ''}`;
panel.appendChild(card);
}
// Add gateway button
const addBtn = document.createElement('button');
addBtn.className = 'cron-btn run';
addBtn.style.cssText = 'width:100%;margin-top:8px;padding:6px';
addBtn.innerHTML = `+ ${esc(t('gateway_add'))}`;
addBtn.onclick = () => showAddGatewayDialog();
panel.appendChild(addBtn);
} catch (e) {
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;
}
}
async function startGateway(name) {
try {
await api('/api/gateway/start', { method: 'POST', body: JSON.stringify({ name }) });
showToast(t('gateway_started', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_start_failed') + e.message); }
}
async function stopGateway(name) {
try {
await api('/api/gateway/stop', { method: 'POST', body: JSON.stringify({ name }) });
showToast(t('gateway_stopped_msg', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_stop_failed') + e.message); }
}
async function restartGateway(name) {
try {
await api('/api/gateway/restart', { method: 'POST', body: JSON.stringify({ name }) });
showToast(t('gateway_restarted', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_restart_failed') + e.message); }
}
async function showAddGatewayDialog() {
const name = await showPromptDialog({
title: t('gateway_add_title'),
message: t('gateway_add_message'),
confirmLabel: t('gateway_add'),
placeholder: 'telegram'
});
if (!name) return;
try {
await api('/api/gateway/add', { method: 'POST', body: JSON.stringify({ name, type: 'telegram' }) });
showToast(t('gateway_added', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_add_failed') + e.message); }
}
// ── Memory panel ──
async function loadMemory(force) {
const panel = $('memoryPanel');
@@ -1041,6 +1163,17 @@ async function loadMemory(force) {
const data = await api('/api/memory');
_memoryData = data; // cache for edit form
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString() : '';
// Mask sensitive data (API tokens, passwords, keys) in displayed memory
function maskSensitive(md) {
if (!md) return md;
return md
.replace(/([A-Za-z0-9]{32,})\.(?:[A-Za-z0-9+\/=]{10,})/g, '***REDACTED***') // API tokens like Z.ai
.replace(/([Aa]pi[_\s]?[Tt]oken[:\s]*)\S{20,}/g, '$1***REDACTED***') // API Token: xxx
.replace(/([Aa]PI[_\s]?[Kk]ey[:\s]*)\S{20,}/g, '$1***REDACTED***') // API Key: xxx
.replace(/([Pp]assword[:\s]*)\S{8,}/g, '$1***REDACTED***') // Password: xxx
.replace(/([Tt]oken[:\s]*)[a-f0-9]{32,}/g, '$1***REDACTED***') // token: hex
.replace(/([Ss]ecret[:\s]*)\S{16,}/g, '$1***REDACTED***'); // secret: xxx
}
panel.innerHTML = `
<div class="memory-section">
<div class="memory-section-title">
@@ -1048,7 +1181,7 @@ async function loadMemory(force) {
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
</div>
${data.memory
? `<div class="memory-content preview-md">${renderMd(data.memory)}</div>`
? `<div class="memory-content preview-md">${renderMd(maskSensitive(data.memory))}</div>`
: `<div class="memory-empty">${esc(t('no_notes_yet'))}</div>`}
</div>
<div class="memory-section">
@@ -1057,7 +1190,7 @@ async function loadMemory(force) {
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
</div>
${data.user
? `<div class="memory-content preview-md">${renderMd(data.user)}</div>`
? `<div class="memory-content preview-md">${renderMd(maskSensitive(data.user))}</div>`
: `<div class="memory-empty">${esc(t('no_profile_yet'))}</div>`}
</div>`;
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
@@ -1074,14 +1207,13 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
let _settingsDirty = false;
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
let _settingsSkinOnOpen = null; // track skin at open time for discard revert
let _settingsSection = 'conversation';
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='system')?name:'conversation';
const section=(name==='preferences'||name==='system'||name==='gateways')?name:'conversation';
_settingsSection=section;
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',system:'System'};
['conversation','appearance','preferences','system'].forEach(key=>{
const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways'};
['conversation','preferences','system','gateways'].forEach(key=>{
const tab=$('settingsTab'+map[key]);
const pane=$('settingsPane'+map[key]);
const active=key===section;
@@ -1119,8 +1251,7 @@ function toggleSettings(){
if(!overlay) return;
if(overlay.style.display==='none'){
_settingsDirty = false;
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark';
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || document.documentElement.dataset.theme || 'dark';
_settingsSection = 'conversation';
overlay.style.display='';
loadSettingsPanel();
@@ -1160,10 +1291,7 @@ function _revertSettingsPreview(){
if(_settingsThemeOnOpen){
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
}
if(_settingsSkinOnOpen){
localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen);
else document.documentElement.dataset.theme = _settingsThemeOnOpen;
}
}
@@ -1198,16 +1326,6 @@ function _markSettingsDirty(){
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
// Hydrate appearance controls first so a slow /api/models request
// cannot overwrite an in-progress theme/skin selection.
const themeSel=$('settingsTheme');
const themeVal=settings.theme||'dark';
if(themeSel) themeSel.value=themeVal;
if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal);
const skinVal=(settings.skin||'default').toLowerCase();
const skinSel=$('settingsSkin');
if(skinSel) skinSel.value=skinVal;
if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal);
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
@@ -1239,6 +1357,9 @@ async function loadSettingsPanel(){
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
// Theme preference
const themeSel=$('settingsTheme');
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
// Language preference — populate from LOCALES bundle
const langSel=$('settingsLanguage');
if(langSel){
@@ -1280,6 +1401,7 @@ async function loadSettingsPanel(){
}catch(e){}
_syncHermesPanelSessionActions();
switchSettingsSection(_settingsSection);
if(_settingsSection==='gateways') loadGatewaysPanel();
}catch(e){
showToast(t('settings_load_failed')+e.message);
}
@@ -1293,7 +1415,7 @@ function _setSettingsAuthButtonsVisible(active){
}
function _applySavedSettingsUi(saved, body, opts){
const {sendKey,showTokenUsage,showCliSessions,theme,skin,language}=opts;
const {sendKey,showTokenUsage,showCliSessions,theme,language}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
@@ -1311,7 +1433,6 @@ function _applySavedSettingsUi(saved, body, opts){
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
_settingsDirty=false;
_settingsThemeOnOpen=theme;
_settingsSkinOnOpen=skin||'default';
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
renderMessages();
@@ -1326,14 +1447,12 @@ async function saveSettings(andClose){
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
const skin=($('settingsSkin')||{}).value||'default';
const language=($('settingsLanguage')||{}).value||'en';
const body={};
if(model) body.default_model=model;
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.skin=skin;
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_cli_sessions=showCliSessions;
@@ -1349,7 +1468,7 @@ async function saveSettings(andClose){
if(pw && pw.trim()){
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
_hideSettingsPanel();
return;
@@ -1357,7 +1476,7 @@ async function saveSettings(andClose){
}
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
showToast(t('settings_saved'));
_hideSettingsPanel();
}catch(e){
@@ -1368,7 +1487,7 @@ async function saveSettings(andClose){
async function signOut(){
try{
await api('/api/auth/logout',{method:'POST',body:'{}'});
window.location.href='login';
window.location.href='/login';
}catch(e){
showToast(t('sign_out_failed')+e.message);
}
@@ -1492,4 +1611,157 @@ function dismissErrorBanner(){
if(banner) banner.style.display='none';
}
// ── Mission Control panel ──
let _mcInterval = null;
async function loadMissionControl() {
clearInterval(_mcInterval);
await refreshMC();
_mcInterval = setInterval(refreshMC, 15000);
}
async function refreshMC() {
const [statusRes, tasksRes, feedRes, prioritiesRes] = await Promise.all([
api('/api/mc/status'),
api('/api/mc/tasks'),
api('/api/mc/feed?limit=10'),
api('/api/mc/priorities'),
]);
const status = statusRes;
const tasks = tasksRes.tasks || [];
const feed = feedRes.feed || [];
const priorities = prioritiesRes.priorities || [];
// ── Health Badge ──
const h = status.dashboard_health || 'empty';
const healthColors = { healthy: '#4caf50', active: '#ff9800', warning: '#ff5722', empty: '#9e9e9e', ok: '#4caf50' };
const healthLabels = { healthy: 'Healthy', active: 'Active', warning: 'Warning', empty: 'No Data', ok: 'OK' };
const $hb = $('mcHealthBadge');
$hb.textContent = healthLabels[h] || 'Unknown';
$hb.style.color = healthColors[h] || '#9e9e9e';
$hb.style.background = (healthColors[h] || '#9e9e9e') + '22';
// ── Stats Cards ──
const totalTasks = tasks.length;
const doneTasks = tasks.filter(t => t.status === 'done').length;
const progressPct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
$('mcTasksCount').textContent = `${doneTasks}/${totalTasks}`;
$('mcTasksLabel').textContent = `${doneTasks} done · ${totalTasks - doneTasks} open`;
$('mcPrioritiesCount').textContent = priorities.length;
$('mcPrioritiesLabel').textContent = `${priorities.filter(p => p.done).length} done`;
// ── Progress Bar ──
$('mcProgressBar').style.width = progressPct + '%';
$('mcProgressPct').textContent = progressPct + '%';
// ── Priority Filters ──
const priorityMap = {};
priorities.forEach(p => { priorityMap[p.id] = p; });
const priorityEmoji = { 1: '🔴', 2: '🟠', 3: '🟡', 4: '🟢' };
const pfEl = $('mcPriorityFilters');
pfEl.innerHTML = [
`<button onclick="filterMCTasks('all')" style="background:var(--input-bg);border:1px solid var(--border);border-radius:20px;padding:3px 10px;font-size:10px;cursor:pointer;color:var(--text)" id="mcFilterAll">All</button>`,
...priorities.map(p =>
`<button onclick="filterMCTasks(${p.id})" style="background:var(--input-bg);border:1px solid var(--border);border-radius:20px;padding:3px 10px;font-size:10px;cursor:pointer;color:var(--text);display:flex;align-items:center;gap:3px" id="mcFilter${p.id}">${priorityEmoji[p.id] || '•'} ${esc(p.name)}</button>`
)
].join('');
// ── Tasks List ──
const listEl = $('mcTasksList');
const activeFilter = window._mcPriorityFilter || 'all';
const filteredTasks = activeFilter === 'all' ? tasks : tasks.filter(t => t.priority === activeFilter);
if (filteredTasks.length === 0) {
listEl.innerHTML = '<div style="color:var(--muted);font-size:11px;text-align:center;padding:20px">No tasks yet.<br><span style="font-size:10px">Add one above ↑</span></div>';
} else {
const statusMeta = {
backlog: { icon: '○', color: 'var(--muted)', label: 'Backlog' },
progress: { icon: '◐', color: '#ff9800', label: 'In Progress' },
done: { icon: '●', color: '#4caf50', label: 'Done' }
};
listEl.innerHTML = filteredTasks.map(t => {
const meta = statusMeta[t.status] || statusMeta.backlog;
const p = priorityMap[t.priority] || { name: 'Unknown', color: '#808080' };
const emoji = priorityEmoji[t.priority] || '•';
return `<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:6px">
<div style="display:flex;align-items:flex-start;gap:8px">
<span style="font-size:16px;flex-shrink:0;cursor:pointer" onclick="toggleMCTask(${t.id},'${t.status}')" title="Toggle status">${meta.icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;color:var(--text);${t.status === 'done' ? 'text-decoration:line-through;opacity:0.5' : ''};word-break:break-word">${esc(t.title)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
<span style="font-size:10px;${t.status === 'done' ? 'color:#4caf50' : t.status === 'progress' ? 'color:#ff9800' : 'color:var(--muted)'}">${meta.label}</span>
<span style="width:6px;height:6px;border-radius:50%;background:${p.color};flex-shrink:0"></span>
<span style="font-size:10px;color:var(--muted)">${emoji} ${esc(p.name)}</span>
</div>
</div>
<select onchange="updateMCTask(${t.id}, this.value)" style="background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;font-size:9px;color:var(--text);cursor:pointer;flex-shrink:0">
<option value="backlog" ${t.status === 'backlog' ? 'selected' : ''}>○ Backlog</option>
<option value="progress" ${t.status === 'progress' ? 'selected' : ''}>◐ Progress</option>
<option value="done" ${t.status === 'done' ? 'selected' : ''}>● Done</option>
</select>
</div>
</div>`;
}).join('');
}
// ── Feed ──
const feedEl = $('mcFeed');
if (feed.length === 0) {
feedEl.innerHTML = '<div style="opacity:0.5;font-size:10px">No recent activity</div>';
} else {
feedEl.innerHTML = feed.map(f => {
const d = new Date(f.timestamp);
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `<div style="padding:2px 0;border-bottom:1px solid var(--border)">
<span style="color:var(--text);font-size:10px">${esc(f.event)}</span>
<span style="opacity:0.5;font-size:9px;margin-left:6px">${time}</span>
</div>`;
}).join('');
}
}
function toggleMCTask(id, currentStatus) {
const next = currentStatus === 'done' ? 'backlog' : currentStatus === 'backlog' ? 'progress' : 'done';
updateMCTask(id, next);
}
function filterMCTasks(priorityId) {
window._mcPriorityFilter = priorityId;
refreshMC();
}
async function createMCTask() {
const title = $('mcNewTaskTitle').value.trim();
if (!title) return;
const priority = parseInt($('mcNewTaskPriority').value);
const status = $('mcNewTaskStatus').value;
$('mcNewTaskTitle').value = '';
await api('/api/mc/task/create', { title, priority, status });
await refreshMC();
}
async function updateMCTask(id, status) {
await api('/api/mc/task/update', { id, status });
await refreshMC();
}
// ── Priority Management ──
async function createMCPriority() {
const name = prompt('Priority name:');
if (!name) return;
const color = prompt('Color (hex, e.g. #ff0000):', '#808080');
if (!color) return;
await api('/api/mc/priority/create', { name, color });
await refreshMC();
}
async function deleteMCPriority(id) {
if (!confirm('Delete this priority?')) return;
await api('/api/mc/priority/delete', { id });
await refreshMC();
}
// Event wiring

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -200,6 +200,7 @@ async function openFile(path){
$('previewPathText').textContent=path;
$('previewArea').classList.add('visible');
$('fileTree').style.display='none';
const wsSearch=$('wsSearchWrap');if(wsSearch)wsSearch.style.display='none';
_previewCurrentPath = path;
renderFileBreadcrumb(path);
@@ -229,7 +230,27 @@ async function openFile(path){
return;
}
showPreview('code');
$('previewCode').textContent=data.content;
// Apply syntax highlighting based on file extension
const content = data.content || '';
if(['yml','yaml'].includes(ext)){
$('previewCode').className='preview-code hl-yaml';
if(typeof highlightYAML==='function'){
$('previewCode').innerHTML=highlightYAML(content);
}else{
$('previewCode').innerHTML=_highlightWithLineNumbers(content);
}
}else if(ext==='json'){
$('previewCode').className='preview-code hl-json';
$('previewCode').innerHTML=_highlightJSON(content);
}else if(['py','js','ts','sh','bash','zsh','rb','go','rs','java','c','cpp','h','css','scss','html','xml','sql','r','lua','pl','php','swift','kt','dart'].includes(ext)){
$('previewCode').className='preview-code';
$('previewCode').textContent=content;
requestAnimationFrame(()=>{if(typeof highlightCode==='function')highlightCode();});
}else{
// txt, toml, cfg, ini, conf, env, log, etc — readable with line numbers
$('previewCode').className='preview-code hl-text';
$('previewCode').innerHTML=_highlightWithLineNumbers(content);
}
}catch(e){
// If it's a 400/too-large error, offer download instead
downloadFile(path);
@@ -287,3 +308,131 @@ function renderFileBreadcrumb(filePath) {
bar.appendChild(seg);
}
}
// ── Syntax highlighting helpers for file preview ──
function _escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function _highlightWithLineNumbers(text){
return text.split('\n').map(line=>'<span class="code-line">'+_escHtml(line)+'</span>').join('\n');
}
function _highlightJSON(text){
try{
const pretty=JSON.stringify(JSON.parse(text),null,2);
return pretty.split('\n').map(raw=>{
let line=_escHtml(raw);
// Highlight keys
line=line.replace(/^(\s*)(&quot;)([\w\s.\/\-_@:]+)(&quot;)(\s*:)/,'$1<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
// Highlight string values (after colon)
line=line.replace(/(:\s*)(&quot;[^&]*?&quot;)/g,'$1<span class="hl-string">$2</span>');
// Highlight numbers
line=line.replace(/:\s*(\d+\.?\d*)/g,': <span class="hl-number">$1</span>');
// Highlight booleans / null
line=line.replace(/:\s*(true|false|null)/g,': <span class="hl-bool">$1</span>');
return '<span class="code-line">'+line+'</span>';
}).join('\n');
}catch(e){
return _highlightWithLineNumbers(text);
}
}
// ── Workspace file search (server-side recursive) ──
let _wsSearchTimer=null;
function filterWsFiles(){
// Debounce: wait 300ms after last keystroke
clearTimeout(_wsSearchTimer);
_wsSearchTimer=setTimeout(_doWsSearch,300);
}
async function _doWsSearch(){
const input=$('wsSearchInput');
const clearBtn=$('wsSearchClear');
const tree=$('fileTree');
if(!input||!tree)return;
const query=input.value.trim().toLowerCase();
if(clearBtn)clearBtn.classList.toggle('visible',query.length>0);
// Remove any stale "no results" message
const oldNoRes=tree.querySelector('.ws-no-results');
if(oldNoRes)oldNoRes.remove();
// If empty query, restore original file tree
if(!query){
if(typeof renderFileTree==='function')renderFileTree();
return;
}
// Not searchable without a workspace
if(!S.session||!S.session.workspace)return;
// Show loading indicator
tree.innerHTML='<div class="ws-no-results" style="opacity:.5">Suche...</div>';
try{
// Ask server to search recursively
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`);
const results=data.entries||[];
if(!results.length){
tree.innerHTML='<div class="ws-no-results">Keine Dateien gefunden</div>';
return;
}
// Render flat result list with path info
tree.innerHTML='';
for(const item of results){
const el=document.createElement('div');
el.className='file-item';
el.style.paddingLeft='10px';
const iconEl=document.createElement('span');
iconEl.className='file-icon';
iconEl.innerHTML=fileIcon(item.name,item.type);
el.appendChild(iconEl);
const nameEl=document.createElement('span');
nameEl.className='file-name';
nameEl.textContent=item.name;
el.appendChild(nameEl);
// Show relative path as hint
if(item.path){
const pathEl=document.createElement('span');
pathEl.className='file-size';
pathEl.style.opacity='.4';
const dir=item.path.substring(0,item.path.lastIndexOf('/'));
pathEl.textContent=dir||'.';
el.appendChild(pathEl);
}
if(item.type==='dir'){
el.onclick=()=>{clearWsSearch();loadDir(item.path);};
}else{
el.onclick=()=>openFile(item.path);
}
tree.appendChild(el);
}
}catch(e){
// Fallback: client-side filter on currently visible items
tree.innerHTML='';
const allItems=S.entries||[];
const matches=allItems.filter(it=>it.name.toLowerCase().includes(query));
if(!matches.length){
tree.innerHTML='<div class="ws-no-results">Keine Dateien gefunden</div>';
}else{
for(const item of matches){
const el=document.createElement('div');
el.className='file-item';el.style.paddingLeft='10px';
el.innerHTML='<span class="file-icon">'+fileIcon(item.name,item.type)+'</span><span class="file-name">'+item.name+'</span>';
el.onclick=item.type==='dir'?()=>{clearWsSearch();loadDir(item.path);}:()=>openFile(item.path);
tree.appendChild(el);
}
}
}
}
// Toggle workspace search visibility
function toggleWsSearch(){
const wrap=$('wsSearchWrap');
if(!wrap)return;
// Show/hide the search bar
wrap.style.display=wrap.style.display==='none'?'flex':'none';
// Focus input when showing
setTimeout(()=>{if(wrap.style.display!=='none'){const inp=$('wsSearchInput');if(inp)inp.focus();}},50);
}
function clearWsSearch(){
const input=$('wsSearchInput');
if(input)input.value='';
const clearBtn=$('wsSearchClear');
if(clearBtn)clearBtn.classList.remove('visible');
if(typeof renderFileTree==='function')renderFileTree();
}