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:
398
api/config.py
398
api/config.py
@@ -165,7 +165,6 @@ else:
|
||||
# ── Config file (reloadable -- supports profile switching) ──────────────────
|
||||
_cfg_cache = {}
|
||||
_cfg_lock = threading.Lock()
|
||||
_cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded
|
||||
|
||||
|
||||
def _get_config_path() -> Path:
|
||||
@@ -190,7 +189,6 @@ def get_config() -> dict:
|
||||
|
||||
def reload_config() -> None:
|
||||
"""Reload config.yaml from the active profile's directory."""
|
||||
global _cfg_mtime
|
||||
with _cfg_lock:
|
||||
_cfg_cache.clear()
|
||||
config_path = _get_config_path()
|
||||
@@ -198,13 +196,9 @@ def reload_config() -> None:
|
||||
import yaml as _yaml
|
||||
|
||||
if config_path.exists():
|
||||
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
loaded = _yaml.safe_load(config_path.read_text())
|
||||
if isinstance(loaded, dict):
|
||||
_cfg_cache.update(loaded)
|
||||
try:
|
||||
_cfg_mtime = Path(config_path).stat().st_mtime
|
||||
except OSError:
|
||||
_cfg_mtime = 0.0
|
||||
except Exception:
|
||||
logger.debug("Failed to load yaml config from %s", config_path)
|
||||
|
||||
@@ -282,7 +276,7 @@ def _discover_default_workspace() -> Path:
|
||||
|
||||
|
||||
DEFAULT_WORKSPACE = _discover_default_workspace()
|
||||
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "") # Empty = use provider default; avoids showing unavailable OpenAI model to non-OpenAI users (#646)
|
||||
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "openai/gpt-5.4-mini")
|
||||
|
||||
|
||||
# ── Startup diagnostics ───────────────────────────────────────────────────────
|
||||
@@ -386,10 +380,6 @@ MIME_MAP = {
|
||||
".bmp": "image/bmp",
|
||||
".pdf": "application/pdf",
|
||||
".json": "application/json",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
|
||||
@@ -426,35 +416,31 @@ CLI_TOOLSETS = _resolve_cli_toolsets()
|
||||
# ── Model / provider discovery ───────────────────────────────────────────────
|
||||
|
||||
# Hardcoded fallback models (used when no config.yaml or agent is available)
|
||||
# Also used as the OpenRouter model list — keep this curated to current, widely-used models.
|
||||
_FALLBACK_MODELS = [
|
||||
# OpenAI
|
||||
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"provider": "OpenAI", "id": "openai/gpt-5.4", "label": "GPT-5.4"},
|
||||
# Anthropic — 4.6 flagship + 4.5 generation
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-opus-4.6", "label": "Claude Opus 4.6"},
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
||||
# Google — 3.x (latest preview) + 2.5 (stable GA)
|
||||
{"provider": "Google", "id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"provider": "Google", "id": "google/gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"provider": "Google", "id": "google/gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"provider": "Google", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"provider": "Google", "id": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
# DeepSeek
|
||||
{"provider": "DeepSeek", "id": "deepseek/deepseek-chat-v3-0324", "label": "DeepSeek V3"},
|
||||
{"provider": "DeepSeek", "id": "deepseek/deepseek-r1", "label": "DeepSeek R1"},
|
||||
# Qwen (Alibaba) — strong coding and general models
|
||||
{"provider": "Qwen", "id": "qwen/qwen3-coder", "label": "Qwen3 Coder"},
|
||||
{"provider": "Qwen", "id": "qwen/qwen3.6-plus", "label": "Qwen3.6 Plus"},
|
||||
# xAI
|
||||
{"provider": "xAI", "id": "x-ai/grok-4.20", "label": "Grok 4.20"},
|
||||
# Mistral
|
||||
{"provider": "Mistral", "id": "mistralai/mistral-large-latest", "label": "Mistral Large"},
|
||||
# MiniMax
|
||||
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7", "label": "MiniMax M2.7"},
|
||||
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"},
|
||||
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"provider": "OpenAI", "id": "openai/o4-mini", "label": "o4-mini"},
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"id": "anthropic/claude-sonnet-4.6",
|
||||
"label": "Claude Sonnet 4.6",
|
||||
},
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"id": "anthropic/claude-sonnet-4-5",
|
||||
"label": "Claude Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"id": "anthropic/claude-haiku-4-5",
|
||||
"label": "Claude Haiku 4.5",
|
||||
},
|
||||
{"provider": "Other", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{
|
||||
"provider": "Other",
|
||||
"id": "deepseek/deepseek-chat-v3-0324",
|
||||
"label": "DeepSeek V3",
|
||||
},
|
||||
{"provider": "Other", "id": "meta-llama/llama-4-scout", "label": "Llama 4 Scout"},
|
||||
]
|
||||
|
||||
# Provider display names for known Hermes provider IDs
|
||||
@@ -477,9 +463,6 @@ _PROVIDER_DISPLAY = {
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"lmstudio": "LM Studio",
|
||||
"mistralai": "Mistral",
|
||||
"qwen": "Qwen",
|
||||
"x-ai": "xAI",
|
||||
}
|
||||
|
||||
# Well-known models per provider (used to populate dropdown for direct API providers)
|
||||
@@ -492,7 +475,7 @@ _PROVIDER_MODELS = {
|
||||
],
|
||||
"openai": [
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
{"id": "o4-mini", "label": "o4-mini"},
|
||||
],
|
||||
"openai-codex": [
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
@@ -504,11 +487,7 @@ _PROVIDER_MODELS = {
|
||||
{"id": "codex-mini-latest", "label": "Codex Mini (latest)"},
|
||||
],
|
||||
"google": [
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
],
|
||||
"deepseek": [
|
||||
{"id": "deepseek-chat-v3-0324", "label": "DeepSeek V3"},
|
||||
@@ -518,7 +497,7 @@ _PROVIDER_MODELS = {
|
||||
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"},
|
||||
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"},
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini (via Nous)"},
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro (via Nous)"},
|
||||
],
|
||||
"zai": [
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
@@ -548,7 +527,7 @@ _PROVIDER_MODELS = {
|
||||
{"id": "gpt-4o", "label": "GPT-4o"},
|
||||
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6"},
|
||||
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
],
|
||||
# OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits)
|
||||
"opencode-zen": [
|
||||
@@ -575,11 +554,8 @@ _PROVIDER_MODELS = {
|
||||
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
|
||||
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
||||
{"id": "claude-3-5-haiku", "label": "Claude 3.5 Haiku"},
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
{"id": "gemini-3.1-pro", "label": "Gemini 3.1 Pro"},
|
||||
{"id": "gemini-3-flash", "label": "Gemini 3 Flash"},
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
{"id": "glm-5", "label": "GLM-5"},
|
||||
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
|
||||
@@ -599,29 +575,17 @@ _PROVIDER_MODELS = {
|
||||
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
|
||||
],
|
||||
# 'gemini' is the hermes_cli provider ID for Google AI Studio
|
||||
# Model IDs are bare — sent directly to:
|
||||
# https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
|
||||
"gemini": [
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
],
|
||||
# Mistral — prefix used in OpenRouter model IDs (mistralai/mistral-large-latest)
|
||||
"mistralai": [
|
||||
{"id": "mistral-large-latest", "label": "Mistral Large"},
|
||||
{"id": "mistral-small-latest", "label": "Mistral Small"},
|
||||
],
|
||||
# Qwen (Alibaba) — prefix used in OpenRouter model IDs (qwen/qwen3-coder)
|
||||
"qwen": [
|
||||
{"id": "qwen3-coder", "label": "Qwen3 Coder"},
|
||||
{"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"},
|
||||
],
|
||||
# xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
|
||||
"x-ai": [
|
||||
{"id": "grok-4.20", "label": "Grok 4.20"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.0-flash", "label": "Gemini 2.0 Flash"},
|
||||
],
|
||||
# 'lmstudio' — empty list here; the build loop detects live models via
|
||||
# provider_model_ids() when this list is empty. Ollama uses the same
|
||||
# pattern. Custom /v1/models fetching is handled separately for base_url
|
||||
# configs; this entry only exists so the "elif pid in _PROVIDER_MODELS"
|
||||
# branch is taken (enabling live model injection) rather than the generic
|
||||
# "unknown provider" fallback.
|
||||
"lmstudio": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -692,14 +656,10 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
|
||||
# The user has explicitly pointed at a base_url, so trust their routing config.
|
||||
if config_base_url:
|
||||
# Only strip the provider prefix when it's a known provider namespace
|
||||
# (e.g. "openai/gpt-5.4" → "gpt-5.4" for a custom OpenAI-compatible proxy).
|
||||
# Unknown prefixes (e.g. "zai-org/GLM-5.1" on DeepInfra) are intrinsic to
|
||||
# the model ID and must be preserved — stripping them causes model_not_found.
|
||||
if prefix in _PROVIDER_MODELS:
|
||||
return bare, config_provider, config_base_url
|
||||
# Unknown prefix (not a named provider) — pass full model_id through.
|
||||
return model_id, config_provider, config_base_url
|
||||
# Strip provider prefix (e.g. 'openai/gpt-5.4' -> 'gpt-5.4') so prefixed
|
||||
# model IDs from previous sessions don't break custom endpoint routing.
|
||||
bare_model = model_id.split('/', 1)[-1]
|
||||
return bare_model, config_provider, config_base_url
|
||||
# If prefix does NOT match config provider, the user picked a cross-provider model
|
||||
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
||||
# In this case always route through openrouter with the full provider/model string.
|
||||
@@ -725,15 +685,9 @@ def get_available_models() -> dict:
|
||||
'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
|
||||
}
|
||||
"""
|
||||
# Reload config from disk if config.yaml has changed since last load.
|
||||
# This ensures CLI model changes are picked up on page refresh without
|
||||
# a server restart, while avoiding clearing in-memory mocks during tests. (#585)
|
||||
try:
|
||||
_current_mtime = Path(_get_config_path()).stat().st_mtime
|
||||
except OSError:
|
||||
_current_mtime = 0.0
|
||||
if _current_mtime != _cfg_mtime:
|
||||
reload_config()
|
||||
# Reload config on every request so config.yaml changes take effect immediately
|
||||
reload_config()
|
||||
|
||||
active_provider = None
|
||||
default_model = DEFAULT_MODEL
|
||||
groups = []
|
||||
@@ -770,7 +724,7 @@ def get_available_models() -> dict:
|
||||
try:
|
||||
import json as _j
|
||||
|
||||
auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8"))
|
||||
auth_store = _j.loads(auth_store_path.read_text())
|
||||
active_provider = auth_store.get("active_provider")
|
||||
except Exception:
|
||||
logger.debug("Failed to load auth store from %s", auth_store_path)
|
||||
@@ -818,7 +772,7 @@ def get_available_models() -> dict:
|
||||
env_keys = {}
|
||||
if hermes_env_path.exists():
|
||||
try:
|
||||
for line in hermes_env_path.read_text(encoding="utf-8").splitlines():
|
||||
for line in hermes_env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
@@ -831,14 +785,11 @@ def get_available_models() -> dict:
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
):
|
||||
val = os.getenv(k)
|
||||
if val:
|
||||
@@ -851,8 +802,6 @@ def get_available_models() -> dict:
|
||||
detected_providers.add("openrouter")
|
||||
if all_env.get("GOOGLE_API_KEY"):
|
||||
detected_providers.add("google")
|
||||
if all_env.get("GEMINI_API_KEY"):
|
||||
detected_providers.add("gemini")
|
||||
if all_env.get("GLM_API_KEY"):
|
||||
detected_providers.add("zai")
|
||||
if all_env.get("KIMI_API_KEY"):
|
||||
@@ -866,7 +815,20 @@ def get_available_models() -> dict:
|
||||
if all_env.get("OPENCODE_GO_API_KEY"):
|
||||
detected_providers.add("opencode-go")
|
||||
|
||||
# 3. Fetch models from custom endpoint if base_url is configured
|
||||
# 3. Auto-detect LM Studio if it's running on localhost:1234 (no API key needed).
|
||||
# This ensures lmstudio appears in the dropdown even when authenticated=False.
|
||||
# Also auto-detect Ollama on localhost:11434.
|
||||
for _host, _port, _pid in [("localhost", 1234, "lmstudio"), ("localhost", 11434, "ollama")]:
|
||||
if _pid not in detected_providers:
|
||||
import socket as _socket
|
||||
try:
|
||||
with _socket.create_connection((_host, _port), timeout=2):
|
||||
detected_providers.add(_pid)
|
||||
logger.debug("Auto-detected %s on %s:%d", _pid, _host, _port)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3b. Fetch models from custom endpoint if base_url is configured
|
||||
auto_detected_models = []
|
||||
if cfg_base_url:
|
||||
try:
|
||||
@@ -1005,37 +967,18 @@ def get_available_models() -> dict:
|
||||
# 3b. Include models from custom_providers config entries.
|
||||
# These are explicitly configured and should always appear even when the
|
||||
# /v1/models endpoint is unreachable or returns a subset.
|
||||
#
|
||||
# Each entry may carry a `name` field (e.g. "Agent37"). When present we
|
||||
# use it as the dropdown section header instead of the generic "Custom"
|
||||
# label. Internally we key these providers as "custom:<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:
|
||||
|
||||
173
api/routes.py
173
api/routes.py
@@ -53,6 +53,7 @@ from api.helpers import (
|
||||
redact_session_data,
|
||||
_redact_text,
|
||||
)
|
||||
from api import mc as _mc
|
||||
|
||||
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
|
||||
import re as _re
|
||||
@@ -755,6 +756,20 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/memory":
|
||||
return _handle_memory_read(handler)
|
||||
|
||||
# ── Mission Control (GET) ──
|
||||
if parsed.path == "/api/mc/status":
|
||||
return j(handler, _mc.get_dashboard_status())
|
||||
|
||||
if parsed.path == "/api/mc/priorities":
|
||||
return j(handler, {"priorities": _mc.get_priorities()})
|
||||
|
||||
if parsed.path == "/api/mc/tasks":
|
||||
return j(handler, {"tasks": _mc.get_tasks()})
|
||||
|
||||
if parsed.path == "/api/mc/feed":
|
||||
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
|
||||
return j(handler, {"feed": _mc.get_feed(limit=limit)})
|
||||
|
||||
# ── Profile API (GET) ──
|
||||
if parsed.path == "/api/profiles":
|
||||
from api.profiles import list_profiles_api, get_active_profile_name
|
||||
@@ -772,6 +787,52 @@ def handle_get(handler, parsed) -> bool:
|
||||
{"name": get_active_profile_name(), "path": str(get_active_hermes_home())},
|
||||
)
|
||||
|
||||
# ── Commands API (dynamic from Hermes COMMAND_REGISTRY) ──
|
||||
if parsed.path == "/api/commands":
|
||||
import sys as _sys
|
||||
from pathlib import Path as _P
|
||||
|
||||
# Add hermes-agent to path so we can import the registry
|
||||
_agent_path = (_P(__file__).parent.parent / "hermes-agent").resolve()
|
||||
if str(_agent_path) not in _sys.path:
|
||||
_sys.path.insert(0, str(_agent_path))
|
||||
|
||||
try:
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
|
||||
# Map Hermes category names to WebUI category labels
|
||||
_cat_map = {
|
||||
"Session": "Session",
|
||||
"Config": "Configuration",
|
||||
"Tool": "Tools & Skills",
|
||||
"Skill": "Tools & Skills",
|
||||
"Info": "Info",
|
||||
"Agent": "Agents",
|
||||
"System": "System",
|
||||
}
|
||||
|
||||
_by_cat: dict[str, list] = {}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if getattr(cmd, "cli_only", False):
|
||||
continue # skip CLI-only commands
|
||||
cat = _cat_map.get(cmd.category, "Info")
|
||||
if cat not in _by_cat:
|
||||
_by_cat[cat] = []
|
||||
_by_cat[cat].append({
|
||||
"name": cmd.name,
|
||||
"desc": cmd.description,
|
||||
"arg": getattr(cmd, "args_hint", None) or "(none)",
|
||||
"aliases": list(getattr(cmd, "aliases", []) or []),
|
||||
})
|
||||
|
||||
# Always include Agents section even if empty (Tier-2 agents are added client-side)
|
||||
if "Agents" not in _by_cat:
|
||||
_by_cat["Agents"] = []
|
||||
|
||||
return j(handler, {"categories": _by_cat})
|
||||
except Exception as e:
|
||||
return j(handler, {"error": str(e)}, status=500)
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
@@ -1047,6 +1108,67 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/memory/write":
|
||||
return _handle_memory_write(handler, body)
|
||||
|
||||
# ── Mission Control (POST) ──
|
||||
if parsed.path == "/api/mc/priority/create":
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return bad(handler, "name is required")
|
||||
priority = _mc.create_priority(name, body.get("color", "#808080"))
|
||||
return j(handler, {"ok": True, "priority": priority})
|
||||
|
||||
if parsed.path == "/api/mc/priority/update":
|
||||
pid = body.get("id")
|
||||
if not pid:
|
||||
return bad(handler, "id is required")
|
||||
updated = _mc.update_priority(
|
||||
pid,
|
||||
name=body.get("name"),
|
||||
color=body.get("color"),
|
||||
done=body.get("done"),
|
||||
)
|
||||
if not updated:
|
||||
return bad(handler, "priority not found", 404)
|
||||
return j(handler, {"ok": True, "priority": updated})
|
||||
|
||||
if parsed.path == "/api/mc/priority/delete":
|
||||
pid = body.get("id")
|
||||
if not pid:
|
||||
return bad(handler, "id is required")
|
||||
deleted = _mc.delete_priority(pid)
|
||||
return j(handler, {"ok": deleted})
|
||||
|
||||
if parsed.path == "/api/mc/task/create":
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
return bad(handler, "title is required")
|
||||
task = _mc.create_task(
|
||||
title,
|
||||
priority=body.get("priority", 1),
|
||||
status=body.get("status", "backlog"),
|
||||
)
|
||||
return j(handler, {"ok": True, "task": task})
|
||||
|
||||
if parsed.path == "/api/mc/task/update":
|
||||
tid = body.get("id")
|
||||
if not tid:
|
||||
return bad(handler, "id is required")
|
||||
updated = _mc.update_task(
|
||||
tid,
|
||||
title=body.get("title"),
|
||||
priority=body.get("priority"),
|
||||
status=body.get("status"),
|
||||
)
|
||||
if not updated:
|
||||
return bad(handler, "task not found", 404)
|
||||
return j(handler, {"ok": True, "task": updated})
|
||||
|
||||
if parsed.path == "/api/mc/task/delete":
|
||||
tid = body.get("id")
|
||||
if not tid:
|
||||
return bad(handler, "id is required")
|
||||
deleted = _mc.delete_task(tid)
|
||||
return j(handler, {"ok": deleted})
|
||||
|
||||
# ── Profile API (POST) ──
|
||||
if parsed.path == "/api/profile/switch":
|
||||
name = body.get("name", "").strip()
|
||||
@@ -1113,6 +1235,57 @@ def handle_post(handler, parsed) -> bool:
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 409)
|
||||
|
||||
# ── Gateway API ──
|
||||
if parsed.path == "/api/gateways":
|
||||
# GET - list all gateways
|
||||
from api.gateways import list_gateways_api
|
||||
return j(handler, {"gateways": list_gateways_api()})
|
||||
|
||||
if parsed.path == "/api/gateway/start":
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return bad(handler, "name is required")
|
||||
from api.gateways import start_gateway_api
|
||||
try:
|
||||
result = start_gateway_api(name)
|
||||
return j(handler, result)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
if parsed.path == "/api/gateway/stop":
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return bad(handler, "name is required")
|
||||
from api.gateways import stop_gateway_api
|
||||
try:
|
||||
result = stop_gateway_api(name)
|
||||
return j(handler, result)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
if parsed.path == "/api/gateway/restart":
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return bad(handler, "name is required")
|
||||
from api.gateways import restart_gateway_api
|
||||
try:
|
||||
result = restart_gateway_api(name)
|
||||
return j(handler, result)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
if parsed.path == "/api/gateway/add":
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return bad(handler, "name is required")
|
||||
gw_type = body.get("type", "telegram").strip()
|
||||
from api.gateways import add_gateway_api
|
||||
try:
|
||||
result = add_gateway_api(name, gw_type)
|
||||
return j(handler, result)
|
||||
except (ValueError, RuntimeError, FileExistsError) as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
# ── Settings (POST) ──
|
||||
if parsed.path == "/api/settings":
|
||||
from api.auth import (
|
||||
|
||||
19
server.py
19
server.py
@@ -86,6 +86,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Load ~/.hermes/.env into os.environ so API keys are available
|
||||
# (mirrors what run_agent.py does via load_hermes_dotenv).
|
||||
from pathlib import Path as _P
|
||||
import os as _os
|
||||
_env_file = _P(_os.environ.get('HERMES_HOME', str(_P.home() / '.hermes'))) / '.env'
|
||||
if _env_file.exists():
|
||||
try:
|
||||
with open(_env_file) as _f:
|
||||
for _line in _f:
|
||||
_line = _line.strip()
|
||||
if _line and not _line.startswith('#') and '=' in _line:
|
||||
_k, _v = _line.split('=', 1)
|
||||
_k = _k.strip()
|
||||
_v = _v.strip().strip('"').strip("'")
|
||||
if _k and _k not in _os.environ:
|
||||
_os.environ[_k] = _v
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
|
||||
|
||||
print_startup_config()
|
||||
|
||||
@@ -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')){
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '你有未保存的更改。',
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
360
static/panels.js
360
static/panels.js
@@ -16,6 +16,7 @@ async function switchPanel(name) {
|
||||
if (name === 'workspaces') await loadWorkspacesPanel();
|
||||
if (name === 'profiles') await loadProfilesPanel();
|
||||
if (name === 'todos') loadTodos();
|
||||
if (name === 'missioncontrol') await loadMissionControl();
|
||||
}
|
||||
|
||||
// ── Cron panel ──
|
||||
@@ -39,6 +40,8 @@ async function loadCrons() {
|
||||
item.innerHTML = `
|
||||
<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,'"')})">${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(/("[^&]*?"|'[^&]*?')/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
|
||||
|
||||
1285
static/style.css
1285
static/style.css
File diff suppressed because it is too large
Load Diff
825
static/ui.js
825
static/ui.js
File diff suppressed because it is too large
Load Diff
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
|
||||
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*)(")([\w\s.\/\-_@:]+)(")(\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*)("[^&]*?")/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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user