diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 416add3..d51bb1f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,7 +28,8 @@ This makes the code easy to modify from a terminal or by an agent. / server.py Thin routing shell + HTTP Handler + auth middleware. ~81 lines. Delegates all route handling to api/routes.py. - start.sh Discovery script: finds agent dir, Python, starts server. + bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open. + start.sh Thin wrapper around bootstrap.py for shell-based startup. Dockerfile python:3.12-slim container image (~23 lines) docker-compose.yml Compose config with named volume and optional auth (~22 lines) .dockerignore Excludes .git, tests/, .env* from Docker builds @@ -39,6 +40,7 @@ This makes the code easy to modify from a terminal or by an agent. helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines) models.py Session model + CRUD, per-session profile tracking (~137 lines) profiles.py Profile state management, hermes_cli wrapper (~246 lines) + onboarding.py First-run onboarding status, real provider config writes, and readiness detection. routes.py All GET + POST route handlers (~1180 lines) startup.py Startup helpers: auto_install_agent_deps() (~50 lines) streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines) @@ -53,6 +55,7 @@ This makes the code easy to modify from a terminal or by an agent. messages.js send(), SSE event handlers, approval, transcript (~297 lines) panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines) commands.js Slash command registry, parser, autocomplete dropdown (~156 lines) + onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration. boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines) tests/ conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines) diff --git a/README.md b/README.md index 83422c6..a4d57eb 100644 --- a/README.md +++ b/README.md @@ -92,29 +92,31 @@ ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side. ## Quick start -First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) on your computer or server. This includes the following steps to complete: - -* [ ] Running the `curl` command to download and setup Hermes -* [ ] Configure your [LLM provider](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart#2-set-up-a-provider) with `hermes model` -* [ ] Configure yout [messaging gateways](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/) with `hermes gateway setup` -* [ ] Can start chatting with hermes on command-line with `hermes` -* [ ] Optional: [Configure your extended memory provider](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers) -* [ ] Optional: [Configure your tools](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) - -Once installed, you can now setup the web UI with: +Run the repo bootstrap: ```bash git clone https://github.com/nesquena/hermes-webui.git hermes-webui cd hermes-webui +python3 bootstrap.py +``` + +Or keep using the shell launcher: + +```bash ./start.sh ``` -That is it! The script will: +The bootstrap will: -1. Locate your Hermes agent directory automatically. -2. Find (or create) a Python environment with the required dependencies. -3. Start the web server. -4. Print the URL (and SSH tunnel command if you are on a remote machine). +1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`). +2. Find or create a Python environment with the WebUI dependencies. +3. Start the web server and wait for `/health`. +4. Open the browser unless you pass `--no-browser`. +5. Drop you into a first-run onboarding wizard inside the WebUI. + +> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2. + +If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser. --- diff --git a/api/config.py b/api/config.py index 9f63f83..d0de2b2 100644 --- a/api/config.py +++ b/api/config.py @@ -8,6 +8,7 @@ Discovery order for all paths: 3. Hardened defaults relative to $HOME 4. Fail loudly with a human-readable fix-it message if required modules are missing """ + import collections import json import os @@ -20,31 +21,33 @@ from pathlib import Path from urllib.parse import parse_qs, urlparse # ── Basic layout ────────────────────────────────────────────────────────────── -HOME = Path.home() +HOME = Path.home() # REPO_ROOT is the directory that contains this file's parent (api/ -> repo root) REPO_ROOT = Path(__file__).parent.parent.resolve() # ── Network config (env-overridable) ───────────────────────────────────────── -HOST = os.getenv('HERMES_WEBUI_HOST', '127.0.0.1') -PORT = int(os.getenv('HERMES_WEBUI_PORT', '8787')) +HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1") +PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787")) # ── TLS/HTTPS config (optional, env-overridable) ──────────────────────────── -TLS_CERT = os.getenv('HERMES_WEBUI_TLS_CERT', '').strip() or None -TLS_KEY = os.getenv('HERMES_WEBUI_TLS_KEY', '').strip() or None +TLS_CERT = os.getenv("HERMES_WEBUI_TLS_CERT", "").strip() or None +TLS_KEY = os.getenv("HERMES_WEBUI_TLS_KEY", "").strip() or None TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None # ── State directory (env-overridable, never inside repo) ────────────────────── -STATE_DIR = Path(os.getenv( - 'HERMES_WEBUI_STATE_DIR', - str(HOME / '.hermes' / 'webui') -)).expanduser().resolve() +STATE_DIR = ( + Path(os.getenv("HERMES_WEBUI_STATE_DIR", str(HOME / ".hermes" / "webui"))) + .expanduser() + .resolve() +) + +SESSION_DIR = STATE_DIR / "sessions" +WORKSPACES_FILE = STATE_DIR / "workspaces.json" +SESSION_INDEX_FILE = SESSION_DIR / "_index.json" +SETTINGS_FILE = STATE_DIR / "settings.json" +LAST_WORKSPACE_FILE = STATE_DIR / "last_workspace.txt" +PROJECTS_FILE = STATE_DIR / "projects.json" -SESSION_DIR = STATE_DIR / 'sessions' -WORKSPACES_FILE = STATE_DIR / 'workspaces.json' -SESSION_INDEX_FILE = SESSION_DIR / '_index.json' -SETTINGS_FILE = STATE_DIR / 'settings.json' -LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt' -PROJECTS_FILE = STATE_DIR / 'projects.json' # ── Hermes agent directory discovery ───────────────────────────────────────── def _discover_agent_dir() -> Path: @@ -62,28 +65,30 @@ def _discover_agent_dir() -> Path: candidates = [] # 1. Explicit env var - if os.getenv('HERMES_WEBUI_AGENT_DIR'): - candidates.append(Path(os.getenv('HERMES_WEBUI_AGENT_DIR')).expanduser().resolve()) + if os.getenv("HERMES_WEBUI_AGENT_DIR"): + candidates.append( + Path(os.getenv("HERMES_WEBUI_AGENT_DIR")).expanduser().resolve() + ) # 2. HERMES_HOME / hermes-agent - hermes_home = os.getenv('HERMES_HOME', str(HOME / '.hermes')) - candidates.append(Path(hermes_home).expanduser() / 'hermes-agent') + hermes_home = os.getenv("HERMES_HOME", str(HOME / ".hermes")) + candidates.append(Path(hermes_home).expanduser() / "hermes-agent") # 3. Sibling: /../hermes-agent - candidates.append(REPO_ROOT.parent / 'hermes-agent') + candidates.append(REPO_ROOT.parent / "hermes-agent") # 4. Parent is the agent repo itself (repo cloned inside hermes-agent/) - if (REPO_ROOT.parent / 'run_agent.py').exists(): + if (REPO_ROOT.parent / "run_agent.py").exists(): candidates.append(REPO_ROOT.parent) # 5. ~/.hermes/hermes-agent (explicit common path) - candidates.append(HOME / '.hermes' / 'hermes-agent') + candidates.append(HOME / ".hermes" / "hermes-agent") # 6. ~/hermes-agent - candidates.append(HOME / 'hermes-agent') + candidates.append(HOME / "hermes-agent") for path in candidates: - if path.exists() and (path / 'run_agent.py').exists(): + if path.exists() and (path / "run_agent.py").exists(): return path.resolve() return None @@ -99,32 +104,33 @@ def _discover_python(agent_dir: Path) -> str: 3. Local .venv inside this repo 4. System python3 """ - if os.getenv('HERMES_WEBUI_PYTHON'): - return os.getenv('HERMES_WEBUI_PYTHON') + if os.getenv("HERMES_WEBUI_PYTHON"): + return os.getenv("HERMES_WEBUI_PYTHON") if agent_dir: - venv_py = agent_dir / 'venv' / 'bin' / 'python' + venv_py = agent_dir / "venv" / "bin" / "python" if venv_py.exists(): return str(venv_py) # Windows layout - venv_py_win = agent_dir / 'venv' / 'Scripts' / 'python.exe' + venv_py_win = agent_dir / "venv" / "Scripts" / "python.exe" if venv_py_win.exists(): return str(venv_py_win) # Local .venv inside this repo - local_venv = REPO_ROOT / '.venv' / 'bin' / 'python' + local_venv = REPO_ROOT / ".venv" / "bin" / "python" if local_venv.exists(): return str(local_venv) # Fall back to system python3 import shutil - for name in ('python3', 'python'): + + for name in ("python3", "python"): found = shutil.which(name) if found: return found - return 'python3' + return "python3" # Run discovery @@ -157,16 +163,19 @@ else: _cfg_cache = {} _cfg_lock = threading.Lock() + def _get_config_path() -> Path: """Return config.yaml path for the active profile.""" - env_override = os.getenv('HERMES_CONFIG_PATH') + env_override = os.getenv("HERMES_CONFIG_PATH") if env_override: return Path(env_override).expanduser() try: from api.profiles import get_active_hermes_home - return get_active_hermes_home() / 'config.yaml' + + return get_active_hermes_home() / "config.yaml" except ImportError: - return HOME / '.hermes' / 'config.yaml' + return HOME / ".hermes" / "config.yaml" + def get_config() -> dict: """Return the cached config dict, loading from disk if needed.""" @@ -174,6 +183,7 @@ def get_config() -> dict: reload_config() return _cfg_cache + def reload_config() -> None: """Reload config.yaml from the active profile's directory.""" with _cfg_lock: @@ -181,6 +191,7 @@ def reload_config() -> None: config_path = _get_config_path() try: import yaml as _yaml + if config_path.exists(): loaded = _yaml.safe_load(config_path.read_text()) if isinstance(loaded, dict): @@ -188,10 +199,12 @@ def reload_config() -> None: except Exception: pass + # Initial load reload_config() cfg = _cfg_cache # alias for backward compat with existing references + # ── Default workspace discovery ─────────────────────────────────────────────── def _discover_default_workspace() -> Path: """ @@ -200,62 +213,65 @@ def _discover_default_workspace() -> Path: 2. ~/workspace (common Hermes convention) 3. STATE_DIR / workspace (isolated fallback) """ - if os.getenv('HERMES_WEBUI_DEFAULT_WORKSPACE'): - return Path(os.getenv('HERMES_WEBUI_DEFAULT_WORKSPACE')).expanduser().resolve() + if os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"): + return Path(os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE")).expanduser().resolve() - common = HOME / 'workspace' + common = HOME / "workspace" if common.exists(): return common.resolve() - return (STATE_DIR / 'workspace').resolve() + return (STATE_DIR / "workspace").resolve() + DEFAULT_WORKSPACE = _discover_default_workspace() -DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini') +DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "openai/gpt-5.4-mini") + # ── Startup diagnostics ─────────────────────────────────────────────────────── def print_startup_config() -> None: """Print detected configuration at startup so the user can verify what was found.""" - ok = '\033[32m[ok]\033[0m' - warn = '\033[33m[!!]\033[0m' - err = '\033[31m[XX]\033[0m' + ok = "\033[32m[ok]\033[0m" + warn = "\033[33m[!!]\033[0m" + err = "\033[31m[XX]\033[0m" lines = [ - '', - ' Hermes Web UI -- startup config', - ' --------------------------------', - f' repo root : {REPO_ROOT}', - f' agent dir : {_AGENT_DIR if _AGENT_DIR else "NOT FOUND"} {ok if _AGENT_DIR else err}', - f' python : {PYTHON_EXE}', - f' state dir : {STATE_DIR}', - f' workspace : {DEFAULT_WORKSPACE}', - f' host:port : {HOST}:{PORT}', - f' config file : {_get_config_path()} {"(found)" if _get_config_path().exists() else "(not found, using defaults)"}', - '', + "", + " Hermes Web UI -- startup config", + " --------------------------------", + f" repo root : {REPO_ROOT}", + f" agent dir : {_AGENT_DIR if _AGENT_DIR else 'NOT FOUND'} {ok if _AGENT_DIR else err}", + f" python : {PYTHON_EXE}", + f" state dir : {STATE_DIR}", + f" workspace : {DEFAULT_WORKSPACE}", + f" host:port : {HOST}:{PORT}", + f" config file : {_get_config_path()} {'(found)' if _get_config_path().exists() else '(not found, using defaults)'}", + "", ] - print('\n'.join(lines), flush=True) + print("\n".join(lines), flush=True) if not _HERMES_FOUND: print( - f'{err} Could not find the Hermes agent directory.\n' - ' The server will start but agent features will not work.\n' - '\n' - ' To fix, set one of:\n' - ' export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent\n' - ' export HERMES_HOME=/path/to/.hermes\n' - '\n' - ' Or clone hermes-agent as a sibling of this repo:\n' - ' git clone ../hermes-agent\n', - flush=True + f"{err} Could not find the Hermes agent directory.\n" + " The server will start but agent features will not work.\n" + "\n" + " To fix, set one of:\n" + " export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent\n" + " export HERMES_HOME=/path/to/.hermes\n" + "\n" + " Or clone hermes-agent as a sibling of this repo:\n" + " git clone ../hermes-agent\n", + flush=True, ) + def verify_hermes_imports() -> tuple: """ Attempt to import the key Hermes modules. Returns (ok: bool, missing: list[str], errors: dict[str, str]). """ - required = ['run_agent'] - missing = [] - errors = {} + required = ["run_agent"] + missing = [] + errors = {} for mod in required: try: __import__(mod) @@ -266,121 +282,189 @@ def verify_hermes_imports() -> tuple: errors[mod] = f"{type(e).__name__}: {e}" return (len(missing) == 0), missing, errors + # ── Limits ─────────────────────────────────────────────────────────────────── -MAX_FILE_BYTES = 200_000 +MAX_FILE_BYTES = 200_000 MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # ── File type maps ─────────────────────────────────────────────────────────── -IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp'} -MD_EXTS = {'.md', '.markdown', '.mdown'} -CODE_EXTS = {'.py', '.js', '.ts', '.jsx', '.tsx', '.css', '.html', '.json', - '.yaml', '.yml', '.toml', '.sh', '.bash', '.txt', '.log', '.env', - '.csv', '.xml', '.sql', '.rs', '.go', '.java', '.c', '.cpp', '.h'} +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"} +MD_EXTS = {".md", ".markdown", ".mdown"} +CODE_EXTS = { + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".css", + ".html", + ".json", + ".yaml", + ".yml", + ".toml", + ".sh", + ".bash", + ".txt", + ".log", + ".env", + ".csv", + ".xml", + ".sql", + ".rs", + ".go", + ".java", + ".c", + ".cpp", + ".h", +} MIME_MAP = { - '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', - '.gif':'image/gif', '.svg':'image/svg+xml', '.webp':'image/webp', - '.ico':'image/x-icon', '.bmp':'image/bmp', - '.pdf':'application/pdf', '.json':'application/json', + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".bmp": "image/bmp", + ".pdf": "application/pdf", + ".json": "application/json", } # ── Toolsets (from config.yaml or hardcoded default) ───────────────────────── _DEFAULT_TOOLSETS = [ - 'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file', - 'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo', - 'web', 'webhook', + "browser", + "clarify", + "code_execution", + "cronjob", + "delegation", + "file", + "image_gen", + "memory", + "session_search", + "skills", + "terminal", + "todo", + "web", + "webhook", ] -CLI_TOOLSETS = get_config().get('platform_toolsets', {}).get('cli', _DEFAULT_TOOLSETS) +CLI_TOOLSETS = get_config().get("platform_toolsets", {}).get("cli", _DEFAULT_TOOLSETS) # ── Model / provider discovery ─────────────────────────────────────────────── # Hardcoded fallback models (used when no config.yaml or agent is available) _FALLBACK_MODELS = [ - {'provider': 'OpenAI', 'id': 'openai/gpt-5.4-mini', 'label': 'GPT-5.4 Mini'}, - {'provider': 'OpenAI', 'id': 'openai/gpt-4o', 'label': 'GPT-4o'}, - {'provider': 'OpenAI', 'id': 'openai/o3', 'label': 'o3'}, - {'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": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"}, + {"provider": "OpenAI", "id": "openai/gpt-4o", "label": "GPT-4o"}, + {"provider": "OpenAI", "id": "openai/o3", "label": "o3"}, + {"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 _PROVIDER_DISPLAY = { - 'nous': 'Nous Portal', 'openrouter': 'OpenRouter', 'anthropic': 'Anthropic', - 'openai': 'OpenAI', 'openai-codex': 'OpenAI Codex', 'copilot': 'GitHub Copilot', - 'zai': 'Z.AI / GLM', 'kimi-coding': 'Kimi / Moonshot', 'deepseek': 'DeepSeek', - 'minimax': 'MiniMax', 'google': 'Google', 'meta-llama': 'Meta Llama', - 'huggingface': 'HuggingFace', 'alibaba': 'Alibaba', - 'ollama': 'Ollama', 'lmstudio': 'LM Studio', + "nous": "Nous Portal", + "openrouter": "OpenRouter", + "anthropic": "Anthropic", + "openai": "OpenAI", + "openai-codex": "OpenAI Codex", + "copilot": "GitHub Copilot", + "zai": "Z.AI / GLM", + "kimi-coding": "Kimi / Moonshot", + "deepseek": "DeepSeek", + "minimax": "MiniMax", + "google": "Google", + "meta-llama": "Meta Llama", + "huggingface": "HuggingFace", + "alibaba": "Alibaba", + "ollama": "Ollama", + "lmstudio": "LM Studio", } # Well-known models per provider (used to populate dropdown for direct API providers) _PROVIDER_MODELS = { - 'anthropic': [ - {'id': 'claude-opus-4.6', 'label': 'Claude Opus 4.6'}, - {'id': 'claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6'}, - {'id': 'claude-sonnet-4-5', 'label': 'Claude Sonnet 4.5'}, - {'id': 'claude-haiku-4-5', 'label': 'Claude Haiku 4.5'}, + "anthropic": [ + {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}, + {"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"}, + {"id": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5"}, + {"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"}, ], - 'openai': [ - {'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'}, - {'id': 'gpt-4o', 'label': 'GPT-4o'}, - {'id': 'o3', 'label': 'o3'}, - {'id': 'o4-mini', 'label': 'o4-mini'}, + "openai": [ + {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"}, + {"id": "gpt-4o", "label": "GPT-4o"}, + {"id": "o3", "label": "o3"}, + {"id": "o4-mini", "label": "o4-mini"}, ], - 'openai-codex': [ - {'id': 'codex-mini-latest', 'label': 'Codex Mini'}, + "openai-codex": [ + {"id": "codex-mini-latest", "label": "Codex Mini"}, ], - 'google': [ - {'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'}, + "google": [ + {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, ], - 'deepseek': [ - {'id': 'deepseek-chat-v3-0324', 'label': 'DeepSeek V3'}, - {'id': 'deepseek-reasoner', 'label': 'DeepSeek Reasoner'}, + "deepseek": [ + {"id": "deepseek-chat-v3-0324", "label": "DeepSeek V3"}, + {"id": "deepseek-reasoner", "label": "DeepSeek Reasoner"}, ], - 'nous': [ - {'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-2.5-pro', 'label': 'Gemini 2.5 Pro (via Nous)'}, + "nous": [ + {"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-2.5-pro", "label": "Gemini 2.5 Pro (via Nous)"}, ], - 'zai': [ - {'id': 'glm-5.1', 'label': 'GLM-5.1'}, - {'id': 'glm-5', 'label': 'GLM-5'}, - {'id': 'glm-5-turbo', 'label': 'GLM-5 Turbo'}, - {'id': 'glm-4.7', 'label': 'GLM-4.7'}, - {'id': 'glm-4.5', 'label': 'GLM-4.5'}, - {'id': 'glm-4.5-flash', 'label': 'GLM-4.5 Flash'}, + "zai": [ + {"id": "glm-5.1", "label": "GLM-5.1"}, + {"id": "glm-5", "label": "GLM-5"}, + {"id": "glm-5-turbo", "label": "GLM-5 Turbo"}, + {"id": "glm-4.7", "label": "GLM-4.7"}, + {"id": "glm-4.5", "label": "GLM-4.5"}, + {"id": "glm-4.5-flash", "label": "GLM-4.5 Flash"}, ], - 'kimi-coding': [ - {'id': 'moonshot-v1-8k', 'label': 'Moonshot v1 8k'}, - {'id': 'moonshot-v1-32k', 'label': 'Moonshot v1 32k'}, - {'id': 'moonshot-v1-128k', 'label': 'Moonshot v1 128k'}, - {'id': 'kimi-latest', 'label': 'Kimi Latest'}, + "kimi-coding": [ + {"id": "moonshot-v1-8k", "label": "Moonshot v1 8k"}, + {"id": "moonshot-v1-32k", "label": "Moonshot v1 32k"}, + {"id": "moonshot-v1-128k", "label": "Moonshot v1 128k"}, + {"id": "kimi-latest", "label": "Kimi Latest"}, ], - 'minimax': [ - {'id': 'MiniMax-M2.7', 'label': 'MiniMax M2.7'}, - {'id': 'MiniMax-M2.7-highspeed', 'label': 'MiniMax M2.7 Highspeed'}, - {'id': 'MiniMax-M2.5', 'label': 'MiniMax M2.5'}, - {'id': 'MiniMax-M2.5-highspeed', 'label': 'MiniMax M2.5 Highspeed'}, - {'id': 'MiniMax-M2.1', 'label': 'MiniMax M2.1'}, + "minimax": [ + {"id": "MiniMax-M2.7", "label": "MiniMax M2.7"}, + {"id": "MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"}, + {"id": "MiniMax-M2.5", "label": "MiniMax M2.5"}, + {"id": "MiniMax-M2.5-highspeed", "label": "MiniMax M2.5 Highspeed"}, + {"id": "MiniMax-M2.1", "label": "MiniMax M2.1"}, ], # GitHub Copilot — model IDs served via the Copilot API - 'copilot': [ - {'id': 'gpt-5.4', 'label': 'GPT-5.4'}, - {'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'}, - {'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-2.5-pro', 'label': 'Gemini 2.5 Pro'}, + "copilot": [ + {"id": "gpt-5.4", "label": "GPT-5.4"}, + {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"}, + {"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-2.5-pro", "label": "Gemini 2.5 Pro"}, ], # 'gemini' is the hermes_cli provider ID for Google AI Studio - 'gemini': [ - {'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'}, - {'id': 'gemini-2.0-flash', 'label': 'Gemini 2.0 Flash'}, + "gemini": [ + {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, + {"id": "gemini-2.0-flash", "label": "Gemini 2.0 Flash"}, ], } @@ -408,42 +492,42 @@ def resolve_model_provider(model_id: str) -> tuple: """ config_provider = None config_base_url = None - model_cfg = cfg.get('model', {}) + model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): - config_provider = model_cfg.get('provider') - config_base_url = model_cfg.get('base_url') + config_provider = model_cfg.get("provider") + config_base_url = model_cfg.get("base_url") - model_id = (model_id or '').strip() + model_id = (model_id or "").strip() if not model_id: return model_id, config_provider, config_base_url # Custom providers declared in config.yaml should win over slash-based # OpenRouter heuristics. Their model IDs commonly contain '/' too. - custom_providers = cfg.get('custom_providers', []) + custom_providers = cfg.get("custom_providers", []) if isinstance(custom_providers, list): for entry in custom_providers: if not isinstance(entry, dict): continue - entry_model = (entry.get('model') or '').strip() - entry_name = (entry.get('name') or '').strip() - entry_base_url = (entry.get('base_url') or '').strip() + entry_model = (entry.get("model") or "").strip() + entry_name = (entry.get("name") or "").strip() + entry_base_url = (entry.get("base_url") or "").strip() if entry_model and entry_name and model_id == entry_model: - provider_hint = 'custom:' + entry_name.lower().replace(' ', '-') + provider_hint = "custom:" + entry_name.lower().replace(" ", "-") return model_id, provider_hint, entry_base_url or None # @provider:model format — explicit provider hint from the dropdown. # Route through that provider directly (resolve_runtime_provider will # resolve credentials in streaming.py). - if model_id.startswith('@') and ':' in model_id: - provider_hint, bare_model = model_id[1:].split(':', 1) + if model_id.startswith("@") and ":" in model_id: + provider_hint, bare_model = model_id[1:].split(":", 1) return bare_model, provider_hint, None - if '/' in model_id: - prefix, bare = model_id.split('/', 1) + if "/" in model_id: + prefix, bare = model_id.split("/", 1) # OpenRouter always needs the full provider/model path (e.g. openrouter/free, # anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter. - if config_provider == 'openrouter': - return model_id, 'openrouter', config_base_url + if config_provider == "openrouter": + return model_id, "openrouter", config_base_url # If prefix matches config provider exactly, strip it and use that provider directly. # e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API if config_provider and prefix == config_provider: @@ -457,7 +541,7 @@ def resolve_model_provider(model_id: str) -> tuple: # 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. if prefix in _PROVIDER_MODELS and prefix != config_provider: - return model_id, 'openrouter', None + return model_id, "openrouter", None return model_id, config_provider, config_base_url @@ -483,20 +567,22 @@ def get_available_models() -> dict: groups = [] # 1. Read config.yaml model section - cfg_base_url = '' # must be defined before conditional blocks (#117) - model_cfg = cfg.get('model', {}) - cfg_base_url = '' + cfg_base_url = "" # must be defined before conditional blocks (#117) + model_cfg = cfg.get("model", {}) + cfg_base_url = "" if isinstance(model_cfg, str): default_model = model_cfg elif isinstance(model_cfg, dict): - active_provider = model_cfg.get('provider') - cfg_default = model_cfg.get('default', '') - cfg_base_url = model_cfg.get('base_url', '') + active_provider = model_cfg.get("provider") + cfg_default = model_cfg.get("default", "") + cfg_base_url = model_cfg.get("base_url", "") if cfg_default: default_model = cfg_default # 2. Also check env vars for model override - env_model = os.getenv('HERMES_MODEL') or os.getenv('OPENAI_MODEL') or os.getenv('LLM_MODEL') + env_model = ( + os.getenv("HERMES_MODEL") or os.getenv("OPENAI_MODEL") or os.getenv("LLM_MODEL") + ) if env_model: default_model = env_model.strip() @@ -504,14 +590,16 @@ def get_available_models() -> dict: if not active_provider: try: from api.profiles import get_active_hermes_home as _gah - auth_store_path = _gah() / 'auth.json' + + auth_store_path = _gah() / "auth.json" except ImportError: - auth_store_path = HOME / '.hermes' / 'auth.json' + auth_store_path = HOME / ".hermes" / "auth.json" if auth_store_path.exists(): try: import json as _j + auth_store = _j.loads(auth_store_path.read_text()) - active_provider = auth_store.get('active_provider') + active_provider = auth_store.get("active_provider") except Exception: pass @@ -529,19 +617,20 @@ def get_available_models() -> dict: try: from hermes_cli.models import list_available_providers as _lap from hermes_cli.auth import get_auth_status as _gas + for _p in _lap(): - if not _p.get('authenticated'): + if not _p.get("authenticated"): continue # Exclude providers whose credential came from an ambient token # (e.g. 'gh auth token' for Copilot on a machine with gh CLI auth). # Only include providers with an explicit, dedicated credential. try: - _src = _gas(_p['id']).get('key_source', '') - if _src == 'gh auth token': + _src = _gas(_p["id"]).get("key_source", "") + if _src == "gh auth token": continue except Exception: pass - detected_providers.add(_p['id']) + detected_providers.add(_p["id"]) _hermes_auth_used = True except Exception: pass @@ -550,41 +639,49 @@ def get_available_models() -> dict: # Fallback: scan .env and os.environ for known API key variables try: from api.profiles import get_active_hermes_home as _gah2 - hermes_env_path = _gah2() / '.env' + + hermes_env_path = _gah2() / ".env" except ImportError: - hermes_env_path = HOME / '.hermes' / '.env' + hermes_env_path = HOME / ".hermes" / ".env" env_keys = {} if hermes_env_path.exists(): try: 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) + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) env_keys[k.strip()] = v.strip().strip('"').strip("'") except Exception: pass all_env = {**env_keys} - for k in ('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY', - 'GOOGLE_API_KEY', 'GLM_API_KEY', 'KIMI_API_KEY', 'DEEPSEEK_API_KEY'): + for k in ( + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GOOGLE_API_KEY", + "GLM_API_KEY", + "KIMI_API_KEY", + "DEEPSEEK_API_KEY", + ): val = os.getenv(k) if val: all_env[k] = val - if all_env.get('ANTHROPIC_API_KEY'): - detected_providers.add('anthropic') - if all_env.get('OPENAI_API_KEY'): - detected_providers.add('openai') - if all_env.get('OPENROUTER_API_KEY'): - detected_providers.add('openrouter') - if all_env.get('GOOGLE_API_KEY'): - detected_providers.add('google') - if all_env.get('GLM_API_KEY'): - detected_providers.add('zai') - if all_env.get('KIMI_API_KEY'): - detected_providers.add('kimi-coding') - if all_env.get('MINIMAX_API_KEY') or all_env.get('MINIMAX_CN_API_KEY'): - detected_providers.add('minimax') - if all_env.get('DEEPSEEK_API_KEY'): - detected_providers.add('deepseek') + if all_env.get("ANTHROPIC_API_KEY"): + detected_providers.add("anthropic") + if all_env.get("OPENAI_API_KEY"): + detected_providers.add("openai") + if all_env.get("OPENROUTER_API_KEY"): + detected_providers.add("openrouter") + if all_env.get("GOOGLE_API_KEY"): + detected_providers.add("google") + if all_env.get("GLM_API_KEY"): + detected_providers.add("zai") + if all_env.get("KIMI_API_KEY"): + detected_providers.add("kimi-coding") + if all_env.get("MINIMAX_API_KEY") or all_env.get("MINIMAX_CN_API_KEY"): + detected_providers.add("minimax") + if all_env.get("DEEPSEEK_API_KEY"): + detected_providers.add("deepseek") # 3. Fetch models from custom endpoint if base_url is configured auto_detected_models = [] @@ -595,26 +692,30 @@ def get_available_models() -> dict: # Normalize the base_url and build models endpoint base_url = cfg_base_url.strip() - if base_url.endswith('/v1'): - endpoint_url = base_url + '/models' # /v1/models + if base_url.endswith("/v1"): + endpoint_url = base_url + "/models" # /v1/models else: - endpoint_url = base_url.rstrip('/') + '/v1/models' + endpoint_url = base_url.rstrip("/") + "/v1/models" # Detect provider from base_url - provider = 'custom' - parsed = urlparse(base_url if '://' in base_url else f'http://{base_url}') + provider = "custom" + parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}") host = (parsed.netloc or parsed.path).lower() if parsed.hostname: try: addr = ipaddress.ip_address(parsed.hostname) if addr.is_private or addr.is_loopback or addr.is_link_local: - if 'ollama' in host or '127.0.0.1' in host or 'localhost' in host: - provider = 'ollama' - elif 'lmstudio' in host or 'lm-studio' in host: - provider = 'lmstudio' + if ( + "ollama" in host + or "127.0.0.1" in host + or "localhost" in host + ): + provider = "ollama" + elif "lmstudio" in host or "lm-studio" in host: + provider = "lmstudio" else: - provider = 'local' + provider = "local" except ValueError: pass @@ -624,66 +725,93 @@ def get_available_models() -> dict: # 2. provider-specific providers..api_key / providers.custom.api_key # 3. env/.env fallbacks headers = {} - api_key = '' + api_key = "" if isinstance(model_cfg, dict): - api_key = (model_cfg.get('api_key') or '').strip() + api_key = (model_cfg.get("api_key") or "").strip() if not api_key: - providers_cfg = cfg.get('providers', {}) + providers_cfg = cfg.get("providers", {}) if isinstance(providers_cfg, dict): - for provider_key in filter(None, [active_provider, 'custom']): + for provider_key in filter(None, [active_provider, "custom"]): provider_cfg = providers_cfg.get(provider_key, {}) if isinstance(provider_cfg, dict): - api_key = (provider_cfg.get('api_key') or '').strip() + api_key = (provider_cfg.get("api_key") or "").strip() if api_key: break if not api_key: - api_key_vars = ('HERMES_API_KEY', 'HERMES_OPENAI_API_KEY', 'OPENAI_API_KEY', - 'LOCAL_API_KEY', 'OPENROUTER_API_KEY', 'API_KEY') + api_key_vars = ( + "HERMES_API_KEY", + "HERMES_OPENAI_API_KEY", + "OPENAI_API_KEY", + "LOCAL_API_KEY", + "OPENROUTER_API_KEY", + "API_KEY", + ) for key in api_key_vars: - api_key = (all_env.get(key) or os.getenv(key) or '').strip() + api_key = (all_env.get(key) or os.getenv(key) or "").strip() if api_key: break if api_key: - headers['Authorization'] = f'Bearer {api_key}' + headers["Authorization"] = f"Bearer {api_key}" # Fetch model list from endpoint (with SSRF protection) import socket + # Resolve hostname and check against private IPs after DNS lookup - parsed_url = urlparse(endpoint_url if '://' in endpoint_url else f'http://{endpoint_url}') + parsed_url = urlparse( + endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}" + ) if parsed_url.hostname: try: resolved_ips = socket.getaddrinfo(parsed_url.hostname, None) for _, _, _, _, addr in resolved_ips: addr_obj = ipaddress.ip_address(addr[0]) - if addr_obj.is_private or addr_obj.is_loopback or addr_obj.is_link_local: + if ( + addr_obj.is_private + or addr_obj.is_loopback + or addr_obj.is_link_local + ): # Allow known local providers (ollama, lmstudio) - is_known_local = any(k in (parsed_url.hostname or '').lower() - for k in ('ollama', 'localhost', '127.0.0.1', 'lmstudio', 'lm-studio')) + is_known_local = any( + k in (parsed_url.hostname or "").lower() + for k in ( + "ollama", + "localhost", + "127.0.0.1", + "lmstudio", + "lm-studio", + ) + ) if not is_known_local: - raise ValueError(f'SSRF: resolved hostname to private IP {addr[0]}') + raise ValueError( + f"SSRF: resolved hostname to private IP {addr[0]}" + ) except socket.gaierror: pass # DNS resolution failed -- let urllib handle it - req = urllib.request.Request(endpoint_url, method='GET') - req.add_header('User-Agent', 'OpenAI/Python 1.0') + req = urllib.request.Request(endpoint_url, method="GET") + req.add_header("User-Agent", "OpenAI/Python 1.0") for k, v in headers.items(): req.add_header(k, v) with urllib.request.urlopen(req, timeout=10) as response: - data = json.loads(response.read().decode('utf-8')) + data = json.loads(response.read().decode("utf-8")) # Handle both OpenAI-compatible and llama.cpp response formats models_list = [] - if 'data' in data and isinstance(data['data'], list): - models_list = data['data'] - elif 'models' in data and isinstance(data['models'], list): - models_list = data['models'] + if "data" in data and isinstance(data["data"], list): + models_list = data["data"] + elif "models" in data and isinstance(data["models"], list): + models_list = data["models"] for model in models_list: if not isinstance(model, dict): continue - model_id = model.get('id', '') or model.get('name', '') or model.get('model', '') - model_name = model.get('name', '') or model.get('model', '') or model_id + model_id = ( + model.get("id", "") + or model.get("name", "") + or model.get("model", "") + ) + model_name = model.get("name", "") or model.get("model", "") or model_id if model_id and model_name: - auto_detected_models.append({'id': model_id, 'label': model_name}) + auto_detected_models.append({"id": model_id, "label": model_name}) detected_providers.add(provider.lower()) except Exception: pass # custom endpoint unreachable or misconfigured -- fail silently @@ -691,78 +819,96 @@ 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. - _custom_providers_cfg = cfg.get('custom_providers', []) + _custom_providers_cfg = cfg.get("custom_providers", []) if isinstance(_custom_providers_cfg, list): - _seen_custom_ids = {m['id'] for m in auto_detected_models} + _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_model = _cp.get("model", "") 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}) + _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) - 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 # 'custom' as authenticated whenever base_url is set, which would otherwise # build a phantom "Custom" bucket next to the real provider's group. Drop # it unless the user explicitly chose 'custom' as their active provider. - if active_provider and active_provider != 'custom': - detected_providers.discard('custom') + if active_provider and active_provider != "custom": + detected_providers.discard("custom") # 5. Build model groups if detected_providers: for pid in sorted(detected_providers): provider_name = _PROVIDER_DISPLAY.get(pid, pid.title()) - if pid == 'openrouter': + if pid == "openrouter": # OpenRouter uses provider/model format -- show the fallback list - groups.append({ - 'provider': 'OpenRouter', - 'models': [{'id': m['id'], 'label': m['label']} for m in _FALLBACK_MODELS], - }) + groups.append( + { + "provider": "OpenRouter", + "models": [ + {"id": m["id"], "label": m["label"]} + for m in _FALLBACK_MODELS + ], + } + ) 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[pid] - _active = (active_provider or '').lower() + _active = (active_provider or "").lower() if _active and pid != _active: models = [] for m in raw_models: - mid = m['id'] + mid = m["id"] # Don't double-prefix; use @provider: hint for bare names - if mid.startswith('@') or '/' in mid: - models.append({'id': mid, 'label': m['label']}) + if mid.startswith("@") or "/" in mid: + models.append({"id": mid, "label": m["label"]}) else: - models.append({'id': f'@{pid}:{mid}', 'label': m['label']}) + models.append({"id": f"@{pid}:{mid}", "label": m["label"]}) else: models = list(raw_models) - groups.append({ - 'provider': provider_name, - 'models': models, - }) + groups.append( + { + "provider": provider_name, + "models": models, + } + ) else: # Unknown provider -- use auto-detected models if available, # otherwise fall back to default model placeholder if auto_detected_models: - groups.append({ - 'provider': provider_name, - 'models': auto_detected_models, - }) + groups.append( + { + "provider": provider_name, + "models": auto_detected_models, + } + ) else: - groups.append({ - 'provider': provider_name, - 'models': [{'id': default_model, 'label': default_model.split('/')[-1]}], - }) + 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. - 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, @@ -770,8 +916,8 @@ def get_available_models() -> dict: # Normalize before comparing: strip provider prefix so 'anthropic/claude-opus-4.6' # matches 'claude-opus-4.6' already in the list and avoids a duplicate entry. if default_model: - _norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid - all_ids_norm = {_norm(m['id']) for g in groups for m in g.get('models', [])} + _norm = lambda mid: mid.split("/", 1)[-1] if "/" in mid else mid + all_ids_norm = {_norm(m["id"]) for g in groups for m in g.get("models", [])} if _norm(default_model) not in all_ids_norm: # Determine which group to inject into. Compare against the # provider's display name from _PROVIDER_DISPLAY rather than @@ -780,38 +926,46 @@ def get_available_models() -> dict: # vs display name 'OpenAI Codex' (hyphen vs. space), which # silently falls through to groups[0] and lands the model in # the wrong group. - label = default_model.split('/')[-1] if '/' in default_model else default_model + label = ( + default_model.split("/")[-1] if "/" in default_model else default_model + ) target_display = ( - _PROVIDER_DISPLAY.get(active_provider, active_provider or '').lower() - if active_provider else '' + _PROVIDER_DISPLAY.get(active_provider, active_provider or "").lower() + if active_provider + else "" ) injected = False for g in groups: - if target_display and g.get('provider', '').lower() == target_display: - g['models'].insert(0, {'id': default_model, 'label': label}) + if target_display and g.get("provider", "").lower() == target_display: + g["models"].insert(0, {"id": default_model, "label": label}) injected = True break if not injected and groups: - groups[0]['models'].insert(0, {'id': default_model, 'label': label}) + groups[0]["models"].insert(0, {"id": default_model, "label": label}) elif not groups: - groups.append({'provider': active_provider or 'Default', 'models': [{'id': default_model, 'label': label}]}) + groups.append( + { + "provider": active_provider or "Default", + "models": [{"id": default_model, "label": label}], + } + ) return { - 'active_provider': active_provider, - 'default_model': default_model, - 'groups': groups, + "active_provider": active_provider, + "default_model": default_model, + "groups": groups, } # ── Static file path ───────────────────────────────────────────────────────── -_INDEX_HTML_PATH = REPO_ROOT / 'static' / 'index.html' +_INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html" # ── Thread synchronisation ─────────────────────────────────────────────────── -LOCK = threading.Lock() -SESSIONS_MAX = 100 -CHAT_LOCK = threading.Lock() -STREAMS: dict = {} -STREAMS_LOCK = threading.Lock() +LOCK = threading.Lock() +SESSIONS_MAX = 100 +CHAT_LOCK = threading.Lock() +STREAMS: dict = {} +STREAMS_LOCK = threading.Lock() CANCEL_FLAGS: dict = {} AGENT_INSTANCES: dict = {} # stream_id -> AIAgent instance for interrupt propagation SERVER_START_TIME = time.time() @@ -819,15 +973,19 @@ SERVER_START_TIME = time.time() # ── Thread-local env context ───────────────────────────────────────────────── _thread_ctx = threading.local() + def _set_thread_env(**kwargs): _thread_ctx.env = kwargs + def _clear_thread_env(): _thread_ctx.env = {} + # ── Per-session agent locks ─────────────────────────────────────────────────── SESSION_AGENT_LOCKS: dict = {} -SESSION_AGENT_LOCKS_LOCK = threading.Lock() +SESSION_AGENT_LOCKS_LOCK = threading.Lock() + def _get_session_agent_lock(session_id: str) -> threading.Lock: with SESSION_AGENT_LOCKS_LOCK: @@ -835,63 +993,81 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock: SESSION_AGENT_LOCKS[session_id] = threading.Lock() return SESSION_AGENT_LOCKS[session_id] + # ── Settings persistence ───────────────────────────────────────────────────── _SETTINGS_DEFAULTS = { - 'default_model': DEFAULT_MODEL, - 'default_workspace': str(DEFAULT_WORKSPACE), - 'send_key': 'enter', # 'enter' or 'ctrl+enter' - 'show_token_usage': False, # show input/output token badge below assistant messages - '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', # 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'), # display name for the assistant - 'sound_enabled': False, # play notification sound when assistant finishes - 'notifications_enabled': False, # browser notification when tab is in background - 'password_hash': None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled + "default_model": DEFAULT_MODEL, + "default_workspace": str(DEFAULT_WORKSPACE), + "onboarding_completed": False, + "send_key": "enter", # 'enter' or 'ctrl+enter' + "show_token_usage": False, # show input/output token badge below assistant messages + "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", # 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" + ), # display name for the assistant + "sound_enabled": False, # play notification sound when assistant finishes + "notifications_enabled": False, # browser notification when tab is in background + "password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled } + def load_settings() -> dict: """Load settings from disk, merging with defaults for any missing keys.""" settings = dict(_SETTINGS_DEFAULTS) if SETTINGS_FILE.exists(): try: - stored = json.loads(SETTINGS_FILE.read_text(encoding='utf-8')) + stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) if isinstance(stored, dict): settings.update(stored) except Exception: pass return settings -_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'} + +_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {"password_hash"} _SETTINGS_ENUM_VALUES = { - 'send_key': {'enter', 'ctrl+enter'}, + "send_key": {"enter", "ctrl+enter"}, +} +_SETTINGS_BOOL_KEYS = { + "onboarding_completed", + "show_token_usage", + "show_cli_sessions", + "sync_to_insights", + "check_for_updates", + "sound_enabled", + "notifications_enabled", } -_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates', 'sound_enabled', 'notifications_enabled'} # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') -_SETTINGS_LANG_RE = __import__('re').compile(r'^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$') +_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() # Handle _set_password: hash and store as password_hash - raw_pw = settings.pop('_set_password', None) + raw_pw = settings.pop("_set_password", None) if raw_pw and isinstance(raw_pw, str) and raw_pw.strip(): # Use PBKDF2 from auth module (600k iterations) -- never raw SHA-256 from api.auth import _hash_password - current['password_hash'] = _hash_password(raw_pw.strip()) + + current["password_hash"] = _hash_password(raw_pw.strip()) # Handle _clear_password: explicitly disable auth - if settings.pop('_clear_password', False): - current['password_hash'] = None + if settings.pop("_clear_password", False): + current["password_hash"] = None for k, v in settings.items(): if k in _SETTINGS_ALLOWED_KEYS: # Validate enum-constrained keys if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]: continue # Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN') - if k == 'language' and (not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)): + if k == "language" and ( + not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v) + ): continue # Coerce bool keys if k in _SETTINGS_BOOL_KEYS: @@ -899,23 +1075,26 @@ def save_settings(settings: dict) -> dict: current[k] = v SETTINGS_FILE.write_text( json.dumps(current, ensure_ascii=False, indent=2), - encoding='utf-8', + encoding="utf-8", ) # Update runtime defaults so new sessions use them immediately global DEFAULT_MODEL, DEFAULT_WORKSPACE - if 'default_model' in current: - DEFAULT_MODEL = current['default_model'] - if 'default_workspace' in current: - DEFAULT_WORKSPACE = Path(current['default_workspace']).expanduser().resolve() + if "default_model" in current: + DEFAULT_MODEL = current["default_model"] + if "default_workspace" in current: + DEFAULT_WORKSPACE = Path(current["default_workspace"]).expanduser().resolve() return current + # Apply saved settings on startup (override env-derived defaults) _startup_settings = load_settings() if SETTINGS_FILE.exists(): - if _startup_settings.get('default_model'): - DEFAULT_MODEL = _startup_settings['default_model'] - if _startup_settings.get('default_workspace'): - DEFAULT_WORKSPACE = Path(_startup_settings['default_workspace']).expanduser().resolve() + if _startup_settings.get("default_model"): + DEFAULT_MODEL = _startup_settings["default_model"] + if _startup_settings.get("default_workspace"): + DEFAULT_WORKSPACE = ( + Path(_startup_settings["default_workspace"]).expanduser().resolve() + ) # ── SESSIONS in-memory cache (LRU OrderedDict) ─────────────────────────────── SESSIONS: collections.OrderedDict = collections.OrderedDict() @@ -924,6 +1103,7 @@ SESSIONS: collections.OrderedDict = collections.OrderedDict() # Must run after all imports are resolved to correctly patch module-level caches try: from api.profiles import init_profile_state + init_profile_state() except ImportError: pass # hermes_cli not available -- default profile only diff --git a/api/onboarding.py b/api/onboarding.py new file mode 100644 index 0000000..a826a56 --- /dev/null +++ b/api/onboarding.py @@ -0,0 +1,401 @@ +"""Hermes Web UI -- first-run onboarding helpers.""" + +from __future__ import annotations + +import os +from pathlib import Path +from urllib.parse import urlparse + +from api.auth import is_auth_enabled +from api.config import ( + DEFAULT_MODEL, + DEFAULT_WORKSPACE, + _FALLBACK_MODELS, + _HERMES_FOUND, + _PROVIDER_DISPLAY, + _PROVIDER_MODELS, + _get_config_path, + get_available_models, + get_config, + load_settings, + reload_config, + save_settings, + verify_hermes_imports, +) +from api.workspace import get_last_workspace, load_workspaces + + +_SUPPORTED_PROVIDER_SETUPS = { + "openrouter": { + "label": "OpenRouter", + "env_var": "OPENROUTER_API_KEY", + "default_model": "anthropic/claude-sonnet-4.6", + "requires_base_url": False, + "models": [ + {"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS + ], + }, + "anthropic": { + "label": "Anthropic", + "env_var": "ANTHROPIC_API_KEY", + "default_model": "claude-sonnet-4.6", + "requires_base_url": False, + "models": list(_PROVIDER_MODELS.get("anthropic", [])), + }, + "openai": { + "label": "OpenAI", + "env_var": "OPENAI_API_KEY", + "default_model": "gpt-4o", + "default_base_url": "https://api.openai.com/v1", + "requires_base_url": False, + "models": list(_PROVIDER_MODELS.get("openai", [])), + }, + "custom": { + "label": "Custom OpenAI-compatible", + "env_var": "OPENAI_API_KEY", + "default_model": "gpt-4o-mini", + "requires_base_url": True, + "models": [], + }, +} + +_UNSUPPORTED_PROVIDER_NOTE = ( + "OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub " + "Copilot are still terminal-first. Use `hermes model` for those flows." +) + + +def _get_active_hermes_home() -> Path: + try: + from api.profiles import get_active_hermes_home + + return get_active_hermes_home() + except ImportError: + return Path.home() / ".hermes" + + +def _load_env_file(env_path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not env_path.exists(): + return values + try: + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + except Exception: + return {} + return values + + +def _write_env_file(env_path: Path, updates: dict[str, str]) -> None: + current = _load_env_file(env_path) + for key, value in updates.items(): + if value is None: + current.pop(key, None) + os.environ.pop(key, None) + continue + clean = str(value).strip() + if not clean: + continue + # Reject embedded newlines/carriage returns to prevent .env injection + if "\n" in clean or "\r" in clean: + raise ValueError("API key must not contain newline characters.") + current[key] = clean + os.environ[key] = clean + + env_path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"{key}={current[key]}" for key in sorted(current)] + env_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") + + +def _load_yaml_config(config_path: Path) -> dict: + try: + import yaml as _yaml + except ImportError: + return {} + + if not config_path.exists(): + return {} + try: + loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) + return loaded if isinstance(loaded, dict) else {} + except Exception: + return {} + + +def _save_yaml_config(config_path: Path, config: dict) -> None: + try: + import yaml as _yaml + except ImportError as exc: + raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc + + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + _yaml.safe_dump(config, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + +def _normalize_model_for_provider(provider: str, model: str) -> str: + clean = (model or "").strip() + if not clean: + return "" + if provider in {"anthropic", "openai"} and clean.startswith(provider + "/"): + return clean.split("/", 1)[1] + return clean + + +def _normalize_base_url(base_url: str) -> str: + return (base_url or "").strip().rstrip("/") + + +def _extract_current_provider(cfg: dict) -> str: + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + provider = str(model_cfg.get("provider") or "").strip().lower() + if provider: + return provider + return "" + + +def _extract_current_model(cfg: dict) -> str: + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, str): + return model_cfg.strip() + if isinstance(model_cfg, dict): + return str(model_cfg.get("default") or "").strip() + return "" + + +def _extract_current_base_url(cfg: dict) -> str: + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + return _normalize_base_url(str(model_cfg.get("base_url") or "")) + return "" + + +def _provider_api_key_present( + provider: str, cfg: dict, env_values: dict[str, str] +) -> bool: + provider = (provider or "").strip().lower() + if not provider: + return False + + env_var = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var") + if env_var and env_values.get(env_var): + return True + + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip(): + return True + + providers_cfg = cfg.get("providers", {}) + if isinstance(providers_cfg, dict): + provider_cfg = providers_cfg.get(provider, {}) + if ( + isinstance(provider_cfg, dict) + and str(provider_cfg.get("api_key") or "").strip() + ): + return True + if provider == "custom": + custom_cfg = providers_cfg.get("custom", {}) + if ( + isinstance(custom_cfg, dict) + and str(custom_cfg.get("api_key") or "").strip() + ): + return True + return False + + +def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict: + provider = _extract_current_provider(cfg) + model = _extract_current_model(cfg) + base_url = _extract_current_base_url(cfg) + env_values = _load_env_file(_get_active_hermes_home() / ".env") + + provider_configured = bool(provider and model) + provider_ready = False + + if provider_configured: + if provider == "custom": + provider_ready = bool( + base_url and _provider_api_key_present(provider, cfg, env_values) + ) + elif provider in _SUPPORTED_PROVIDER_SETUPS: + provider_ready = _provider_api_key_present(provider, cfg, env_values) + + chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready) + + if not _HERMES_FOUND or not imports_ok: + state = "agent_unavailable" + note = ( + "Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the " + "agent install before provider setup will work." + ) + elif chat_ready: + state = "ready" + provider_name = _PROVIDER_DISPLAY.get( + provider, provider.title() if provider else "Hermes" + ) + note = f"Hermes is minimally configured and ready to chat via {provider_name}." + elif provider_configured: + state = "provider_incomplete" + missing = ( + "base URL and API key" + if provider == "custom" and not base_url + else "API key" + ) + note = ( + f"Hermes has a saved provider/model selection but still needs the {missing} " + "required to chat." + ) + else: + state = "needs_provider" + note = "Hermes is installed, but you still need to choose a provider and save working credentials." + + return { + "provider_configured": provider_configured, + "provider_ready": provider_ready, + "chat_ready": chat_ready, + "setup_state": state, + "provider_note": note, + "current_provider": provider or None, + "current_model": model or None, + "current_base_url": base_url or None, + "env_path": str(_get_active_hermes_home() / ".env"), + } + + +def _build_setup_catalog(cfg: dict) -> dict: + current_provider = _extract_current_provider(cfg) or "openrouter" + current_model = _extract_current_model(cfg) + current_base_url = _extract_current_base_url(cfg) + + providers = [] + for provider_id, meta in _SUPPORTED_PROVIDER_SETUPS.items(): + providers.append( + { + "id": provider_id, + "label": meta["label"], + "env_var": meta["env_var"], + "default_model": meta["default_model"], + "default_base_url": meta.get("default_base_url") or "", + "requires_base_url": bool(meta.get("requires_base_url")), + "models": list(meta.get("models", [])), + "quick": provider_id == "openrouter", + } + ) + + return { + "providers": providers, + "unsupported_note": _UNSUPPORTED_PROVIDER_NOTE, + "current": { + "provider": current_provider, + "model": current_model + or _SUPPORTED_PROVIDER_SETUPS[current_provider]["default_model"], + "base_url": current_base_url, + }, + } + + +def get_onboarding_status() -> dict: + settings = load_settings() + cfg = get_config() + imports_ok, missing, errors = verify_hermes_imports() + runtime = _status_from_runtime(cfg, imports_ok) + workspaces = load_workspaces() + last_workspace = get_last_workspace() + available_models = get_available_models() + + return { + "completed": bool(settings.get("onboarding_completed")), + "settings": { + "default_model": settings.get("default_model") or DEFAULT_MODEL, + "default_workspace": settings.get("default_workspace") + or str(DEFAULT_WORKSPACE), + "password_enabled": is_auth_enabled(), + "bot_name": settings.get("bot_name") or "Hermes", + }, + "system": { + "hermes_found": bool(_HERMES_FOUND), + "imports_ok": bool(imports_ok), + "missing_modules": missing, + "import_errors": errors, + "config_path": str(_get_config_path()), + "config_exists": Path(_get_config_path()).exists(), + **runtime, + }, + "setup": _build_setup_catalog(cfg), + "workspaces": { + "items": workspaces, + "last": last_workspace, + }, + "models": available_models, + } + + +def apply_onboarding_setup(body: dict) -> dict: + provider = str(body.get("provider") or "").strip().lower() + model = str(body.get("model") or "").strip() + api_key = str(body.get("api_key") or "").strip() + base_url = _normalize_base_url(str(body.get("base_url") or "")) + + if provider not in _SUPPORTED_PROVIDER_SETUPS: + raise ValueError("Unsupported provider for WebUI onboarding.") + if not model: + raise ValueError("model is required") + + provider_meta = _SUPPORTED_PROVIDER_SETUPS[provider] + if provider_meta.get("requires_base_url"): + if not base_url: + raise ValueError("base_url is required for custom endpoints") + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"}: + raise ValueError("base_url must start with http:// or https://") + + cfg = _load_yaml_config(_get_config_path()) + env_path = _get_active_hermes_home() / ".env" + env_values = _load_env_file(env_path) + + if not api_key and not _provider_api_key_present(provider, cfg, env_values): + raise ValueError(f"{provider_meta['env_var']} is required") + + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + model_cfg = {} + + model_cfg["provider"] = provider + model_cfg["default"] = _normalize_model_for_provider(provider, model) + + if provider == "custom": + model_cfg["base_url"] = base_url + elif provider == "openai": + model_cfg["base_url"] = ( + provider_meta.get("default_base_url") or "https://api.openai.com/v1" + ) + else: + model_cfg.pop("base_url", None) + + cfg["model"] = model_cfg + _save_yaml_config(_get_config_path(), cfg) + + if api_key: + _write_env_file(env_path, {provider_meta["env_var"]: api_key}) + + try: + from api.profiles import _reload_dotenv + + _reload_dotenv(_get_active_hermes_home()) + except Exception: + pass + + reload_config() + return get_onboarding_status() + + +def complete_onboarding() -> dict: + save_settings({"onboarding_completed": True}) + return get_onboarding_status() diff --git a/api/routes.py b/api/routes.py index 7ef586d..b4d9bd8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2,6 +2,7 @@ Hermes Web UI -- Route handlers for GET and POST endpoints. Extracted from server.py (Sprint 11) so server.py is a thin shell. """ + import html as _html import json import os @@ -14,63 +15,121 @@ from pathlib import Path from urllib.parse import parse_qs from api.config import ( - STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL, - SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS, - SERVER_START_TIME, CLI_TOOLSETS, _INDEX_HTML_PATH, get_available_models, - IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES, - CHAT_LOCK, load_settings, save_settings, + STATE_DIR, + SESSION_DIR, + DEFAULT_WORKSPACE, + DEFAULT_MODEL, + SESSIONS, + SESSIONS_MAX, + LOCK, + STREAMS, + STREAMS_LOCK, + CANCEL_FLAGS, + SERVER_START_TIME, + CLI_TOOLSETS, + _INDEX_HTML_PATH, + get_available_models, + IMAGE_EXTS, + MD_EXTS, + MIME_MAP, + MAX_FILE_BYTES, + MAX_UPLOAD_BYTES, + CHAT_LOCK, + load_settings, + save_settings, +) +from api.helpers import ( + require, + bad, + safe_resolve, + j, + t, + read_body, + _security_headers, + _sanitize_error, + redact_session_data, + _redact_text, ) -from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_headers, _sanitize_error, redact_session_data, _redact_text # ── CSRF: validate Origin/Referer on POST ──────────────────────────────────── import re as _re + + def _check_csrf(handler) -> bool: """Reject cross-origin POST requests. Returns True if OK.""" - origin = handler.headers.get('Origin', '') - referer = handler.headers.get('Referer', '') - host = handler.headers.get('Host', '') + origin = handler.headers.get("Origin", "") + referer = handler.headers.get("Referer", "") + host = handler.headers.get("Host", "") if not origin and not referer: return True # non-browser clients (curl, agent) have no Origin target = origin or referer # Extract host:port from origin/referer - m = _re.match(r'^https?://([^/]+)', target) + m = _re.match(r"^https?://([^/]+)", target) if not m: return False origin_host = m.group(1) # Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and # X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set # X-Forwarded-Host to the client's original Host header. - allowed_hosts = {h.strip() for h in [ - host, - handler.headers.get('X-Forwarded-Host', ''), - handler.headers.get('X-Real-Host', ''), - ] if h.strip()} + allowed_hosts = { + h.strip() + for h in [ + host, + handler.headers.get("X-Forwarded-Host", ""), + handler.headers.get("X-Real-Host", ""), + ] + if h.strip() + } if origin_host in allowed_hosts: return True return False + + from api.models import ( - Session, get_session, new_session, all_sessions, title_from, - _write_session_index, SESSION_INDEX_FILE, - load_projects, save_projects, import_cli_session, - get_cli_sessions, get_cli_session_messages, + Session, + get_session, + new_session, + all_sessions, + title_from, + _write_session_index, + SESSION_INDEX_FILE, + load_projects, + save_projects, + import_cli_session, + get_cli_sessions, + get_cli_session_messages, ) from api.workspace import ( - load_workspaces, save_workspaces, get_last_workspace, set_last_workspace, - list_dir, read_file_content, safe_resolve_ws, + load_workspaces, + save_workspaces, + get_last_workspace, + set_last_workspace, + list_dir, + read_file_content, + safe_resolve_ws, ) from api.upload import handle_upload from api.streaming import _sse, _run_agent_streaming, cancel_stream +from api.onboarding import ( + apply_onboarding_setup, + get_onboarding_status, + complete_onboarding, +) # Approval system (optional -- graceful fallback if agent not available) try: from tools.approval import ( submit_pending, - approve_session, approve_permanent, save_permanent_allowlist, - is_approved, _pending, _lock, _permanent_approved, + approve_session, + approve_permanent, + save_permanent_allowlist, + is_approved, + _pending, + _lock, + _permanent_approved, resolve_gateway_approval, ) except ImportError: - submit_pending = lambda *a, **k: None approve_session = lambda *a, **k: None approve_permanent = lambda *a, **k: None @@ -86,22 +145,28 @@ except ImportError: # Add entries here to support more languages on the login page. # The key must match the 'language' setting value (from static/i18n.js LOCALES). _LOGIN_LOCALE = { - 'en': { - 'lang': 'en', 'title': 'Sign in', - 'subtitle': 'Enter your password to continue', - 'placeholder': 'Password', 'btn': 'Sign in', - 'invalid_pw': 'Invalid password', 'conn_failed': 'Connection failed', + "en": { + "lang": "en", + "title": "Sign in", + "subtitle": "Enter your password to continue", + "placeholder": "Password", + "btn": "Sign in", + "invalid_pw": "Invalid password", + "conn_failed": "Connection failed", }, - 'zh': { - 'lang': 'zh-CN', 'title': '\u767b\u5f55', - 'subtitle': '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528', - 'placeholder': '\u5bc6\u7801', 'btn': '\u767b\u5f55', - 'invalid_pw': '\u5bc6\u7801\u9519\u8bef', 'conn_failed': '\u8fde\u63a5\u5931\u8d25', + "zh": { + "lang": "zh-CN", + "title": "\u767b\u5f55", + "subtitle": "\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528", + "placeholder": "\u5bc6\u7801", + "btn": "\u767b\u5f55", + "invalid_pw": "\u5bc6\u7801\u9519\u8bef", + "conn_failed": "\u8fde\u63a5\u5931\u8d25", }, } # ── Login page (self-contained, no external deps) ──────────────────────────── -_LOGIN_PAGE_HTML = ''' +_LOGIN_PAGE_HTML = """ {{BOT_NAME}} — {{LOGIN_TITLE}}