From 255914c9f1a65bbe57c22cbab5642362922325f1 Mon Sep 17 00:00:00 2001 From: Rose Date: Wed, 29 Apr 2026 11:50:00 +0200 Subject: [PATCH] Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats --- .gitignore | 21 + api/agents.py | 1035 +------ api/agents_memory.py | 6 +- api/clarify.py | 2 +- api/config.py | 18 +- api/heartbeats.py | 289 ++ api/helpers.py | 19 +- api/mc.py | 827 ++++-- api/models.py | 12 +- api/projects.py | 280 ++ api/routes.py | 109 +- api/streaming.py | 47 +- api/workspace.py | 6 +- package.json | 26 + server.py | 21 + static/activity-tree.js | 380 +++ static/activity-tree.ts | 528 ++++ static/awesome-design-md_README.md | 196 ++ static/boot.js | 1 - static/boot.ts | 876 ++++++ static/commands.js | 745 ++--- static/commands.ts | 358 +++ static/global.d.ts | 389 +++ static/i18n.js | 2927 ++++---------------- static/i18n.ts | 1046 +++++++ static/icons.js | 145 +- static/icons.ts | 79 + static/index.html | 220 +- static/login.js | 100 +- static/login.ts | 60 + static/messages.js | 16 + static/messages.ts | 1212 +++++++++ static/onboarding.ts | 390 +++ static/panels.js | 2500 ++++------------- static/panels.ts | 4087 ++++++++++++++++++++++++++++ static/sessions.js | 191 +- static/sessions.ts | 1018 +++++++ static/style.css | 1032 ++++++- static/ui.js | 127 +- static/ui.ts | 2161 +++++++++++++++ static/workspace.js | 926 ++++--- static/workspace.ts | 396 +++ tsconfig.json | 23 + 43 files changed, 17948 insertions(+), 6899 deletions(-) create mode 100644 api/heartbeats.py create mode 100644 api/projects.py create mode 100644 package.json create mode 100644 static/activity-tree.js create mode 100644 static/activity-tree.ts create mode 100644 static/awesome-design-md_README.md create mode 100644 static/boot.ts create mode 100644 static/commands.ts create mode 100644 static/global.d.ts create mode 100644 static/i18n.ts create mode 100644 static/icons.ts create mode 100644 static/login.ts create mode 100644 static/messages.ts create mode 100644 static/onboarding.ts create mode 100644 static/panels.ts create mode 100644 static/sessions.ts create mode 100644 static/ui.ts create mode 100644 static/workspace.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 20373fa..59bfa6f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,24 @@ Thumbs.db docs/* !docs/ui-ux/ !docs/ui-ux/** + +# Build outputs +dist/ +graphify-out/ + +# Bootstrap logs +bootstrap-*.log + +# Root index (dev entry point is static/index.html) +index.html + +# Package lock +package-lock.json + +# Root-level TypeScript config (but NOT static/*.ts source files) +*.ts +!static/*.ts + +# Scripts and tools +scripts/ +settings.json diff --git a/api/agents.py b/api/agents.py index df5edcf..7112454 100644 --- a/api/agents.py +++ b/api/agents.py @@ -1,22 +1,19 @@ """ Rose Agents Panel API — Data layer for Hermes WebUI Agents extension. -Provides Rose + Tier-2 agent status, inbox management, soul/memory editing, and configuration. +Provides Rose + Tier-2 agent status, inbox management, and configuration. """ import json import os +import re import subprocess import threading import time -from datetime import datetime, timedelta from pathlib import Path from typing import Any from api.helpers import j -# ChromaDB for memory search -import chromadb - # ── Paths ────────────────────────────────────────────────────────────────────── _HERMES_DIR = Path.home() / ".hermes" _AGENTS_DIR = _HERMES_DIR / "agents" @@ -24,13 +21,13 @@ _INBOX_BUS = _HERMES_DIR / "scripts" / "message_bus.py" # ── Tier-2 Agent Registry ────────────────────────────────────────────────────── TIER2_AGENTS = { - "lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"}, - "forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"}, - "sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"}, - "iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"}, - "ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"}, - "dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"}, - "root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"}, + "lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"}, + "forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"}, + "sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"}, + "iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"}, + "ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"}, + "dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"}, + "root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"}, } ROSE_META = { @@ -55,44 +52,8 @@ def _get_process_status(agent_name: str) -> dict: except Exception: return {"running": False, "pid": None} - -def _get_agent_status(agent_name: str) -> dict: - """ - Determine agent status: active / idle / offline. - - active: process running AND recent activity (< 5 min) - - idle: process running BUT no recent activity (5-15 min) - - offline: no process - """ - proc = _get_process_status(agent_name) - active_session_path = _AGENTS_DIR / agent_name / "active_session.txt" - - if not proc["running"]: - return {"status": "offline", "last_activity": None, "pid": None} - - last_activity = None - if active_session_path.exists(): - try: - mtime = active_session_path.stat().st_mtime - last_activity = datetime.fromtimestamp(mtime).isoformat() + "Z" - age_minutes = (time.time() - mtime) / 60 - if age_minutes < 5: - status = "active" - elif age_minutes < 15: - status = "idle" - else: - status = "offline" - except Exception: - status = "unknown" - else: - # Process running but no session file = treat as idle - status = "idle" - last_activity = None - - return {"status": status, "last_activity": last_activity, "pid": proc["pid"]} - - def _get_inbox_count(agent_name: str) -> int: - """Count pending (unread) messages in agent inbox.""" + """Count messages in agent inbox via message_bus.py.""" try: result = subprocess.run( ["/usr/bin/python3", str(_INBOX_BUS), "check", "--agent", agent_name], @@ -105,8 +66,7 @@ def _get_inbox_count(agent_name: str) -> int: pass return 0 - -def _read_inbox(agent_name: str, limit: int = 50) -> list[dict]: +def _read_inbox(agent_name: str, limit: int = 20) -> list[dict]: """Read messages from agent inbox.""" inbox_path = _AGENTS_DIR / agent_name / "inbox.json" if not inbox_path.exists(): @@ -115,12 +75,10 @@ def _read_inbox(agent_name: str, limit: int = 50) -> list[dict]: with open(inbox_path, "r") as f: data = json.load(f) messages = data if isinstance(data, list) else data.get("messages", []) - # Reverse so newest first, return limited - return list(reversed(messages))[:limit] + return messages[-limit:] except (json.JSONDecodeError, IOError): return [] - def _read_file_safe(path: Path) -> str | None: """Read a file safely, return None if missing.""" try: @@ -130,27 +88,14 @@ def _read_file_safe(path: Path) -> str | None: pass return None - -def _write_file_safe(path: Path, content: str, backup: bool = True) -> dict: - """Write a file safely with optional backup. Returns dict with success/error.""" - try: - if backup and path.exists(): - backup_path = path.with_suffix(path.suffix + ".backup") - backup_path.write_text(path.read_text()) - path.write_text(content) - return {"ok": True} - except Exception as e: - return {"ok": False, "error": str(e)} - - # ── API Functions ───────────────────────────────────────────────────────────── def list_agents() -> dict: - """Return status summary for Rose + all Tier-2 agents.""" + """Return status for Rose + all Tier-2 agents.""" agents = [] - # Rose (orchestrator — always "running" as it's the gateway itself) - rose_status = _get_agent_status("rose") + # Rose (the orchestrator) + rose_running = True # Rose IS the gateway/webui rose_inbox_count = _get_inbox_count("rose") agents.append({ "id": "rose", @@ -159,16 +104,15 @@ def list_agents() -> dict: "domain": ROSE_META["domain"], "color": ROSE_META["color"], "tier": "orchestrator", - "status": "active", # Rose is always running + "running": rose_running, "pid": None, - "last_activity": rose_status.get("last_activity"), "inbox_count": rose_inbox_count, }) # Tier-2 agents for agent_id, meta in TIER2_AGENTS.items(): - status_info = _get_agent_status(agent_id) - inbox_count = _get_inbox_count(agent_id) if status_info["status"] != "offline" else 0 + status = _get_process_status(agent_id) + inbox_count = _get_inbox_count(agent_id) if status["running"] else 0 agents.append({ "id": agent_id, "name": meta["name"], @@ -176,181 +120,74 @@ def list_agents() -> dict: "domain": meta["domain"], "color": meta["color"], "tier": "tier2", - "status": status_info["status"], - "pid": status_info["pid"], - "last_activity": status_info.get("last_activity"), + "running": status["running"], + "pid": status["pid"], "inbox_count": inbox_count, }) return {"agents": agents} - -def get_agent(agent_id: str) -> dict: - """Return full detail for one agent: soul.md, memory.md, inbox, config.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - - meta = TIER2_AGENTS.get(agent_id, ROSE_META) - agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR - - soul = _read_file_safe(agent_dir / "soul.md") - memory = _read_file_safe(agent_dir / "memory.md") - inbox_messages = _read_inbox(agent_id) - status_info = _get_agent_status(agent_id) - inbox_count = _get_inbox_count(agent_id) - - # Default model — extract from soul.md YAML frontmatter if present - default_model = None - if soul: - import re - m = re.search(r'model:\s*["\']?([^"\'\n]+)["\']?', soul) - if m: - default_model = m.group(1).strip() - - # Disabled flag - disabled = (agent_dir / "disabled").exists() if agent_dir.exists() else False - - return { - "id": agent_id, - "name": meta["name"], - "emoji": meta["emoji"], - "domain": meta["domain"], - "color": meta["color"], - "tier": "orchestrator" if agent_id == "rose" else "tier2", - "status": status_info["status"], - "last_activity": status_info.get("last_activity"), - "pid": status_info["pid"], - "inbox_count": inbox_count, - "soul": soul or "", - "memory": memory or "", - "default_model": default_model, - "disabled": disabled, - "inbox": inbox_messages, - } - - -def get_agent_status(agent_id: str) -> dict: - """Return only the status info for one agent.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - if agent_id == "rose": - return {"status": "active", "last_activity": None, "pid": None} - return _get_agent_status(agent_id) - - -def get_agent_inbox(agent_id: str, limit: int = 50) -> dict: +def get_agent_inbox(agent_id: str, limit: int = 20) -> dict: """Return inbox messages for a specific agent.""" if agent_id not in TIER2_AGENTS and agent_id != "rose": return {"error": f"Unknown agent: {agent_id}"} messages = _read_inbox(agent_id, limit) - meta = TIER2_AGENTS.get(agent_id, ROSE_META) return { "agent_id": agent_id, - "agent_name": meta["name"], + "agent_name": TIER2_AGENTS.get(agent_id, {}).get("name", "Rose"), "messages": messages, } - -def update_agent_soul(agent_id: str, content: str) -> dict: - """Write soul.md for an agent. Returns {ok, error}.""" +def get_agent_config(agent_id: str) -> dict: + """Return configuration for a specific agent, including YAML frontmatter from soul.md.""" if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"ok": False, "error": f"Unknown agent: {agent_id}"} + return {"error": f"Unknown agent: {agent_id}"} + if agent_id == "rose": - return {"ok": False, "error": "Rose's soul.md cannot be edited via this API"} - - soul_path = _AGENTS_DIR / agent_id / "soul.md" - # Ensure directory exists - (_AGENTS_DIR / agent_id).mkdir(parents=True, exist_ok=True) - return _write_file_safe(soul_path, content, backup=True) - - -def update_agent_memory(agent_id: str, content: str) -> dict: - """Write memory.md for an agent. Returns {ok, error}.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"ok": False, "error": f"Unknown agent: {agent_id}"} - if agent_id == "rose": - return {"ok": False, "error": "Rose's memory.md cannot be edited via this API"} - - memory_path = _AGENTS_DIR / agent_id / "memory.md" - (_AGENTS_DIR / agent_id).mkdir(parents=True, exist_ok=True) - return _write_file_safe(memory_path, content, backup=True) - - -def send_agent_message(agent_id: str, payload: dict) -> dict: - """ - Add a message to an agent's inbox (simulates inter-agent message). - payload: {from, type, subject, content} - """ - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"ok": False, "error": f"Unknown agent: {agent_id}"} - - inbox_path = _AGENTS_DIR / agent_id / "inbox.json" - (_AGENTS_DIR / agent_id).mkdir(parents=True, exist_ok=True) - - # Load existing inbox - if inbox_path.exists(): - try: - with open(inbox_path, "r") as f: - data = json.load(f) - messages = data if isinstance(data, list) else data.get("messages", []) - except (json.JSONDecodeError, IOError): - messages = [] + soul_path = _HERMES_DIR / "rose.md" + soul = _read_file_safe(soul_path) else: - messages = [] - - # Add new message - import uuid - msg = { - "id": uuid.uuid4().hex[:8], - "from": payload.get("from", "rose"), - "type": payload.get("type", "request"), - "subject": payload.get("subject", ""), - "content": payload.get("content", ""), - "timestamp": datetime.utcnow().isoformat() + "Z", - "status": "unread", - } - messages.append(msg) - - try: - with open(inbox_path, "w") as f: - json.dump(messages, f, indent=2) - return {"ok": True, "message_id": msg["id"]} - except Exception as e: - return {"ok": False, "error": str(e)} + soul_path = _AGENTS_DIR / agent_id / "soul.md" + soul = _read_file_safe(soul_path) + # Extract YAML frontmatter from soul.md + default_model = None + provider = None + if soul: + # Match YAML frontmatter block + fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", soul, re.DOTALL) + if fm_match: + fm_text = fm_match.group(1) + # Extract default_model + model_match = re.search(r"^\s*default_model:\s*[\"']?([^\"'\n]+)[\"']?", fm_text, re.MULTILINE) + if model_match: + default_model = model_match.group(1).strip() + # Extract provider + prov_match = re.search(r"^\s*provider:\s*[\"']?([^\"'\n]+)[\"']?", fm_text, re.MULTILINE) + if prov_match: + provider = prov_match.group(1).strip() -def ack_agent_message(agent_id: str, msg_id: str) -> dict: - """Mark a message as acknowledged/read in an agent's inbox.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"ok": False, "error": f"Unknown agent: {agent_id}"} - - inbox_path = _AGENTS_DIR / agent_id / "inbox.json" - if not inbox_path.exists(): - return {"ok": False, "error": "Inbox not found"} - - try: - with open(inbox_path, "r") as f: - messages = json.load(f) - if not isinstance(messages, list): - messages = messages.get("messages", []) - - found = False - for msg in messages: - if msg.get("id") == msg_id: - msg["status"] = "read" - found = True - break - - if not found: - return {"ok": False, "error": f"Message {msg_id} not found"} - - with open(inbox_path, "w") as f: - json.dump(messages, f, indent=2) - return {"ok": True} - except Exception as e: - return {"ok": False, "error": str(e)} - + if agent_id == "rose": + return { + "id": "rose", + "name": "Rose", + "soul_path": str(_HERMES_DIR / "rose.md"), + "memory_path": str(_HERMES_DIR / "memory.json"), + "default_model": default_model, + "provider": provider, + } + else: + soul_path = _AGENTS_DIR / agent_id / "soul.md" + inbox_path = _AGENTS_DIR / agent_id / "inbox.json" + return { + "id": agent_id, + "name": TIER2_AGENTS[agent_id]["name"], + "soul_path": str(soul_path) if soul_path.exists() else None, + "inbox_path": str(inbox_path), + "default_model": default_model, + "provider": provider, + } def set_agent_enabled(agent_id: str, enabled: bool) -> dict: """Enable or disable an agent. Disabled agents won't respond.""" @@ -358,749 +195,15 @@ def set_agent_enabled(agent_id: str, enabled: bool) -> dict: return {"ok": False, "error": f"Unknown agent: {agent_id}"} if agent_id == "rose": return {"ok": False, "error": "Rose cannot be disabled"} - + disabled_flag = _AGENTS_DIR / agent_id / "disabled" try: if enabled: if disabled_flag.exists(): disabled_flag.unlink() + return {"status": "enabled", "agent_id": agent_id} else: disabled_flag.write_text("disabled") - return {"ok": True, "disabled": not enabled} + return {"status": "disabled", "agent_id": agent_id} except Exception as e: return {"ok": False, "error": str(e)} - - -def get_agent_config(agent_id: str) -> dict: - """Return configuration paths/info for a specific agent.""" - if agent_id == "rose": - return { - "id": "rose", - "name": "Rose", - "soul_path": str(_HERMES_DIR / "rose.md"), - "memory_path": str(_HERMES_DIR / "memory.json"), - } - elif agent_id in TIER2_AGENTS: - soul_path = _AGENTS_DIR / agent_id / "soul.md" - memory_path = _AGENTS_DIR / agent_id / "memory.md" - inbox_path = _AGENTS_DIR / agent_id / "inbox.json" - return { - "id": agent_id, - "name": TIER2_AGENTS[agent_id]["name"], - "soul_path": str(soul_path) if soul_path.exists() else None, - "memory_path": str(memory_path) if memory_path.exists() else None, - "inbox_path": str(inbox_path), - } - return {"error": f"Unknown agent: {agent_id}"} - - -# ── Activity & Error Log ─────────────────────────────────────────────────────── - -ACTIVITY_EVENT_TYPES = [ - "agent_started", "agent_stopped", - "message_sent", "message_received", - "task_started", "task_completed", "task_failed", - "error", "soul_updated", "memory_updated", - "chat_started", "chat_ended", - "health_check", "config_updated", -] - - -def _log_agent_activity(agent_id: str, event_type: str, details: str = "") -> dict: - """ - Write an activity event to the agent's activity.log file. - Log format: ISO timestamp | type | details - Returns {ok: True} or {ok: False, error: ...} - """ - if event_type not in ACTIVITY_EVENT_TYPES: - return {"ok": False, "error": f"Unknown event type: {event_type}"} - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"ok": False, "error": f"Unknown agent: {agent_id}"} - - agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR - log_path = agent_dir / "activity.log" - agent_dir.mkdir(parents=True, exist_ok=True) - - try: - timestamp = datetime.utcnow().isoformat() + "Z" - line = f"{timestamp} | {event_type} | {details}\n" - with open(log_path, "a") as f: - f.write(line) - return {"ok": True} - except Exception as e: - return {"ok": False, "error": str(e)} - - -def _get_activity_log(agent_id: str, limit: int = 50) -> list[dict]: - """ - Read recent activity events for an agent. - Returns list of {timestamp, type, details} sorted newest-first. - """ - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return [] - agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR - log_path = agent_dir / "activity.log" - if not log_path.exists(): - return [] - - try: - lines = log_path.read_text().strip().split("\n") - events = [] - for line in reversed(lines): - line = line.strip() - if not line: - continue - parts = line.split(" | ", 2) - if len(parts) >= 2: - events.append({ - "timestamp": parts[0], - "type": parts[1], - "details": parts[2] if len(parts) > 2 else "", - }) - if len(events) >= limit: - break - return events - except Exception: - return [] - - -def _get_error_log(agent_id: str, limit: int = 20) -> list[dict]: - """ - Read error events from activity log (type == 'error'). - Returns list of {timestamp, type, details} sorted newest-first. - """ - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return [] - agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR - log_path = agent_dir / "activity.log" - if not log_path.exists(): - return [] - - try: - lines = log_path.read_text().strip().split("\n") - errors = [] - for line in reversed(lines): - line = line.strip() - if not line or " | error | " not in line: - continue - parts = line.split(" | ", 2) - if len(parts) >= 2: - errors.append({ - "timestamp": parts[0], - "type": parts[1], - "details": parts[2] if len(parts) > 2 else "", - }) - if len(errors) >= limit: - break - return errors - except Exception: - return [] - - -def get_agent_activity(agent_id: str, limit: int = 50) -> dict: - """API: GET /api/agents/{id}/activity — return activity log.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - events = _get_activity_log(agent_id, limit) - return { - "agent_id": agent_id, - "events": events, - } - - -def get_agent_errors(agent_id: str, limit: int = 20) -> dict: - """API: GET /api/agents/{id}/errors — return error log.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - errors = _get_error_log(agent_id, limit) - return { - "agent_id": agent_id, - "errors": errors, - } - - -# ── Token / Cost Usage Tracking ─────────────────────────────────────────────── - -def _get_agent_usage(agent_id: str) -> dict: - """ - Read ~/.hermes/agents/{agent_id}/usage.json and compute daily/weekly/monthly totals. - Returns {agent_id, today: {tokens, cost}, week: {tokens, cost}, month: {tokens, cost}, history: [...]}. - If the usage file doesn't exist, returns zeros for all periods. - """ - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - - usage_path = _AGENTS_DIR / agent_id / "usage.json" - if not usage_path.exists(): - return { - "agent_id": agent_id, - "today": {"tokens": 0, "cost": 0.0}, - "week": {"tokens": 0, "cost": 0.0}, - "month": {"tokens": 0, "cost": 0.0}, - "history": [], - } - - try: - with open(usage_path, "r") as f: - data = json.load(f) - except (json.JSONDecodeError, IOError): - return { - "agent_id": agent_id, - "today": {"tokens": 0, "cost": 0.0}, - "week": {"tokens": 0, "cost": 0.0}, - "month": {"tokens": 0, "cost": 0.0}, - "history": [], - } - - token_usage = data.get("token_usage", []) - today_str = datetime.utcnow().strftime("%Y-%m-%d") - - # Compute period boundaries - now = datetime.utcnow() - week_ago = now - timedelta(days=7) - month_ago = now - timedelta(days=30) - - today_tokens = 0 - today_cost = 0.0 - week_tokens = 0 - week_cost = 0.0 - month_tokens = 0 - month_cost = 0.0 - - history = [] - for entry in token_usage: - date_str = entry.get("date", "") - prompt = entry.get("prompt_tokens", 0) - completion = entry.get("completion_tokens", 0) - cost = entry.get("cost_usd", 0.0) - total = prompt + completion - - try: - entry_date = datetime.strptime(date_str, "%Y-%m-%d") - except ValueError: - continue - - history.append({ - "date": date_str, - "prompt_tokens": prompt, - "completion_tokens": completion, - "total_tokens": total, - "cost_usd": cost, - }) - - if date_str == today_str: - today_tokens += total - today_cost += cost - if entry_date >= week_ago: - week_tokens += total - week_cost += cost - if entry_date >= month_ago: - month_tokens += total - month_cost += cost - - # Sort history newest-first - history.sort(key=lambda x: x["date"], reverse=True) - - return { - "agent_id": agent_id, - "today": {"tokens": today_tokens, "cost": round(today_cost, 6)}, - "week": {"tokens": week_tokens, "cost": round(week_cost, 6)}, - "month": {"tokens": month_tokens, "cost": round(month_cost, 6)}, - "history": history, - } - - -def get_agent_usage(agent_id: str) -> dict: - """API: GET /api/agents/{id}/usage — return token/cost usage.""" - return _get_agent_usage(agent_id) - - -# ── Chat History ────────────────────────────────────────────────────────────── - -def _get_chat_history(agent_id: str, limit: int = 20) -> list[dict]: - """ - Read chat sessions from JSONL files and return history for a specific agent. - Sessions are sorted newest-first. - Returns list of {session_id, title, message_count, created_at, last_message_at, model}. - """ - sessions_dir = _HERMES_DIR / "sessions" - if not sessions_dir.exists(): - return [] - - sessions = sorted(sessions_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) - history = [] - - for session_file in sessions[:limit * 2]: # overscan - if len(history) >= limit: - break - try: - with open(session_file) as f: - lines = f.readlines() - - if not lines: - continue - - # First line has metadata - metadata = json.loads(lines[0]) - created_at = metadata.get("timestamp", "") - model = metadata.get("model", "unknown") - - # Count messages - message_count = sum(1 for l in lines if l.strip()) - - # Title = first user message preview - title = "Chat" - for line in lines[1:]: - if line.strip(): - try: - msg = json.loads(line) - if msg.get("role") == "user": - content = str(msg.get("content", ""))[:80] - title = content if content else "Chat" - break - except Exception: - pass - - # Last message timestamp - last_msg = None - for line in reversed(lines): - if line.strip(): - try: - last_msg = json.loads(line).get("timestamp", created_at) - break - except Exception: - pass - - session_id = session_file.stem # filename without .jsonl - - history.append({ - "session_id": session_id, - "title": title, - "message_count": message_count, - "created_at": created_at, - "last_message_at": last_msg or created_at, - "model": model, - }) - except Exception: - continue - - return history[:limit] - - -def get_agent_chat_history(agent_id: str, limit: int = 20) -> dict: - """API: GET /api/agents/{id}/chat-history — return chat history for agent.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - history = _get_chat_history(agent_id, limit) - return { - "agent_id": agent_id, - "sessions": history, - } - - -# ── Health Check ─────────────────────────────────────────────────────────────── - -def _get_agent_health(agent_id: str) -> dict: - """ - Return health metrics for an agent. - - status: active/idle/offline based on process presence - - uptime_seconds: from process start time - - cpu_percent: 60s avg (sampled via ps) - - memory_mb: RSS from ps - - threads: thread count - - pid: process ID if running - """ - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - - status = "offline" - pid = None - uptime_seconds = 0 - cpu_percent = 0.0 - memory_mb = 0.0 - threads = 0 - - import subprocess, time - - # Try to find Hermes process for rose or Tier-2 agents - # Rose runs as 'hermes' process, Tier-2 agents may run as 'python server.py' or similar - try: - # Find hermes process - ps_result = subprocess.run( - ["ps", "aux"], - capture_output=True, text=True, timeout=5 - ) - for line in ps_result.stdout.split("\n"): - if "hermes" in line.lower() and "grep" not in line: - parts = line.split() - if len(parts) >= 11: - pid = int(parts[1]) - cpu = float(parts[2]) - rss_kb = int(parts[5]) - # STAT column index varies, try to get threads - try: - # RSS is in KB, convert to MB - memory_mb = rss_kb / 1024 - except Exception: - pass - cpu_percent = cpu - status = "active" - threads = 1 # ps doesn't show threads in aux mode - break - except Exception: - pass - - # Try to get PID from agent's active_session.txt - if agent_id == "rose": - rose_dir = _HERMES_DIR - else: - rose_dir = _AGENTS_DIR / agent_id - - pid_file = rose_dir / "active_session.txt" - if pid_file.exists(): - try: - pid = int(pid_file.read_text().strip().split()[0]) - except Exception: - pass - - return { - "agent_id": agent_id, - "status": status, - "pid": pid, - "uptime_seconds": uptime_seconds, - "cpu_percent": round(cpu_percent, 1), - "memory_mb": round(memory_mb, 1), - "threads": threads, - } - - -def get_agent_health(agent_id: str) -> dict: - """API: GET /api/agents/{id}/health — return health metrics.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - return _get_agent_health(agent_id) - - -# ── Task Queue ───────────────────────────────────────────────────────────────── - -def _get_task_queue(agent_id: str) -> list[dict]: - """ - Read task queue from ~/.hermes/agents/{id}/tasks.json if it exists. - Returns list of tasks with {id, description, status, created_at}. - """ - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return [] - tasks_file = _AGENTS_DIR / agent_id / "tasks.json" - if not tasks_file.exists(): - return [] - try: - import json as _json - data = _json.loads(tasks_file.read_text()) - return data if isinstance(data, list) else [] - except Exception: - return [] - - -def get_agent_tasks(agent_id: str) -> dict: - """API: GET /api/agents/{id}/tasks — return task queue.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - tasks = _get_task_queue(agent_id) - return { - "agent_id": agent_id, - "tasks": tasks, - "count": len(tasks), - } - - -# ── Message Bus ──────────────────────────────────────────────────────────────── - -def _get_message_bus_overview() -> dict: - """ - Return a overview of all agent inboxes — message counts and recent messages. - """ - agents = [] - for agent_id in TIER2_AGENTS: - inbox_path = _AGENTS_DIR / agent_id / "inbox.json" - count = 0 - last_msg = None - if inbox_path.exists(): - try: - inbox = json.loads(inbox_path.read_text()) - messages = inbox.get("messages", []) - count = len(messages) - if messages: - last_msg = { - "from": messages[-1].get("from"), - "subject": messages[-1].get("subject"), - "timestamp": messages[-1].get("timestamp"), - } - except Exception: - pass - agents.append({ - "agent_id": agent_id, - "name": TIER2_AGENTS[agent_id].get("name", agent_id), - "emoji": TIER2_AGENTS[agent_id].get("emoji", "•"), - "inbox_count": count, - "last_message": last_msg, - }) - - # Rose inbox - rose_inbox_path = _HERMES_DIR / "inbox.json" - rose_count = 0 - rose_last = None - if rose_inbox_path.exists(): - try: - inbox = json.loads(rose_inbox_path.read_text()) - messages = inbox.get("messages", []) - rose_count = len(messages) - if messages: - rose_last = { - "from": messages[-1].get("from"), - "subject": messages[-1].get("subject"), - "timestamp": messages[-1].get("timestamp"), - } - except Exception: - pass - agents.insert(0, { - "agent_id": "rose", - "name": "Rose", - "emoji": "🌹", - "inbox_count": rose_count, - "last_message": rose_last, - }) - - return {"agents": agents} - - -def get_message_bus_overview() -> dict: - """API: GET /api/agents/message-bus — overview of all agent inboxes.""" - return _get_message_bus_overview() - - -def send_bus_message(target_agent: str, from_agent: str, subject: str, content: str) -> dict: - """ - Write a message directly into an agent's inbox.json via the message bus. - """ - if target_agent not in TIER2_AGENTS and target_agent != "rose": - return {"ok": False, "error": f"Unknown agent: {target_agent}"} - - if target_agent == "rose": - inbox_path = _HERMES_DIR / "inbox.json" - else: - inbox_path = _AGENTS_DIR / target_agent / "inbox.json" - - inbox_path.parent.mkdir(parents=True, exist_ok=True) - - try: - if inbox_path.exists(): - inbox = json.loads(inbox_path.read_text()) - else: - inbox = {"messages": []} - except Exception: - inbox = {"messages": []} - - import datetime - msg_id = f"bus_{int(datetime.datetime.now().timestamp() * 1000)}" - message = { - "id": msg_id, - "from": from_agent, - "subject": subject, - "content": content, - "type": "request", - "status": "unread", - "timestamp": datetime.datetime.now().isoformat(), - } - - inbox["messages"].append(message) - inbox_path.write_text(json.dumps(inbox, indent=2)) - - return {"ok": True, "message_id": msg_id} - - -def get_agent_bus_messages(agent_id: str, limit: int = 50) -> dict: - """API: GET /api/agents/{id}/bus-messages — raw inbox messages.""" - if agent_id not in TIER2_AGENTS and agent_id != "rose": - return {"error": f"Unknown agent: {agent_id}"} - if agent_id == "rose": - inbox_path = _HERMES_DIR / "inbox.json" - else: - inbox_path = _AGENTS_DIR / agent_id / "inbox.json" - if not inbox_path.exists(): - return {"agent_id": agent_id, "messages": []} - try: - inbox = json.loads(inbox_path.read_text()) - messages = inbox.get("messages", [])[-limit:] - return {"agent_id": agent_id, "messages": messages} - except Exception: - return {"agent_id": agent_id, "messages": []} - - -# ── Message Bus Viewer ──────────────────────────────────────────────────────── - -def get_message_bus_status() -> dict: - """ - Return all inboxes across all agents — a complete view of the message bus. - Returns dict with each agent and their messages. - """ - all_agents = list(TIER2_AGENTS.keys()) + ["rose"] - bus = {} - for agent_id in all_agents: - try: - messages = _read_inbox(agent_id, limit=100) - bus[agent_id] = { - "count": len(messages), - "messages": messages[-10:] if messages else [], # last 10 - } - except Exception: - bus[agent_id] = {"count": 0, "messages": [], "error": True} - return {"bus": bus} - - -def send_bus_message(to_agent: str, from_agent: str, subject: str, content: str, msg_type: str = "request") -> dict: - """ - Write a message directly to an agent's inbox.json. - """ - if to_agent not in TIER2_AGENTS and to_agent != "rose": - return {"ok": False, "error": f"Unknown agent: {to_agent}"} - - if to_agent == "rose": - inbox_path = _HERMES_DIR / "inbox.json" - else: - inbox_path = _AGENTS_DIR / to_agent / "inbox.json" - - try: - data = [] - if inbox_path.exists(): - data = json.loads(inbox_path.read_text()) - if not isinstance(data, list): - data = [] - - import uuid, datetime - msg = { - "id": str(uuid.uuid4())[:8], - "from": from_agent, - "to": to_agent, - "type": msg_type, - "subject": subject, - "content": content, - "timestamp": datetime.datetime.now().isoformat() + "Z", - "status": "unread", - } - data.append(msg) - inbox_path.write_text(json.dumps(data, indent=2)) - return {"ok": True, "message_id": msg["id"]} - except Exception as e: - return {"ok": False, "error": str(e)} - - -# ── Memory Search (ChromaDB) ─────────────────────────────────────────────────── - -def _get_chroma_client(): - """Get or create the shared ChromaDB HTTP client (thread-safe singleton).""" - if not hasattr(_get_chroma_client, "_client"): - _get_chroma_client._client = chromadb.HttpClient(host="127.0.0.1", port=8000) - return _get_chroma_client._client - - -def _search_agent_memory(agent_id: str, query: str, limit: int = 10) -> list: - """ - Search memory for a specific agent. - Searches the rose_memory collection filtered by topic matching agent_id. - """ - try: - client = _get_chroma_client() - coll = client.get_collection(name="rose_memory") - results = coll.query( - query_texts=[query], - n_results=limit, - include=["metadatas", "documents"], - ) - matches = [] - for i, doc in enumerate(results.get("documents", [[]])[0] or []): - meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {} - topic = meta.get("topic", "") - if not topic.startswith(agent_id): - continue - matches.append({ - "id": (results.get("ids", [["?"]])[0] or ["?"])[i], - "topic": topic, - "content": doc, - "confidence": float(meta.get("confidence", 0.0)), - "tags": meta.get("tags", ""), - "vault_path": meta.get("vault_path", ""), - }) - return matches - except Exception: - return [] - - -def _search_all_agents_memory(query: str, limit: int = 20) -> list: - """ - Search across all agent memories in ChromaDB. - Returns matches with agent attribution from topic. - Topic format: "agent-name/fact-name" or flat topic name. - """ - try: - client = _get_chroma_client() - coll = client.get_collection(name="rose_memory") - results = coll.query( - query_texts=[query], - n_results=limit, - include=["metadatas", "documents"], - ) - matches = [] - for i, doc in enumerate(results.get("documents", [[]])[0] or []): - meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {} - topic = meta.get("topic", "") - parts = topic.split("/") - agent = parts[0] if len(parts) > 1 else topic - matches.append({ - "id": (results.get("ids", [["?"]])[0] or ["?"])[i], - "topic": topic, - "agent": agent, - "content": doc, - "confidence": float(meta.get("confidence", 0.0)), - "tags": meta.get("tags", ""), - "vault_path": meta.get("vault_path", ""), - }) - return matches - except Exception: - return [] - - -# ── Topology Graph ─────────────────────────────────────────────────────────── - -def _get_topology() -> dict: - """ - Build a network graph of all agents and their connections. - Returns {nodes: [...], edges: [...]} for D3.js visualization. - """ - # Nodes: Rose + all Tier-2 agents - nodes = [ - {"id": "rose", "name": "Rose 🌹", "type": "orchestrator", - "color": "#f44336", "domain": "Orchestrator"}, - ] - for agent_id, meta in TIER2_AGENTS.items(): - nodes.append({ - "id": agent_id, - "name": f"{meta['emoji']} {meta['name']}", - "type": "tier2", - "color": meta["color"], - "domain": meta["domain"], - }) - - # Edges: Rose connects to all Tier-2 agents - edges = [] - for agent_id in TIER2_AGENTS: - edges.append({ - "source": "rose", "target": agent_id, - "type": "orchestrates", "strength": 1, - }) - - return {"nodes": nodes, "edges": edges} - - -def get_topology() -> dict: - """API: GET /api/agents/topology — return agent network graph.""" - return _get_topology() diff --git a/api/agents_memory.py b/api/agents_memory.py index 9030f42..4fdfa55 100644 --- a/api/agents_memory.py +++ b/api/agents_memory.py @@ -19,8 +19,8 @@ def _get_agent_soul(agent_id: str) -> str | None: Returns None if not found. """ - if not agent_id or agent_id == "rose": - return None # Rose uses the global HERMES_HOME/SOUL.md + if not agent_id: + return None for fname in ("soul.md", "SOUL.md"): path = HERMES_HOME / "agents" / agent_id / fname @@ -41,7 +41,7 @@ def _get_agent_memory_context(agent_id: str, query: str, limit: int = 5) -> str Searches rose_memory collection filtered by topic matching "{agent_id}/". Returns formatted text block or None if nothing found. """ - if not agent_id or agent_id == "rose": + if not agent_id: return None matches = _search_agent_memory(agent_id, query, limit=limit) diff --git a/api/clarify.py b/api/clarify.py index 4fbbfc3..d504fff 100644 --- a/api/clarify.py +++ b/api/clarify.py @@ -10,7 +10,7 @@ import threading from typing import Optional -_lock = threading.Lock() +_lock = threading.RLock() # Reentrant for consistency; no nested calls currently but safer _pending: dict[str, dict] = {} _gateway_queues: dict[str, list] = {} _gateway_notify_cbs: dict[str, object] = {} diff --git a/api/config.py b/api/config.py index 6703983..35a34a1 100644 --- a/api/config.py +++ b/api/config.py @@ -1033,6 +1033,18 @@ def get_available_models() -> dict: 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) + # Fallback: read models from config.yaml providers..models + if not raw_models: + try: + _prov_cfg = cfg.get("providers", {}).get(pid, {}) + if isinstance(_prov_cfg, dict): + _cfg_models = _prov_cfg.get("models", []) + if isinstance(_cfg_models, list): + raw_models = [{"id": m, "label": m.split("/")[-1] if "/" in m else m} for m in _cfg_models if isinstance(m, str)] + if raw_models: + logger.debug("Loaded %d models from config for %s", len(raw_models), pid) + except Exception as _e: + logger.debug("Could not read config models for %s: %s", pid, _e) _active = (active_provider or "").lower() if _active and pid != _active: models = [] @@ -1136,7 +1148,7 @@ def get_available_models() -> dict: _INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html" # ── Thread synchronisation ─────────────────────────────────────────────────── -LOCK = threading.Lock() +LOCK = threading.RLock() # Reentrant — allows nested acquisition in save() → _write_session_index() SESSIONS_MAX = 100 CHAT_LOCK = threading.Lock() STREAMS: dict = {} @@ -1188,6 +1200,8 @@ _SETTINGS_DEFAULTS = { "sound_enabled": False, # play notification sound when assistant finishes "notifications_enabled": False, # browser notification when tab is in background "bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles + "user_emoji": "🙂", # emoji shown for user messages in chat + "user_name": "You", # name shown for user messages in chat "password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled } _SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"} @@ -1198,7 +1212,7 @@ def load_settings() -> dict: settings = dict(_SETTINGS_DEFAULTS) if SETTINGS_FILE.exists(): try: - stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + with SETTINGS_FILE.open(encoding="utf-8") as _f: stored = json.loads(_f.read()) if isinstance(stored, dict): settings.update( { diff --git a/api/heartbeats.py b/api/heartbeats.py new file mode 100644 index 0000000..dc2e933 --- /dev/null +++ b/api/heartbeats.py @@ -0,0 +1,289 @@ +""" +Heartbeat System API for WebUI. +Provides endpoints to manage heartbeats and monitor the manager/watchdog. +""" +import json +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +HEARTBEAT_DIR = Path.home() / ".hermes" / "heartbeat" +REGISTRY_FILE = HEARTBEAT_DIR / "registry.json" +MANAGER_SCRIPT = Path.home() / ".hermes" / "scripts" / "heartbeat_manager.py" +WATCHDOG_LOG = Path.home() / ".hermes" / "logs" / "heartbeat_watchdog.log" +MANAGER_LOG = Path.home() / ".hermes" / "logs" / "heartbeat_manager.log" +HB_API = Path.home() / ".hermes" / "scripts" / "heartbeat_api.py" + +def _run_api(args: list) -> dict: + """Run heartbeat_api.py with given args, return parsed JSON.""" + try: + result = subprocess.run( + [sys.executable, str(HB_API)] + args, + capture_output=True, text=True, timeout=30, + cwd=str(Path.home() / ".hermes") + ) + if result.returncode == 0: + stdout = result.stdout.strip() + # Try to parse JSON from stdout + for line in stdout.splitlines(): + line = line.strip() + if line.startswith("{"): + return json.loads(line) + # Plain text output = success + return {"ok": True, "output": stdout} + # Error case + stderr = result.stderr.strip() + if stderr: + return {"error": stderr} + return {"error": f"Exit code {result.returncode}"} + except subprocess.TimeoutExpired: + return {"error": "Command timed out"} + except Exception as e: + return {"error": str(e)} + + +def _load_registry() -> dict: + try: + with REGISTRY_FILE.open(encoding="utf-8") as f: + return json.loads(f.read()) + except Exception: + return {"heartbeats": []} + + +def _manager_pid() -> str | None: + result = subprocess.run( + ["pgrep", "-f", "heartbeat_manager.py"], + capture_output=True, text=True + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().split()[0] + return None + + +def _manager_log_tail(lines: int = 20) -> str: + try: + if MANAGER_LOG.exists(): + all_lines = MANAGER_LOG.read_text().splitlines() + return "\n".join(all_lines[-lines:]) + except Exception: + pass + return "" + + +def _watchdog_log_tail(lines: int = 10) -> str: + try: + if WATCHDOG_LOG.exists(): + all_lines = WATCHDOG_LOG.read_text().splitlines() + return "\n".join(all_lines[-lines:]) + except Exception: + pass + return "" + + +# ── Public API ────────────────────────────────────────────────────────────── + +def handle_get(path: str) -> dict: + """Handle GET /api/heartbeats/* routes.""" + if path == "/api/heartbeats": + # List all heartbeats with status summary + manager info + registry = _load_registry() + heartbeats = registry.get("heartbeats", []) + by_status = {} + by_priority = {} + by_source = {} + pending_due = 0 + now = datetime.now().isoformat() + + for hb in heartbeats: + s = hb.get("status", "unknown") + by_status[s] = by_status.get(s, 0) + 1 + p = hb.get("priority", "normal") + by_priority[p] = by_priority.get(p, 0) + 1 + src = hb.get("source", "unknown") + by_source[src] = by_source.get(src, 0) + 1 + if s == "pending" and hb.get("trigger_at", "") <= now: + pending_due += 1 + + # Manager info + pid = _manager_pid() + + return { + "heartbeats": heartbeats, + "total": len(heartbeats), + "pending_due_count": pending_due, + "by_status": by_status, + "by_priority": by_priority, + "by_source": by_source, + "_manager": { + "running": pid is not None, + "pid": pid, + } + } + + if path == "/api/heartbeats/manager": + pid = _manager_pid() + return { + "running": pid is not None, + "pid": pid, + "log_tail": _manager_log_tail(15), + } + + if path == "/api/heartbeats/watchdog": + return { + "log_tail": _watchdog_log_tail(10), + } + + if path == "/api/heartbeats/stats": + # Compute firing stats from log files + import glob, re + log_dir = HEARTBEAT_DIR / "logs" + fired_24h = 0 + fired_total = 0 + now = datetime.now() + day_ago = datetime.fromtimestamp(now.timestamp() - 86400) + + for log_file in glob.glob(str(log_dir / "heartbeat_*.log")): + try: + for line in Path(log_file).read_text().splitlines(): + if "processed heartbeat" in line or "fired" in line.lower(): + # Parse timestamp from log line: [2026-04-28 08:17:54] + m = re.match(r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]", line) + if m: + fired_total += 1 + try: + dt = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") + if dt >= day_ago: + fired_24h += 1 + except: pass + except: pass + + # Next scheduled heartbeat + registry = _load_registry() + next_hb = None + for hb in registry.get("heartbeats", []): + if hb.get("status") == "pending": + ta = hb.get("trigger_at", "") + if ta and (next_hb is None or ta < next_hb): + next_hb = ta + + # Load heartbeat.json config + config_file = Path.home() / ".hermes" / "config" / "heartbeat.json" + config = {} + if config_file.exists(): + try: + with config_file.open(encoding="utf-8") as f: + config = json.loads(f.read()) + except: pass + + return { + "fired_total": fired_total, + "fired_24h": fired_24h, + "next_scheduled": next_hb, + "config": config, + } + + # GET /api/heartbeats/{id} + if path.startswith("/api/heartbeats/"): + hb_id = path.split("/")[-1] + if hb_id in ("manager", "watchdog"): + return {"error": "Not found"}, 404 + registry = _load_registry() + for hb in registry.get("heartbeats", []): + if hb.get("id") == hb_id: + return hb + return {"error": f"Heartbeat {hb_id} not found"}, 404 + + return None # Not handled + + +def handle_post(path: str, body: dict) -> dict: + """Handle POST /api/heartbeats/* routes.""" + if path == "/api/heartbeats": + # Create heartbeat + source = body.get("source", "webui") + action = body.get("action", "rose_continue") + instruction = body.get("instruction", "") + minutes = int(body.get("minutes", 5)) + priority = body.get("priority") + mode = body.get("mode", "silent") + recurring = bool(body.get("recurring", False)) + interval_minutes = int(body.get("interval_minutes", minutes)) if recurring else None + max_iterations = int(body["max_iterations"]) if body.get("max_iterations") else None + + args = [ + "create", + "--source", source, + "--action", action, + "--instruction", instruction, + "--minutes", str(minutes), + "--mode", mode, + ] + if priority: + args += ["--priority", priority] + if recurring: + args.append("--recurring") + if interval_minutes: + args += ["--interval-minutes", str(interval_minutes)] + if max_iterations: + args += ["--max-iterations", str(max_iterations)] + + result = _run_api(args) + return result + + if path == "/api/heartbeats/manager/restart": + pid = _manager_pid() + if pid: + subprocess.run(["kill", pid], capture_output=True) + subprocess.Popen( + [sys.executable, str(MANAGER_SCRIPT), "--daemon"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, cwd=str(Path.home() / ".hermes") + ) + return {"ok": True, "message": "Manager restart initiated"} + + if path.startswith("/api/heartbeats/") and path.endswith("/cancel"): + hb_id = path.split("/")[-2] + result = _run_api(["cancel", "--id", hb_id]) + return result + + if path.startswith("/api/heartbeats/") and path.endswith("/fire"): + # Manual fire (for testing) + hb_id = path.split("/")[-2] + # Simulate fire by updating trigger_at to now + registry = _load_registry() + for hb in registry.get("heartbeats", []): + if hb.get("id") == hb_id: + hb["trigger_at"] = datetime.now().isoformat() + REGISTRY_FILE.write_text(json.dumps(registry, indent=2)) + return {"ok": True, "message": f"Heartbeat {hb_id} fire time set to now"} + return {"error": f"Heartbeat {hb_id} not found"}, 404 + + if path == "/api/heartbeats/config": + # Update heartbeat config (quiet hours, intervals, telegram) + config_file = Path.home() / ".hermes" / "config" / "heartbeat.json" + config = {} + if config_file.exists(): + try: + with config_file.open(encoding="utf-8") as f: + config = json.loads(f.read()) + except: pass + for key in ("quiet_hours", "daemon_interval_seconds", "intervals", "telegram", "critical_override"): + if key in body: + config[key] = body[key] + config_file.write_text(json.dumps(config, indent=2, ensure_ascii=False)) + return {"ok": True, "config": config} + + return None # Not handled + + +def handle_delete(path: str) -> dict: + """Handle DELETE /api/heartbeats/{id}.""" + if path.startswith("/api/heartbeats/"): + hb_id = path.split("/")[-1] + if hb_id in ("manager", "watchdog"): + return {"error": "Cannot delete system endpoint"}, 400 + result = _run_api(["cancel", "--id", hb_id]) + return result + return None diff --git a/api/helpers.py b/api/helpers.py index 95c7a0e..733ba90 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -35,17 +35,26 @@ def safe_resolve(root: Path, requested: str) -> Path: return resolved -def _security_headers(handler): +def _security_headers(handler, origin=None): """Add security headers to every response.""" handler.send_header('X-Content-Type-Options', 'nosniff') handler.send_header('X-Frame-Options', 'DENY') handler.send_header('Referrer-Policy', 'same-origin') + handler.send_header('Access-Control-Allow-Origin', origin or '*') + handler.send_header('Access-Control-Allow-Credentials', 'true' if origin else 'false') + handler.send_header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS') + handler.send_header('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Requested-With') + handler.send_header('Vary', 'Origin') + connect_src = "'self'" + if origin: + connect_src += f" {origin}" handler.send_header( 'Content-Security-Policy', "default-src 'self'; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; " + "img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net; " + f"connect-src {connect_src}; " "base-uri 'self'; form-action 'self'" ) handler.send_header( @@ -61,7 +70,8 @@ def j(handler, payload, status: int=200) -> None: handler.send_header('Content-Type', 'application/json; charset=utf-8') handler.send_header('Content-Length', str(len(body))) handler.send_header('Cache-Control', 'no-store') - _security_headers(handler) + origin = handler.headers.get('Origin', None) or handler.headers.get('Referer', '').rsplit('/', 1)[0] if handler.headers.get('Referer', '') else None + _security_headers(handler, origin=origin) handler.end_headers() handler.wfile.write(body) @@ -73,7 +83,8 @@ def t(handler, payload, status: int=200, content_type: str='text/plain; charset= handler.send_header('Content-Type', content_type) handler.send_header('Content-Length', str(len(body))) handler.send_header('Cache-Control', 'no-store') - _security_headers(handler) + origin = handler.headers.get('Origin', None) or handler.headers.get('Referer', '').rsplit('/', 1)[0] if handler.headers.get('Referer', '') else None + _security_headers(handler, origin=origin) handler.end_headers() handler.wfile.write(body) diff --git a/api/mc.py b/api/mc.py index 283c6a2..e88c573 100644 --- a/api/mc.py +++ b/api/mc.py @@ -1,218 +1,695 @@ -""" -Mission Control API — Data layer for Hermes WebUI Mission Control extension. -Provides priorities, tasks, feed, and dashboard status management. -""" +# api/mc.py +# Mission Control — Projects & Tasks API +# Rose's persönliches PM-System import json -import threading -import time +import uuid from pathlib import Path -from typing import Any +from datetime import datetime, date, timedelta -from api.helpers import j +HERMES_HOME = Path.home() / ".hermes" +DATA_DIR = HERMES_HOME / "data" / "mc" +DATA_DIR.mkdir(parents=True, exist_ok=True) -# ── State file ──────────────────────────────────────────────────────────────── -_MC_DATA_FILE = Path.home() / ".hermes" / "data" / "mc-data.json" -_MC_LOCK = threading.RLock() +TASKS_FILE = DATA_DIR / "tasks.json" +PROJECTS_FILE = DATA_DIR / "projects.json" -# ── Default structure ───────────────────────────────────────────────────────── -DEFAULT_MC_DATA = { - "priorities": [], - "tasks": [], - "feed": [], +TASKS_FILE.write_text(json.dumps({"version": "3.0.0", "tasks": []}, indent=2)) +PROJECTS_FILE.write_text(json.dumps({"version": "3.0.0", "projects": []}, indent=2)) + +# ───────────────────────────────────────────────────────────────────────────── +# AGENT REGISTRY +# ───────────────────────────────────────────────────────────────────────────── + +AGENTS = { + "root": {"name": "🌳 Root", "emoji": "🌳", "domain": "Infrastruktur, Server, Docker, Backups"}, + "forget-me-not": {"name": "🌼 Forget-me-not", "emoji": "🌼", "domain": "Kalender, Termine, Geburtstage"}, + "sunflower": {"name": "🌻 Sunflower", "emoji": "🌻", "domain": "Finanzen, Abos, Rechnungen"}, + "iris": {"name": "⚜️ Iris", "emoji": "⚜️", "domain": "Karriere, Lernen, Focus"}, + "lotus": {"name": "🪷 Lotus", "emoji": "🪷", "domain": "Gesundheit, Fitness, Hobbys"}, + "ivy": {"name": "🌿 Ivy", "emoji": "🌿", "domain": "Smart Home, Home Assistant"}, + "dandelion": {"name": "🛡 Dandelion", "emoji": "🛡", "domain": "Kommunikation, Notifications, Spam"}, + "rose": {"name": "🌹 Rose", "emoji": "🌹", "domain": "Orchestrierung, Koordination"}, } +# ───────────────────────────────────────────────────────────────────────────── +# INTERNAL HELPERS +# ───────────────────────────────────────────────────────────────────────────── -def _load_mc_data() -> dict: - """Load Mission Control data from disk.""" - with _MC_LOCK: - if not _MC_DATA_FILE.exists(): - return DEFAULT_MC_DATA.copy() - try: - with open(_MC_DATA_FILE, "r") as f: - return json.load(f) - except (json.JSONDecodeError, IOError): - return DEFAULT_MC_DATA.copy() +def _load_tasks(): + if TASKS_FILE.exists(): + with TASKS_FILE.open(encoding="utf-8") as f: + return json.loads(f.read()) + return {"version": "3.0.0", "tasks": []} +def _save_tasks(data): + TASKS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) -def _save_mc_data(data: dict) -> None: - """Save Mission Control data to disk.""" - with _MC_LOCK: - _MC_DATA_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(_MC_DATA_FILE, "w") as f: - json.dump(data, f, indent=2) +def _load_projects(): + if PROJECTS_FILE.exists(): + with PROJECTS_FILE.open(encoding="utf-8") as f: + return json.loads(f.read()) + return {"version": "3.0.0", "projects": []} +def _save_projects(data): + PROJECTS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) -# ── Priority helpers ────────────────────────────────────────────────────────── +def _new_id(prefix="task"): + return f"{prefix}-{datetime.now().strftime('%y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}" -def get_priorities() -> list[dict]: - """Return all priorities sorted by id.""" - data = _load_mc_data() - return sorted(data.get("priorities", []), key=lambda p: p.get("id", 0)) +def _now(): + return datetime.now().isoformat() +def _today(): + return date.today().isoformat() -def create_priority(name: str, color: str = "#808080") -> dict: - """Add a new priority. Returns the created priority.""" - data = _load_mc_data() - priorities = data.get("priorities", []) - new_id = max([p.get("id", 0) for p in priorities], default=0) + 1 - priority = {"id": new_id, "name": name, "color": color} - priorities.append(priority) - data["priorities"] = priorities - _save_mc_data(data) - _add_feed_event(f"Priority created: {name}") - return priority +def _auto_done_subtasks(item): + """Check if all subtasks are done (for auto-done logic).""" + subtasks = item.get("subtasks", []) + if not subtasks: + return None + all_done = all(s.get("done", False) for s in subtasks) + return all_done +# ───────────────────────────────────────────────────────────────────────────── +# TASKS — CRUD +# ───────────────────────────────────────────────────────────────────────────── -def update_priority(priority_id: int, name: str = None, color: str = None, done: bool = None) -> dict | None: - """Update an existing priority. Returns updated priority or None if not found.""" - data = _load_mc_data() - priorities = data.get("priorities", []) - for p in priorities: - if p.get("id") == priority_id: - if name is not None: - p["name"] = name - if color is not None: - p["color"] = color - if done is not None: - p["done"] = done - if done: - _add_feed_event(f"Priority completed: {p['name']}") - data["priorities"] = priorities - _save_mc_data(data) - return p - return None - - -def delete_priority(priority_id: int) -> bool: - """Delete a priority. Returns True if found and deleted.""" - data = _load_mc_data() - priorities = data.get("priorities", []) - original_len = len(priorities) - priorities = [p for p in priorities if p.get("id") != priority_id] - if len(priorities) < original_len: - data["priorities"] = priorities - _save_mc_data(data) - return True - return False - - -# ── Task helpers ────────────────────────────────────────────────────────────── - -def get_tasks() -> list[dict]: - """Return all tasks sorted by priority then id.""" - data = _load_mc_data() - return sorted(data.get("tasks", []), key=lambda t: (t.get("priority", 999), t.get("id", 0))) - - -def create_task(title: str, priority: int = 1, status: str = "backlog") -> dict: - """Create a new task. Returns the created task.""" - data = _load_mc_data() +def list_tasks(filters=None): + """GET /api/mc/tasks — alle Tasks mit optionalen Filtern.""" + data = _load_tasks() tasks = data.get("tasks", []) - new_id = max([t.get("id", 0) for t in tasks], default=0) + 1 - task = {"id": new_id, "title": title, "priority": priority, "status": status} - tasks.append(task) - data["tasks"] = tasks - _save_mc_data(data) - _add_feed_event(f"Task created: {title}") + + if not filters: + return tasks + + # Filter: project_id + if "project_id" in filters and filters["project_id"]: + tasks = [t for t in tasks if t.get("project_id") == filters["project_id"]] + + # Filter: phase_id + if "phase_id" in filters and filters["phase_id"]: + tasks = [t for t in tasks if t.get("phase_id") == filters["phase_id"]] + + # Filter: task_type + if "task_type" in filters and filters["task_type"]: + tasks = [t for t in tasks if t.get("task_type") == filters["task_type"]] + + # Filter: type (user/agent) + if "type" in filters and filters["type"]: + tasks = [t for t in tasks if t.get("type") == filters["type"]] + + # Filter: assigned_agent + if "assigned_agent" in filters and filters["assigned_agent"]: + tasks = [t for t in tasks if t.get("assigned_agent") == filters["assigned_agent"]] + + # Filter: status + if "status" in filters and filters["status"]: + tasks = [t for t in tasks if t.get("status") == filters["status"]] + + # Filter: priority + if "priority" in filters and filters["priority"]: + tasks = [t for t in tasks if t.get("priority") == filters["priority"]] + + # Filter: task_type = one-time | daily (shorthand) + if "task_type" in filters and filters["task_type"]: + tasks = [t for t in tasks if t.get("task_type") == filters["task_type"]] + + return tasks + +def get_task(task_id): + """GET /api/mc/tasks/:id — einzelner Task.""" + data = _load_tasks() + return next((t for t in data["tasks"] if t["id"] == task_id), None) + +def create_task(body): + """POST /api/mc/tasks — Task erstellen.""" + data = _load_tasks() + + task = { + "id": _new_id("task"), + "title": body.get("title", "Untitled Task"), + "task_type": body.get("task_type", "one-time"), + "type": body.get("type", "user"), + "project_id": body.get("project_id"), + "phase_id": body.get("phase_id"), + "status": body.get("status", "todo"), + "priority": body.get("priority", "p2"), + "due": body.get("due"), + "due_time": body.get("due_time"), + "tags": body.get("tags", []), + "daily_schedule": body.get("daily_schedule"), + "daily_completed_today": False, + "daily_last_done": None, + "assigned_agent": body.get("assigned_agent"), + "agent_status": "pending" if body.get("type") == "agent" else None, + "agent_note": body.get("agent_note"), + "cron_schedule": body.get("cron_schedule"), + "cron_last_run": None, + "cron_next_run": None, + "subtasks": [], + "created_by": body.get("created_by", "user"), + "created_at": _now(), + "updated_at": _now(), + "completed_at": None, + } + + data["tasks"].append(task) + _save_tasks(data) return task +def update_task(task_id, body): + """PUT /api/mc/tasks/:id — Task updaten.""" + data = _load_tasks() -def update_task(task_id: int, **kwargs) -> dict | None: - """Update a task by id. kwargs: title, priority, status. Returns updated task or None.""" - data = _load_mc_data() - tasks = data.get("tasks", []) - for t in tasks: - if t.get("id") == task_id: - old_status = t.get("status") - for key in ("title", "priority", "status"): - if key in kwargs: - t[key] = kwargs[key] - new_status = t.get("status") - # Feed events for status transitions - if old_status != new_status: - if new_status == "done": - _add_feed_event(f"Task completed: {t['title']}") - elif new_status == "progress": - _add_feed_event(f"Task started: {t['title']}") - data["tasks"] = tasks - _save_mc_data(data) + for t in data["tasks"]: + if t["id"] == task_id: + # Erlaubte Felder + for key in ["title", "task_type", "type", "project_id", "phase_id", + "status", "priority", "due", "due_time", "tags", + "daily_schedule", "assigned_agent", "agent_status", + "agent_note", "cron_schedule", "cron_last_run", + "cron_next_run", "daily_completed_today", "daily_last_done"]: + if key in body: + t[key] = body[key] + + # Status → completed_at + if body.get("status") == "done" and t["completed_at"] is None: + t["completed_at"] = _now() + elif body.get("status") and body.get("status") != "done": + t["completed_at"] = None + + t["updated_at"] = _now() + + # Auto-done via subtasks + all_done = _auto_done_subtasks(t) + if all_done is True and t["status"] != "done": + t["status"] = "done" + t["completed_at"] = _now() + elif all_done is False and t["status"] == "done": + t["status"] = "todo" + + _save_tasks(data) return t + return None +def delete_task(task_id): + """DELETE /api/mc/tasks/:id — Task löschen.""" + data = _load_tasks() + before = len(data["tasks"]) + data["tasks"] = [t for t in data["tasks"] if t["id"] != task_id] + _save_tasks(data) + return len(data["tasks"]) < before -def delete_task(task_id: int) -> bool: - """Delete a task. Returns True if found and deleted.""" - data = _load_mc_data() - tasks = data.get("tasks", []) - original_len = len(tasks) - tasks = [t for t in tasks if t.get("id") != task_id] - if len(tasks) < original_len: - data["tasks"] = tasks - _save_mc_data(data) - return True +# ───────────────────────────────────────────────────────────────────────────── +# DAILY TASKS +# ───────────────────────────────────────────────────────────────────────────── + +def list_daily(): + """GET /api/mc/daily — alle Daily Tasks.""" + return list_tasks({"task_type": "daily"}) + +def toggle_daily_done(task_id): + """POST /api/mc/daily/:id/done — Daily Task heute erledigt togglen.""" + data = _load_tasks() + + for t in data["tasks"]: + if t["id"] == task_id and t.get("task_type") == "daily": + t["daily_completed_today"] = not t["daily_completed_today"] + if t["daily_completed_today"]: + t["daily_last_done"] = _today() + t["status"] = "done" + t["completed_at"] = _now() + else: + t["status"] = "todo" + t["completed_at"] = None + t["updated_at"] = _now() + _save_tasks(data) + return t + + return None + +def reset_daily_tasks(): + """POST /api/mc/daily/reset — Alle daily_completed_today = false (Mitternacht).""" + data = _load_tasks() + for t in data["tasks"]: + if t.get("task_type") == "daily": + t["daily_completed_today"] = False + if t["status"] == "done" and t.get("daily_last_done") != _today(): + t["status"] = "todo" + t["completed_at"] = None + _save_tasks(data) + return {"ok": True, "reset_at": _now()} + +# ───────────────────────────────────────────────────────────────────────────── +# SUBTASKS — TASK LEVEL +# ───────────────────────────────────────────────────────────────────────────── + +def add_subtask_task(task_id, body): + """POST /api/mc/tasks/:id/subtasks — Subtask zu Task.""" + data = _load_tasks() + for t in data["tasks"]: + if t["id"] == task_id: + subtask = { + "id": _new_id("sub"), + "title": body.get("title", "Subtask"), + "done": False, + "order": len(t.get("subtasks", [])) + 1, + "created_at": _now(), + } + if "subtasks" not in t: + t["subtasks"] = [] + t["subtasks"].append(subtask) + t["updated_at"] = _now() + _save_tasks(data) + return subtask + return None + +def update_subtask_task(task_id, subtask_id, body): + """PUT /api/mc/tasks/:id/subtasks/:sid — Subtask updaten.""" + data = _load_tasks() + for t in data["tasks"]: + if t["id"] == task_id: + for s in t.get("subtasks", []): + if s["id"] == subtask_id: + if "title" in body: + s["title"] = body["title"] + if "done" in body: + s["done"] = body["done"] + if "order" in body: + s["order"] = body["order"] + t["updated_at"] = _now() + + # Auto-done check + all_done = _auto_done_subtasks(t) + if all_done is True and t["status"] != "done": + t["status"] = "done" + t["completed_at"] = _now() + elif all_done is False and t["status"] == "done": + t["status"] = "todo" + t["completed_at"] = None + + _save_tasks(data) + return s + return None + +def delete_subtask_task(task_id, subtask_id): + """DELETE /api/mc/tasks/:id/subtasks/:sid — Subtask löschen.""" + data = _load_tasks() + for t in data["tasks"]: + if t["id"] == task_id: + before = len(t.get("subtasks", [])) + t["subtasks"] = [s for s in t.get("subtasks", []) if s["id"] != subtask_id] + t["updated_at"] = _now() + _save_tasks(data) + return len(t["subtasks"]) < before return False +# ───────────────────────────────────────────────────────────────────────────── +# PROJECTS — CRUD +# ───────────────────────────────────────────────────────────────────────────── -# ── Feed helpers ────────────────────────────────────────────────────────────── +def list_projects(): + """GET /api/mc/projects — alle Projekte mit Phasen.""" + data = _load_projects() + return data.get("projects", []) -def get_feed(limit: int = 50) -> list[dict]: - """Return recent feed events, newest first.""" - data = _load_mc_data() - feed = data.get("feed", []) - return sorted(feed, key=lambda f: f.get("timestamp", ""), reverse=True)[:limit] +def get_project(project_id): + """GET /api/mc/projects/:id — einzelnes Projekt.""" + data = _load_projects() + return next((p for p in data["projects"] if p["id"] == project_id), None) +def create_project(body): + """POST /api/mc/projects — Projekt erstellen.""" + data = _load_projects() -def _add_feed_event(event: str) -> None: - """Add a timestamped feed event.""" - data = _load_mc_data() - feed = data.get("feed", []) - feed.append({ - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "event": event, + project = { + "id": body.get("id") or _new_id("proj").replace("proj-", ""), + "name": body.get("name", "Neues Projekt"), + "color": body.get("color", "#6366f1"), + "description": body.get("description", ""), + "status": body.get("status", "active"), + "created_at": _now(), + "updated_at": _now(), + "subtasks": [], + "phases": [], + } + + data["projects"].append(project) + _save_projects(data) + return project + +def update_project(project_id, body): + """PUT /api/mc/projects/:id — Projekt updaten.""" + data = _load_projects() + + for p in data["projects"]: + if p["id"] == project_id: + for key in ["name", "color", "description", "status"]: + if key in body: + p[key] = body[key] + p["updated_at"] = _now() + _save_projects(data) + return p + + return None + +def delete_project(project_id): + """DELETE /api/mc/projects/:id — Projekt löschen.""" + data = _load_projects() + before = len(data["projects"]) + data["projects"] = [p for p in data["projects"] if p["id"] != project_id] + _save_projects(data) + + # Auch alle Tasks dieses Projekts löschen + tasks_data = _load_tasks() + tasks_data["tasks"] = [t for t in tasks_data["tasks"] if t.get("project_id") != project_id] + _save_tasks(tasks_data) + + return len(data["projects"]) < before + +# ───────────────────────────────────────────────────────────────────────────── +# PROJECT SUBTASKS +# ───────────────────────────────────────────────────────────────────────────── + +def add_subtask_project(project_id, body): + """POST /api/mc/projects/:id/subtasks.""" + data = _load_projects() + for p in data["projects"]: + if p["id"] == project_id: + subtask = { + "id": _new_id("sub"), + "title": body.get("title", "Subtask"), + "done": False, + "order": len(p.get("subtasks", [])) + 1, + "created_at": _now(), + } + if "subtasks" not in p: + p["subtasks"] = [] + p["subtasks"].append(subtask) + p["updated_at"] = _now() + _save_projects(data) + return subtask + return None + +def update_subtask_project(project_id, subtask_id, body): + """PUT /api/mc/projects/:id/subtasks/:sid.""" + data = _load_projects() + for p in data["projects"]: + if p["id"] == project_id: + for s in p.get("subtasks", []): + if s["id"] == subtask_id: + if "title" in body: + s["title"] = body["title"] + if "done" in body: + s["done"] = body["done"] + if "order" in body: + s["order"] = body["order"] + p["updated_at"] = _now() + _save_projects(data) + return s + return None + +def delete_subtask_project(project_id, subtask_id): + """DELETE /api/mc/projects/:id/subtasks/:sid.""" + data = _load_projects() + for p in data["projects"]: + if p["id"] == project_id: + before = len(p.get("subtasks", [])) + p["subtasks"] = [s for s in p.get("subtasks", []) if s["id"] != subtask_id] + p["updated_at"] = _now() + _save_projects(data) + return len(p["subtasks"]) < before + return False + +# ───────────────────────────────────────────────────────────────────────────── +# PHASES +# ───────────────────────────────────────────────────────────────────────────── + +def add_phase(project_id, body): + """POST /api/mc/projects/:id/phases — Phase hinzufügen.""" + data = _load_projects() + + for p in data["projects"]: + if p["id"] == project_id: + phase = { + "id": _new_id("phase"), + "name": body.get("name", "Neue Phase"), + "description": body.get("description", ""), + "testing": body.get("testing", ""), + "testing_status": body.get("testing_status", "pending"), + "reflection": body.get("reflection"), + "status": body.get("status", "todo"), + "order": len(p.get("phases", [])) + 1, + "completed_at": None, + "subtasks": [], + } + if "phases" not in p: + p["phases"] = [] + p["phases"].append(phase) + p["updated_at"] = _now() + _save_projects(data) + return phase + + return None + +def update_phase(phase_id, body): + """PUT /api/mc/phases/:id — Phase updaten.""" + data = _load_projects() + + for p in data["projects"]: + for ph in p.get("phases", []): + if ph["id"] == phase_id: + for key in ["name", "description", "testing", "testing_status", + "reflection", "status", "order"]: + if key in body: + ph[key] = body[key] + + if body.get("status") == "done" and ph["completed_at"] is None: + ph["completed_at"] = _now() + elif body.get("status") and body.get("status") != "done": + ph["completed_at"] = None + + p["updated_at"] = _now() + _save_projects(data) + return ph + + return None + +def delete_phase(phase_id): + """DELETE /api/mc/phases/:id — Phase löschen.""" + data = _load_projects() + + for p in data["projects"]: + before = len(p.get("phases", [])) + p["phases"] = [ph for ph in p.get("phases", []) if ph["id"] != phase_id] + if len(p["phases"]) < before: + p["updated_at"] = _now() + _save_projects(data) + + # Tasks dieser Phase auf standalone setzen + tasks_data = _load_tasks() + for t in tasks_data["tasks"]: + if t.get("phase_id") == phase_id: + t["phase_id"] = None + _save_tasks(tasks_data) + + return True + + return False + +def complete_phase(phase_id): + """PUT /api/mc/phases/:id/complete — Phase als done markieren.""" + return update_phase(phase_id, {"status": "done", "completed_at": _now()}) + +# ───────────────────────────────────────────────────────────────────────────── +# PHASE SUBTASKS +# ───────────────────────────────────────────────────────────────────────────── + +def add_subtask_phase(phase_id, body): + """POST /api/mc/phases/:id/subtasks.""" + data = _load_projects() + + for p in data["projects"]: + for ph in p.get("phases", []): + if ph["id"] == phase_id: + subtask = { + "id": _new_id("sub"), + "title": body.get("title", "Subtask"), + "done": False, + "order": len(ph.get("subtasks", [])) + 1, + "created_at": _now(), + } + if "subtasks" not in ph: + ph["subtasks"] = [] + ph["subtasks"].append(subtask) + p["updated_at"] = _now() + _save_projects(data) + return subtask + + return None + +def update_subtask_phase(phase_id, subtask_id, body): + """PUT /api/mc/phases/:id/subtasks/:sid.""" + data = _load_projects() + + for p in data["projects"]: + for ph in p.get("phases", []): + if ph["id"] == phase_id: + for s in ph.get("subtasks", []): + if s["id"] == subtask_id: + if "title" in body: + s["title"] = body["title"] + if "done" in body: + s["done"] = body["done"] + if "order" in body: + s["order"] = body["order"] + p["updated_at"] = _now() + + # Auto-done check für phase + all_done = all(st.get("done", False) for st in ph.get("subtasks", [])) + if all_done and ph["status"] != "done": + ph["status"] = "done" + ph["completed_at"] = _now() + + _save_projects(data) + return s + return None + +def delete_subtask_phase(phase_id, subtask_id): + """DELETE /api/mc/phases/:id/subtasks/:sid.""" + data = _load_projects() + + for p in data["projects"]: + for ph in p.get("phases", []): + if ph["id"] == phase_id: + before = len(ph.get("subtasks", [])) + ph["subtasks"] = [s for s in ph.get("subtasks", []) if s["id"] != subtask_id] + p["updated_at"] = _now() + _save_projects(data) + return len(ph["subtasks"]) < before + + return False + +# ───────────────────────────────────────────────────────────────────────────── +# AGENT ACTIONS +# ───────────────────────────────────────────────────────────────────────────── + +def agent_progress(task_id, body): + """POST /api/mc/tasks/:id/progress — Agent meldet Fortschritt.""" + return update_task(task_id, { + "agent_status": body.get("agent_status"), + "agent_note": body.get("agent_note"), + "cron_last_run": body.get("cron_last_run"), + "cron_next_run": body.get("cron_next_run"), }) - # Keep only last 200 events - data["feed"] = feed[-200:] - _save_mc_data(data) +def agent_note(task_id, body): + """POST /api/mc/tasks/:id/note — Agent setzt Notiz.""" + return update_task(task_id, {"agent_note": body.get("note")}) -# ── Dashboard status ────────────────────────────────────────────────────────── +def get_agents(): + """GET /api/mc/agents — Agent-Registry.""" + return AGENTS -def get_dashboard_status() -> dict: - """Return aggregated dashboard status for Mission Control.""" - data = _load_mc_data() - priorities = data.get("priorities", []) - tasks = data.get("tasks", []) +# ───────────────────────────────────────────────────────────────────────────── +# STATS +# ───────────────────────────────────────────────────────────────────────────── - priorities_total = len(priorities) - priorities_done = sum(1 for p in priorities if p.get("done")) +def get_stats(): + """GET /api/mc/stats — Statistiken.""" + tasks_data = _load_tasks() + projects_data = _load_projects() + tasks = tasks_data.get("tasks", []) + projects = projects_data.get("projects", []) - tasks_backlog = sum(1 for t in tasks if t.get("status") == "backlog") - tasks_progress = sum(1 for t in tasks if t.get("status") == "progress") - tasks_done = sum(1 for t in tasks if t.get("status") == "done") + total = len(tasks) + done = len([t for t in tasks if t.get("status") == "done"]) + today = _today() - feed = get_feed(limit=5) - latest_event = feed[0]["event"] if feed else "No recent activity" + # Overdue + overdue = len([t for t in tasks if t.get("due") and t["due"] < today and t.get("status") != "done"]) - # Health assessment - if tasks_done == 0 and tasks_backlog == 0 and tasks_progress == 0: - health = "empty" - elif tasks_progress > 0 and tasks_done > 0: - health = "healthy" - elif tasks_progress > 0: - health = "active" - elif tasks_backlog > 0: - health = "warning" - else: - health = "ok" + # By priority + by_priority = {"p1": 0, "p2": 0, "p3": 0} + for t in tasks: + p = t.get("priority", "p2") + if p in by_priority: + by_priority[p] += 1 + + # By type + by_type = {"one-time": 0, "daily": 0} + for t in tasks: + tt = t.get("task_type", "one-time") + if tt in by_type: + by_type[tt] += 1 + + # By status + by_status = {"todo": 0, "in_progress": 0, "review": 0, "done": 0} + for t in tasks: + s = t.get("status", "todo") + if s in by_status: + by_status[s] += 1 + + # Daily done today + daily_done_today = len([t for t in tasks if t.get("task_type") == "daily" and t.get("daily_completed_today", False)]) + + # Streak: consecutive days with completions going backwards + streak = 0 + check_date = date.today() + done_dates = set() + for t in tasks: + c = t.get("completed_at") + if c: + done_dates.add(c[:10]) + while True: + d = check_date.isoformat() + if d in done_dates: + streak += 1 + check_date -= timedelta(days=1) + else: + break + + # Agent activity + agent_activity = {} + for agent_id, agent in AGENTS.items(): + agent_tasks = [t for t in tasks if t.get("assigned_agent") == agent_id] + active = next((t for t in agent_tasks if t.get("agent_status") in ("running", "pending")), None) + agent_activity[agent_id] = { + **agent, + "task_count": len(agent_tasks), + "status": "active" if active else "idle", + "current_task": active["title"] if active else None, + } + + # Project progress + project_progress = [] + for pr in projects: + pr_tasks = [t for t in tasks if t.get("project_id") == pr["id"]] + pr_tasks_done = len([t for t in pr_tasks if t.get("status") == "done"]) + phases_total = len(pr.get("phases", [])) + phases_done = len([ph for ph in pr.get("phases", []) if ph.get("status") == "done"]) + project_progress.append({ + "id": pr["id"], + "name": pr["name"], + "color": pr.get("color"), + "status": pr.get("status"), + "tasks_total": len(pr_tasks), + "tasks_done": pr_tasks_done, + "phases_total": phases_total, + "phases_done": phases_done, + }) return { - "priorities_total": priorities_total, - "priorities_done": priorities_done, - "tasks_backlog": tasks_backlog, - "tasks_progress": tasks_progress, - "tasks_done": tasks_done, - "latest_feed_event": latest_event, - "dashboard_health": health, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "total": total, + "done": done, + "overdue": overdue, + "streak": streak, + "daily_done_today": daily_done_today, + "by_priority": by_priority, + "by_type": by_type, + "by_status": by_status, + "project_progress": project_progress, + "agent_activity": agent_activity, } diff --git a/api/models.py b/api/models.py index ba71954..b1c0b6b 100644 --- a/api/models.py +++ b/api/models.py @@ -32,8 +32,8 @@ def _write_session_index(): for s in SESSIONS.values(): if not any(e['session_id'] == s.session_id for e in entries): entries.append(s.compact()) - entries.sort(key=lambda s: s['updated_at'], reverse=True) - SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8') + entries.sort(key=lambda s: s['updated_at'], reverse=True) + SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8') class Session: @@ -97,7 +97,8 @@ class Session: p = SESSION_DIR / f'{sid}.json' if not p.exists(): return None - return cls(**json.loads(p.read_text(encoding='utf-8'))) + with p.open(encoding='utf-8') as f: + return cls(**json.loads(f.read())) def compact(self) -> dict: return { @@ -156,7 +157,8 @@ def all_sessions(): # Phase C: try index first for O(1) read; fall back to full scan if SESSION_INDEX_FILE.exists(): try: - index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8')) + with SESSION_INDEX_FILE.open(encoding='utf-8') as f: + index = json.loads(f.read()) # Overlay any in-memory sessions that may be newer than the index index_map = {s['session_id']: s for s in index} with LOCK: @@ -212,7 +214,7 @@ def load_projects() -> list: if not PROJECTS_FILE.exists(): return [] try: - return json.loads(PROJECTS_FILE.read_text(encoding='utf-8')) + with PROJECTS_FILE.open(encoding='utf-8') as _f: return json.loads(_f.read()) except Exception: return [] diff --git a/api/projects.py b/api/projects.py new file mode 100644 index 0000000..91cd19e --- /dev/null +++ b/api/projects.py @@ -0,0 +1,280 @@ +# api/projects.py +# Projects Tab Backend — Rose's Projects & Tasks Dashboard + +import json +from pathlib import Path +from datetime import datetime + +HERMES_HOME = Path.home() / ".hermes" +PROJECTS_DIR = HERMES_HOME / "projects" +DATA_FILE = HERMES_HOME / "data" / "projects.json" + +DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + + +def _load(): + """Lädt data/projects.json oder gibt leere Struktur zurück.""" + if DATA_FILE.exists(): + with DATA_FILE.open(encoding="utf-8") as f: + return json.loads(f.read()) + return {"version": "1.0.0", "projects": [], "daily_tasks": [], "recurring_tasks": []} + + +def _save(data): + """Speichert data/projects.json.""" + DATA_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + + +def list_projects(): + """Liest projects/ Ordner aus, synced mit data/projects.json. + + Jeder Unterordner in ~/.hermes/projects/ wird als Projekt registriert. + Bereits existierende Projekte (nach folder) werden nicht dupliziert. + """ + data = _load() + + # Sync: jede Folder in projects/ → Projekt-Eintrag wenn nicht vorhanden + for folder in sorted(PROJECTS_DIR.iterdir()): + if folder.is_dir() and not folder.name.startswith('.'): + exists = any(p.get('folder') == folder.name for p in data['projects']) + if not exists: + data['projects'].append({ + "id": folder.name, + "name": folder.name.replace('-', ' ').replace('_', ' ').title(), + "description": "", + "folder": folder.name, + "category": "unknown", + "status": "active", + "created": datetime.now().date().isoformat(), + "updated": datetime.now().isoformat(), + "tasks": [] + }) + + _save(data) + return data['projects'] + + +def get_project(project_id): + """Holt ein einzelnes Projekt nach ID.""" + data = _load() + return next((p for p in data['projects'] if p['id'] == project_id), None) + + +def create_task(project_id, task): + """Erstellt Task in Projekt oder als daily/recurring. + + Args: + project_id: ID des Projekts (für project tasks) oder None + task: dict mit title, task_type, status, priority, due, tags + + Returns: + Das erstellte Task-Objekt mit generierter ID + """ + data = _load() + + # ID generieren basierend auf task_type + task_type = task.get('task_type', 'project') + if task_type == 'daily': + existing = len(data.get('daily_tasks', [])) + task['id'] = f"daily-{existing + 1:03d}" + elif task_type == 'recurring': + existing = len(data.get('recurring_tasks', [])) + task['id'] = f"recurring-{existing + 1:03d}" + else: + existing = sum(len(p.get('tasks', [])) for p in data['projects']) + task['id'] = f"project-{existing + 1:03d}" + + task['created'] = datetime.now().isoformat() + task['completed'] = None + + if task_type == 'project' and project_id: + for p in data['projects']: + if p['id'] == project_id: + if 'tasks' not in p: + p['tasks'] = [] + p['tasks'].append(task) + p['updated'] = datetime.now().isoformat() + break + elif task_type == 'project': + # Unassigned project task → find or create Inbox project + inbox = next((p for p in data['projects'] if p['id'] == 'inbox'), None) + if not inbox: + inbox = { + 'id': 'inbox', + 'name': '📥 Inbox', + 'color': '#6366f1', + 'tasks': [], + 'created': datetime.now().isoformat(), + 'updated': datetime.now().isoformat() + } + data['projects'].insert(0, inbox) + inbox['tasks'].append(task) + inbox['updated'] = datetime.now().isoformat() + elif task_type == 'daily': + data['daily_tasks'].append(task) + elif task_type == 'recurring': + data['recurring_tasks'].append(task) + + _save(data) + return task + + +def update_task(task_id, updates): + """Updated Task (status, priority, due, etc.). + + Sucht Task in allen drei Listen (projects.tasks, daily_tasks, recurring_tasks). + """ + data = _load() + + # Search in project tasks + for p in data['projects']: + for t in p.get('tasks', []): + if t['id'] == task_id: + t.update(updates) + p['updated'] = datetime.now().isoformat() + _save(data) + return t + + # Search in daily tasks + for t in data.get('daily_tasks', []): + if t['id'] == task_id: + t.update(updates) + _save(data) + return t + + # Search in recurring tasks + for t in data.get('recurring_tasks', []): + if t['id'] == task_id: + t.update(updates) + _save(data) + return t + + return None + + +def delete_task(task_id): + """Löscht Task aus allen drei Listen.""" + data = _load() + + # Remove from project tasks + for p in data['projects']: + p['tasks'] = [t for t in p.get('tasks', []) if t['id'] != task_id] + + # Remove from daily tasks + data['daily_tasks'] = [t for t in data.get('daily_tasks', []) if t['id'] != task_id] + + # Remove from recurring tasks + data['recurring_tasks'] = [t for t in data.get('recurring_tasks', []) if t['id'] != task_id] + + _save(data) + return True + + +def get_all_tasks(): + """Holt alle Tasks für Kanban-View. + + Fügt project_name hinzu für Project-Tasks. + Setzt Defaults für fehlende Felder (defensive). + """ + data = _load() + tasks = [] + + # Defaults für alle Tasks + DEFAULT_FIELDS = { + 'title': 'Untitled Task', + 'task_type': 'project', + 'status': 'todo', + 'priority': 'p2', + 'due': None, + 'tags': [], + 'project_id': None, + 'project_name': None, + 'completed': None, + } + + for p in data['projects']: + for t in p.get('tasks', []): + t = dict(t) # Copy to avoid mutating original + t['project_name'] = p.get('name') + # Apply defaults for missing fields + for k, v in DEFAULT_FIELDS.items(): + t.setdefault(k, v) + tasks.append(t) + + for t in data.get('daily_tasks', []): + t = dict(t) + for k, v in DEFAULT_FIELDS.items(): + t.setdefault(k, v) + t.setdefault('status', 'pending') + tasks.append(t) + + for t in data.get('recurring_tasks', []): + t = dict(t) + for k, v in DEFAULT_FIELDS.items(): + t.setdefault(k, v) + t.setdefault('status', 'pending') + tasks.append(t) + + return tasks + + +def get_stats(): + """Statistiken für Projects Tab. + + Returns: + dict mit total_tasks, done, today_completed, active_projects, + streak, by_priority, by_type, overdue + """ + from datetime import date, timedelta + + data = _load() + all_tasks = get_all_tasks() + + done = [t for t in all_tasks if t.get('status') == 'done'] + today = date.today().isoformat() + today_done = [t for t in done if (t.get('completed') or '').startswith(today)] + + # Streak: consecutive days with completions going backwards + streak = 0 + check_date = date.today() + done_dates = set() + for t in done: + c = t.get('completed') + if c: + done_dates.add(c[:10]) + while True: + d = check_date.isoformat() + if d in done_dates: + streak += 1 + check_date -= timedelta(days=1) + else: + break + + # By priority + by_priority = {'p1': 0, 'p2': 0, 'p3': 0} + for t in all_tasks: + p = t.get('priority', 'p2') + if p in by_priority: + by_priority[p] += 1 + + # By type + by_type = {'project': 0, 'daily': 0, 'recurring': 0} + for t in all_tasks: + by_type[t.get('task_type', 'project')] += 1 + + # Overdue + overdue = [ + t for t in all_tasks + if t.get('due') and t['due'] < today and t.get('status') != 'done' + ] + + return { + "total_tasks": len(all_tasks), + "done": len(done), + "today_completed": len(today_done), + "active_projects": len([p for p in data['projects'] if p.get('status') == 'active']), + "streak": streak, + "by_priority": by_priority, + "by_type": by_type, + "overdue": len(overdue), + } diff --git a/api/routes.py b/api/routes.py index e99ab68..74f2cd4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -61,6 +61,20 @@ from api import heartbeats as _heartbeats import re as _re _re_path = _re.compile(r"^(?P/[^?]*)") +def _extract_origin_from_headers(handler) -> str | None: + """Extract the best origin from request headers (Origin or Referer).""" + origin = handler.headers.get('Origin', '') + if origin: + return origin + referer = handler.headers.get('Referer', '') + if referer: + # Extract origin from Referer header + m = _re.match(r'^(https?://[^/]+)', referer) + if m: + return m.group(1) + return None + + def _normalize_host_port(value: str) -> tuple[str, str | None]: """Split a host or host:port string into (hostname, port|None). Handles IPv6 bracket notation, e.g. [::1]:8080.""" @@ -128,12 +142,15 @@ def _check_csrf(handler) -> bool: origin = handler.headers.get("Origin", "") referer = handler.headers.get("Referer", "") host = handler.headers.get("Host", "") + x_fwd_host = handler.headers.get("X-Forwarded-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) if not m: + import sys + print(f"[CSRF DEBUG] no host match in target={target!r}", flush=True, file=sys.stderr) return False origin_host = m.group(1) origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https' @@ -142,6 +159,9 @@ def _check_csrf(handler) -> bool: origin_value = m.group(0).rstrip('/').lower() if origin_value in _allowed_public_origins(): return True + # Allow dev-mission.sabo.synology.me for development + if origin_name == "dev-mission.sabo.synology.me": + return True # 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. @@ -158,6 +178,9 @@ def _check_csrf(handler) -> bool: allowed_name, allowed_port = _normalize_host_port(allowed) if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port): return True + # DEBUG: log what we rejected + import sys + print(f"[CSRF DEBUG] REJECTED origin={origin!r} referer={referer!r} host={host!r} x_fwd_host={x_fwd_host!r} origin_name={origin_name}", flush=True, file=sys.stderr) return False @@ -569,7 +592,13 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)}) if parsed.path == "/api/projects": - return j(handler, {"projects": load_projects()}) + # Transform from old {project_id, name} format to new {id, name} format + raw = load_projects() + projects = [{"id": p.get("project_id") or p.get("id"), + "name": p.get("name", ""), + "color": p.get("color", "#6366f1"), + "tasks": []} for p in raw] + return j(handler, {"projects": projects}) # ── Projects Tab Tasks (NEW) ────────────────────────────────────────────── from api import projects as _projects @@ -961,6 +990,10 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"error": str(e)}, status=500) # GET /api/heartbeats — list all + status + if parsed.path == "/api/heartbeats/stats": + return j(handler, _heartbeats.handle_get(parsed.path)) + if parsed.path == "/api/heartbeats/config": + return j(handler, _heartbeats.handle_get(parsed.path)) if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"): result = _heartbeats.handle_get(parsed.path) if result is not None: @@ -1031,6 +1064,10 @@ def handle_post(handler, parsed) -> bool: except ValueError as e: return bad(handler, str(e)) s = new_session(workspace=workspace, model=body.get("model")) + # Save agent to session if provided + if body.get("agent"): + s.agent = body.get("agent") + s.save() return j(handler, {"session": s.compact() | {"messages": s.messages}}) if parsed.path == "/api/sessions/cleanup": @@ -1052,6 +1089,21 @@ def handle_post(handler, parsed) -> bool: s.save() return j(handler, {"session": s.compact()}) + if parsed.path == "/api/session/reorder": + # Drag & drop reorder — update the session's updated_at to reposition it + try: + require(body, "session_id", "weight") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + # weight is a float timestamp used as sort key; set it to target + small delta + s.updated_at = float(body["weight"]) + s.save() + return j(handler, {"ok": True}) + if parsed.path == "/api/personality/set": try: require(body, "session_id") @@ -1478,6 +1530,10 @@ def handle_post(handler, parsed) -> bool: if "bot_name" in body: body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes" + if "user_emoji" in body: + body["user_emoji"] = (str(body["user_emoji"]) or "").strip()[:8] or "🙂" + if "user_name" in body: + body["user_name"] = (str(body["user_name"]) or "").strip()[:32] or "You" auth_enabled_before = is_auth_enabled() current_cookie = parse_cookie(handler) @@ -1658,7 +1714,8 @@ def handle_post(handler, parsed) -> bool: # Unassign all sessions that belonged to this project if SESSION_INDEX_FILE.exists(): try: - index = json.loads(SESSION_INDEX_FILE.read_text(encoding="utf-8")) + with SESSION_INDEX_FILE.open(encoding="utf-8") as f: + index = json.loads(f.read()) for entry in index: if entry.get("project_id") == body["project_id"]: try: @@ -1737,6 +1794,12 @@ def handle_post(handler, parsed) -> bool: return True # POST /api/heartbeats — create heartbeat + if parsed.path == "/api/heartbeats/config": + result = _heartbeats.handle_post(parsed.path, body) + if result is not None: + status = 200 + if isinstance(result, tuple): result, status = result + return j(handler, result, status=status) if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"): result = _heartbeats.handle_post(parsed.path, body) if result is not None: @@ -1780,6 +1843,10 @@ def handle_put(handler, parsed) -> bool: agent_id = parsed.path.split("/")[-2] return j(handler, _agents.update_agent_memory(agent_id, body.get("content", ""))) + # PUT /api/skills/toggle + if parsed.path == "/api/skills/toggle": + return _handle_skill_toggle(handler, body) + return False # 404 @@ -1953,6 +2020,8 @@ def _handle_sse_stream(handler, parsed): return j(handler, {"error": "stream not found"}, status=404) handler.send_response(200) handler.send_header("Content-Type", "text/event-stream; charset=utf-8") + # NOTE: Content-Encoding:gzip removed — requires gzip writer wrapper on wfile + # Without actual gzip compression the header would cause browser decode errors handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") handler.send_header("Connection", "keep-alive") @@ -1966,7 +2035,7 @@ def _handle_sse_stream(handler, parsed): handler.wfile.flush() continue _sse(handler, event, data) - if event in ("stream_end", "error", "cancel"): + if event in ("stream_end", "error", "cancel", "apperror"): break except (BrokenPipeError, ConnectionResetError): pass @@ -3232,6 +3301,12 @@ def _handle_skill_save(handler, body): if category and ("/" in category or ".." in category): return bad(handler, "Invalid category") from tools.skills_tool import SKILLS_DIR + import shutil + + # Find and remove ALL existing instances of this skill (handles category-change updates) + existing = list(SKILLS_DIR.rglob(f"{skill_name}/SKILL.md")) + for old_file in existing: + shutil.rmtree(str(old_file.parent)) if category: skill_dir = SKILLS_DIR / category / skill_name @@ -3264,6 +3339,34 @@ def _handle_skill_delete(handler, body): return j(handler, {"ok": True, "name": body["name"]}) +def _handle_skill_toggle(handler, body): + """Enable or disable a skill by name.""" + name = body.get("name") + if not name: + return bad(handler, "Missing field: name") + enabled = body.get("enabled") + if enabled is None: + return bad(handler, "Missing field: enabled") + + import sys as _sys + from pathlib import Path as _P + _agent_path = (_P(__file__).parent.parent / "hermes-agent").resolve() + if str(_agent_path) not in _sys.path: + _sys.path.insert(0, str(_agent_path)) + + from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills + from api.config import load_config + + config = load_config() + disabled = get_disabled_skills(config) + if enabled: + disabled.discard(name) + else: + disabled.add(name) + save_disabled_skills(config, disabled) + return j(handler, {"ok": True, "name": name, "enabled": enabled}) + + def _handle_memory_write(handler, body): try: require(body, "section", "content") diff --git a/api/streaming.py b/api/streaming.py index da1df2c..5c79acb 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -777,9 +777,13 @@ def _sse(handler, event, data): def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, agent=None): """Run agent in background thread, writing SSE events to STREAMS[stream_id].""" + print(f'[DEBUG streaming] started stream_id={stream_id}', flush=True) q = STREAMS.get(stream_id) + print(f'[DEBUG streaming] STREAMS keys={list(STREAMS.keys())}', flush=True) if q is None: + print(f'[DEBUG streaming] queue is None for stream_id={stream_id}', flush=True) return + print(f'[DEBUG streaming] queue found, agent={agent}', flush=True) s = None _rt = {} old_cwd = None @@ -937,12 +941,41 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta _reasoning_text = '' # accumulates reasoning/thinking trace for persistence _live_tool_calls = [] # tool progress fallback when final messages omit tool IDs + _token_buf = [] # token text buffer for batching + _token_buf_timer = None # threading.Timer reference + _token_buf_closed = False # True after sentinel seen + + def _flush_token_buf(): + nonlocal _token_buf_timer + if _token_buf_closed or not _token_buf: + return + # Grab and clear the buffer atomically + batch = ''.join(_token_buf) + _token_buf.clear() + # Cancel any pending timer + if _token_buf_timer is not None: + _token_buf_timer.cancel() + _token_buf_timer = None + # _buf_closed guard ensures we never put after sentinel + if not _token_buf_closed: + put('token', {'text': batch}) + def on_token(text): - nonlocal _token_sent + nonlocal _token_sent, _token_buf_timer, _token_buf_closed if text is None: + # Flush any remaining buffered tokens, then mark closed + _flush_token_buf() + _token_buf_closed = True return # end-of-stream sentinel _token_sent = True - put('token', {'text': text}) + _token_buf.append(text) + if len(_token_buf) >= 20: + # Flush immediately on 20-token threshold + _flush_token_buf() + elif _token_buf_timer is None: + # Start 100ms debounce timer (only if not already pending) + _token_buf_timer = threading.Timer(0.1, _flush_token_buf) + _token_buf_timer.start() def on_reasoning(text): nonlocal _reasoning_text @@ -1318,6 +1351,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta if isinstance(_rm, dict) and _rm.get('role') == 'assistant': _rm['reasoning'] = _reasoning_text break + # Tag the last assistant message with per-turn token usage so the UI + # can display it on that specific message instead of the cumulative total. + if s.messages: + for _rm in reversed(s.messages): + if isinstance(_rm, dict) and _rm.get('role') == 'assistant': + _rm['_usage'] = {'in': input_tokens, 'out': output_tokens} + break s.save() # Sync to state.db for /insights (opt-in setting) try: @@ -1342,6 +1382,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta usage['context_length'] = getattr(_cc, 'context_length', 0) or 0 usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0 usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0 + # Send cumulative session totals separately so UI can label them as "session total" + usage['_session_input_tokens'] = s.input_tokens or 0 + usage['_session_output_tokens'] = s.output_tokens or 0 # (reasoning trace already attached + saved above, before s.save()) raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls} put('done', {'session': redact_session_data(raw_session), 'usage': usage}) diff --git a/api/workspace.py b/api/workspace.py index 140055f..0af3555 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -139,7 +139,8 @@ def _migrate_global_workspaces() -> list: if not _GLOBAL_WS_FILE.exists(): return [] try: - raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8')) + with _GLOBAL_WS_FILE.open(encoding='utf-8') as f: + raw = json.loads(f.read()) cleaned = _clean_workspace_list(raw) if len(cleaned) != len(raw): # Rewrite the cleaned version so future reads are already clean @@ -155,7 +156,8 @@ def load_workspaces() -> list: ws_file = _workspaces_file() if ws_file.exists(): try: - raw = json.loads(ws_file.read_text(encoding='utf-8')) + with ws_file.open(encoding='utf-8') as f: + raw = json.loads(f.read()) cleaned = _clean_workspace_list(raw) if len(cleaned) != len(raw): # Persist the cleaned version so stale entries don't keep reappearing diff --git a/package.json b/package.json new file mode 100644 index 0000000..2946f8b --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "webui-dev", + "version": "1.0.0", + "description": "[Hermes Agent] WebUI Development Environment", + "type": "module", + "scripts": { + "dev": "vite", + "check": "tsc --noEmit", + "build": "node scripts/build.js" + }, + "repository": { + "type": "git", + "url": "https://x-access-token:***@git.sabo.synology.me/Sabo/webui-develop.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.9" + }, + "dependencies": { + "@tanstack/virtual-core": "^3.14.0" + } +} diff --git a/server.py b/server.py index 4466307..8f79507 100644 --- a/server.py +++ b/server.py @@ -60,6 +60,27 @@ class Handler(BaseHTTPRequestHandler): }) print(f'[webui] {record}', flush=True) + def do_OPTIONS(self) -> None: + """Handle CORS preflight requests.""" + self._req_t0 = time.time() + try: + parsed = urlparse(self.path) + origin = self.headers.get('Origin', '') + # Set CORS headers for preflight + self.send_response(204) + self.send_header('Access-Control-Allow-Origin', origin or '*') + self.send_header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Requested-With') + self.send_header('Access-Control-Max-Age', '86400') + self.send_header('Vary', 'Origin') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('X-Frame-Options', 'DENY') + self.end_headers() + except Exception as e: + print(f'[webui] ERROR OPTIONS {self.path}\n' + traceback.format_exc(), flush=True) + self.send_response(500) + self.end_headers() + def do_GET(self) -> None: self._req_t0 = time.time() try: diff --git a/static/activity-tree.js b/static/activity-tree.js new file mode 100644 index 0000000..dd6d2cb --- /dev/null +++ b/static/activity-tree.js @@ -0,0 +1,380 @@ +(() => { + const AGENT_META = { + rose: { emoji: "\u{1F339}", name: "Rose", tier: 1 }, + lotus: { emoji: "\u{1FAB7}", name: "Lotus", tier: 2 }, + "forget-me-not": { emoji: "\u{1F33C}", name: "Forget-me-not", tier: 2 }, + sunflower: { emoji: "\u{1F33B}", name: "Sunflower", tier: 2 }, + iris: { emoji: "\u269C\uFE0F", name: "Iris", tier: 2 }, + ivy: { emoji: "\u{1F33F}", name: "Ivy", tier: 2 }, + dandelion: { emoji: "\u{1F6E1}", name: "Dandelion", tier: 2 }, + root: { emoji: "\u{1F333}", name: "Root", tier: 2 } + }; + let _nodeCounter = 0; + function _nextNodeId(agentId) { + return `${agentId}-${++_nodeCounter}`; + } + function initActivityTree() { + const tree = { + version: 1, + rootId: "rose", + nodes: { + rose: { + id: "rose", + parentId: null, + agentId: "rose", + agentEmoji: "\u{1F339}", + agentName: "Rose", + tier: 1, + status: "running", + task: "Orchestrating", + toolCalls: [], + startedAt: Date.now(), + endedAt: null, + duration: null, + children: [], + collapsed: false, + metadata: {} + } + }, + stats: _emptyStats() + }; + S.activityTree = tree; + S.mcFilter = {}; + S.mcSort = "runtime"; + return tree; + } + function _emptyStats() { + return { + totalAgents: 0, + runningAgents: 0, + pendingAgents: 0, + doneAgents: 0, + errorAgents: 0, + totalTools: 0, + doneTools: 0, + runningTools: 0, + avgResponseTime: 0, + totalElapsed: 0 + }; + } + function atAddNode(opts) { + const tree = S.activityTree; + if (!tree) return null; + const parentId = opts.parentId || tree.rootId; + const parent = tree.nodes[parentId]; + if (!parent) return null; + const meta = AGENT_META[opts.agentId]; + const tier = opts.tier || (meta ? meta.tier : 3); + const id = _nextNodeId(opts.agentId); + const node = { + id, + parentId, + agentId: opts.agentId, + agentEmoji: meta?.emoji || "\u2699\uFE0F", + agentName: meta?.name || opts.agentId, + tier, + status: opts.status || "pending", + task: opts.task, + toolCalls: [], + startedAt: opts.status === "running" ? Date.now() : null, + endedAt: null, + duration: null, + children: [], + collapsed: false, + metadata: {} + }; + tree.nodes[id] = node; + parent.children.push(id); + _recalcStats(tree); + return node; + } + function atUpdateNode(nodeId, updates) { + const tree = S.activityTree; + if (!tree) return; + const node = tree.nodes[nodeId]; + if (!node) return; + Object.assign(node, updates); + _recalcStats(tree); + } + function atGetNode(nodeId) { + return S.activityTree?.nodes[nodeId] || null; + } + function atAddToolCall(nodeId, tc) { + const tree = S.activityTree; + if (!tree) return null; + const node = tree.nodes[nodeId]; + if (!node) return null; + if (node.status === "pending") { + node.status = "running"; + node.startedAt = node.startedAt || Date.now(); + } + const toolCall = { + id: `tc-${++_nodeCounter}`, + name: tc.name, + status: tc.status || "running", + args: tc.args || {}, + startedAt: Date.now(), + endedAt: null + }; + node.toolCalls.push(toolCall); + _recalcStats(tree); + return toolCall; + } + function atUpdateToolCall(nodeId, toolId, updates) { + const tree = S.activityTree; + if (!tree) return; + const node = tree.nodes[nodeId]; + if (!node) return; + const tc = node.toolCalls.find((t) => t.id === toolId); + if (!tc) return; + Object.assign(tc, updates); + if (updates.status === "done" || updates.status === "error") { + tc.endedAt = tc.endedAt || Date.now(); + tc.duration = tc.startedAt ? (tc.endedAt - tc.startedAt) / 1e3 : null; + } + _recalcStats(tree); + } + function atFinalizeNode(nodeId, status) { + const tree = S.activityTree; + if (!tree) return; + const node = tree.nodes[nodeId]; + if (!node) return; + node.status = status; + node.endedAt = Date.now(); + node.duration = node.startedAt ? (node.endedAt - node.startedAt) / 1e3 : null; + for (const tc of node.toolCalls) { + if (tc.status === "running" || tc.status === "pending") { + tc.status = status === "error" ? "error" : "done"; + tc.endedAt = node.endedAt; + tc.duration = tc.startedAt ? (tc.endedAt - tc.startedAt) / 1e3 : null; + } + } + for (const childId of node.children) { + const child = tree.nodes[childId]; + if (child && (child.status === "running" || child.status === "thinking" || child.status === "pending")) { + atFinalizeNode(childId, status); + } + } + _recalcStats(tree); + } + function _recalcStats(tree) { + const nodes = Object.values(tree.nodes).filter((n) => n.id !== tree.rootId); + const tools = nodes.flatMap((n) => n.toolCalls); + const doneDurations = nodes.filter((n) => n.duration !== null).map((n) => n.duration); + const runningNodes = nodes.filter((n) => n.status === "running" || n.status === "thinking"); + const maxElapsed = runningNodes.reduce((max, n) => { + const elapsed = n.startedAt ? (Date.now() - n.startedAt) / 1e3 : 0; + return Math.max(max, elapsed); + }, 0); + tree.stats = { + totalAgents: nodes.length, + runningAgents: runningNodes.length, + pendingAgents: nodes.filter((n) => n.status === "pending").length, + doneAgents: nodes.filter((n) => n.status === "done").length, + errorAgents: nodes.filter((n) => n.status === "error").length, + totalTools: tools.length, + doneTools: tools.filter((t) => t.status === "done").length, + runningTools: tools.filter((t) => t.status === "running").length, + avgResponseTime: doneDurations.length ? doneDurations.reduce((a, b) => a + b, 0) / doneDurations.length : 0, + totalElapsed: maxElapsed + }; + } + function atGetStats() { + return S.activityTree?.stats || _emptyStats(); + } + function atReset() { + _nodeCounter = 0; + initActivityTree(); + } + function formatElapsed(ms) { + if (ms < 1e3) return `${Math.round(ms)}ms`; + const s = Math.floor(ms / 1e3); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m ${rs}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; + } + function formatDuration(seconds) { + if (seconds === null) return "\u2014"; + if (seconds < 0.01) return "<0.01s"; + if (seconds < 1) return `${seconds.toFixed(2)}s`; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const m = Math.floor(seconds / 60); + const rs = Math.round(seconds % 60); + return `${m}m ${rs}s`; + } + function createMockActivityTree() { + initActivityTree(); + const tree = S.activityTree; + const now = Date.now(); + const lotus = atAddNode({ agentId: "lotus", task: "Analysiere Gesundheitsdaten", status: "running" }); + lotus.startedAt = now - 12e3; + atAddToolCall(lotus.id, { name: "search_files", args: { pattern: "health*", path: "/data" } }); + atUpdateToolCall(lotus.id, lotus.toolCalls[0].id, { status: "done", result: "12 matches", duration: 0.3 }); + lotus.toolCalls[0].startedAt = now - 11e3; + lotus.toolCalls[0].endedAt = now - 10997; + atAddToolCall(lotus.id, { name: "read_file", args: { path: "/data/health-log.md" } }); + const sunflower = atAddNode({ agentId: "sunflower", task: "Portfolio-Analyse Q1 2026", status: "running" }); + sunflower.startedAt = now - 8e3; + atAddToolCall(sunflower.id, { name: "browser_navigate", args: { url: "https://finance.example.com" } }); + atUpdateToolCall(sunflower.id, sunflower.toolCalls[0].id, { status: "done", result: "Page loaded", duration: 2.1 }); + sunflower.toolCalls[0].startedAt = now - 7500; + sunflower.toolCalls[0].endedAt = now - 5400; + atAddToolCall(sunflower.id, { name: "terminal", args: { command: "python3 analyse.py" } }); + const dandelion = atAddNode({ agentId: "dandelion", task: "Triaging unread messages", status: "pending" }); + const researcher = atAddNode({ parentId: lotus.id, agentId: "researcher", task: "Looking up nutrition data", status: "running", tier: 3 }); + researcher.agentEmoji = "\u{1F50D}"; + researcher.agentName = "Researcher"; + researcher.startedAt = now - 3e3; + atAddToolCall(researcher.id, { name: "web_search", args: { query: "nutrition database API" } }); + _recalcStats(tree); + return tree; + } + window.initActivityTree = initActivityTree; + window.createMockActivityTree = createMockActivityTree; + window.formatElapsed = formatElapsed; + window.formatDuration = formatDuration; + window.atAddNode = atAddNode; + window.atUpdateNode = atUpdateNode; + window.atGetNode = atGetNode; + window.atAddToolCall = atAddToolCall; + window.atUpdateToolCall = atUpdateToolCall; + window.atFinalizeNode = atFinalizeNode; + window.atGetStats = atGetStats; + window.atReset = atReset; + window._atTrackTool = _atTrackTool; + window._atTrackToolComplete = _atTrackToolComplete; + window._atTrackDone = _atTrackDone; + window._atTrackSubagent = _atTrackSubagent; + window._at = { + init: initActivityTree, + addNode: atAddNode, + updateNode: atUpdateNode, + getNode: atGetNode, + addToolCall: atAddToolCall, + updateToolCall: atUpdateToolCall, + finalize: atFinalizeNode, + stats: atGetStats, + reset: atReset, + mock: createMockActivityTree, + formatElapsed, + formatDuration, + AGENT_META, + trackSubagent: _atTrackSubagent, + }; + let _activeAgentNodeId = null; + let _lastToolId = null; + function _atTrackTool(d) { + if (!S.activityTree) initActivityTree(); + const tree = S.activityTree; + if (d.name === "delegate_task") { + const agentId = d.args?.agentId || d.args?.agent || d.args?.name || "unknown"; + const task = d.args?.goal || d.args?.prompt || d.args?.task || d.preview || "Delegated task"; + const node = atAddNode({ agentId, task, status: "running" }); + if (node) { + _activeAgentNodeId = node.id; + } + return; + } + let targetNodeId = _activeAgentNodeId || tree.rootId; + const inflight = window.INFLIGHT?.[S.session?.session_id]; + if (inflight?.thisTurnAgent) { + const agentKey = inflight.thisTurnAgent; + const existingNode = Object.values(tree.nodes).find( + (n) => n.agentId === agentKey && (n.status === "running" || n.status === "thinking") + ); + if (existingNode) { + targetNodeId = existingNode.id; + _activeAgentNodeId = existingNode.id; + } else if (AGENT_META[agentKey]) { + const task = inflight.thisTurnModel ? `${inflight.thisTurnModel}` : "Working..."; + const node = atAddNode({ agentId: agentKey, task, status: "running" }); + if (node) { + _activeAgentNodeId = node.id; + targetNodeId = node.id; + } + } + } + const tc = atAddToolCall(targetNodeId, { + name: d.name, + args: d.args || {}, + status: "running" + }); + if (tc) { + _lastToolId = tc.id; + } + } + function _atTrackToolComplete(d) { + if (!S.activityTree) return; + const tree = S.activityTree; + const activeNodes = Object.values(tree.nodes).filter( + (n) => n.status === "running" || n.status === "thinking" + ); + for (const node of activeNodes) { + for (const tc of node.toolCalls) { + if (tc.status === "running" && tc.name === d.name) { + atUpdateToolCall(node.id, tc.id, { + status: d.is_error ? "error" : "done", + result: d.preview || d.result, + duration: d.duration + }); + return; + } + } + } + } + function _atTrackSubagent(d) { + if (!S.activityTree) initActivityTree(); + const tree = S.activityTree; + + if (d.event_type === 'subagent.start') { + // Derive agentId from subagent_id if available, otherwise from goal hint. + // subagent_id format: "lotus-1" → agentId = "lotus" + let agentId = 'unknown'; + if (d.subagent_id) { + // Strip numeric suffix e.g. "lotus-1" → "lotus" + agentId = d.subagent_id.replace(/-\d+$/, ''); + } + const task = d.goal || d.preview || `Subagent ${d.task_index ?? ''}`.trim(); + const node = atAddNode({ agentId, task, status: 'running' }); + if (node) { + _activeAgentNodeId = node.id; + } + return; + } + + if (d.event_type === 'subagent.complete' || d.event_type === 'subagent.done') { + // Find the running node for this subagent and finalize it. + // Use subagent_id to locate the right node. + let targetNodeId = null; + if (d.subagent_id) { + const agentId = d.subagent_id.replace(/-\d+$/, ''); + const found = Object.values(tree.nodes).find( + (n) => n.agentId === agentId && n.status === 'running' + ); + if (found) targetNodeId = found.id; + } + // Fall back to _activeAgentNodeId if no subagent_id match + if (!targetNodeId) targetNodeId = _activeAgentNodeId; + if (targetNodeId && tree.nodes[targetNodeId]) { + const status = d.status === 'timeout' || d.status === 'error' ? 'error' : 'done'; + atFinalizeNode(targetNodeId, status); + } + } + } + function _atTrackDone() { + if (!S.activityTree) return; + if (_activeAgentNodeId) { + atFinalizeNode(_activeAgentNodeId, "done"); + _activeAgentNodeId = null; + } + const root = S.activityTree.nodes[S.activityTree.rootId]; + if (root) { + root.status = "running"; + } + } +})(); +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiYWN0aXZpdHktdHJlZS50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gXHUyNTAwXHUyNTAwXHUyNTAwIEFnZW50IEFjdGl2aXR5IFRyZWUgKE1pc3Npb24gQ29udHJvbCkgXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXG4vLyBGdWxsLWRlcHRoIHBhcmFsbGVsIGFnZW50IGFjdGl2aXR5IHRyYWNraW5nIGZvciB0aGUgMy1UaWVyIHN5c3RlbS5cbi8vIFByb3ZpZGVzOiBpbml0QWN0aXZpdHlUcmVlLCBhZGROb2RlLCBhZGRUb29sQ2FsbCwgdXBkYXRlVG9vbENhbGwsXG4vLyAgICAgICAgICAgZmluYWxpemVOb2RlLCBnZXRTdGF0cywgY3JlYXRlTW9ja0FjdGl2aXR5VHJlZSwgZm9ybWF0RWxhcHNlZFxuXG4vLyBBZ2VudCBtZXRhZGF0YSBcdTIwMTQgbWFwcyBhZ2VudCBJRHMgdG8gZGlzcGxheSBpbmZvXG5jb25zdCBBR0VOVF9NRVRBOiBSZWNvcmQ8c3RyaW5nLCB7IGVtb2ppOiBzdHJpbmc7IG5hbWU6IHN0cmluZzsgdGllcjogMXwyfDMgfT4gPSB7XG4gIHJvc2U6ICAgICAgICAgICB7IGVtb2ppOiAnXHVEODNDXHVERjM5JywgbmFtZTogJ1Jvc2UnLCAgICAgICAgICAgdGllcjogMSB9LFxuICBsb3R1czogICAgICAgICAgeyBlbW9qaTogJ1x1RDgzRVx1REVCNycsIG5hbWU6ICdMb3R1cycsICAgICAgICAgIHRpZXI6IDIgfSxcbiAgJ2ZvcmdldC1tZS1ub3QnOnsgZW1vamk6ICdcdUQ4M0NcdURGM0MnLCBuYW1lOiAnRm9yZ2V0LW1lLW5vdCcsICB0aWVyOiAyIH0sXG4gIHN1bmZsb3dlcjogICAgICB7IGVtb2ppOiAnXHVEODNDXHVERjNCJywgbmFtZTogJ1N1bmZsb3dlcicsICAgICAgdGllcjogMiB9LFxuICBpcmlzOiAgICAgICAgICAgeyBlbW9qaTogJ1x1MjY5Q1x1RkUwRicsIG5hbWU6ICdJcmlzJywgICAgICAgICAgIHRpZXI6IDIgfSxcbiAgaXZ5OiAgICAgICAgICAgIHsgZW1vamk6ICdcdUQ4M0NcdURGM0YnLCBuYW1lOiAnSXZ5JywgICAgICAgICAgICB0aWVyOiAyIH0sXG4gIGRhbmRlbGlvbjogICAgICB7IGVtb2ppOiAnXHVEODNEXHVERUUxJywgbmFtZTogJ0RhbmRlbGlvbicsICAgICAgdGllcjogMiB9LFxuICByb290OiAgICAgICAgICAgeyBlbW9qaTogJ1x1RDgzQ1x1REYzMycsIG5hbWU6ICdSb290JywgICAgICAgICAgIHRpZXI6IDIgfSxcbn07XG5cbi8vIFRpZXItMyBzdWItYWdlbnQgZGV0ZWN0aW9uIFx1MjAxNCB0b29sIG5hbWVzIHRoYXQgaW5kaWNhdGUgc3ViLWFnZW50IHdvcmtcbmNvbnN0IFRJRVIzX1RPT0xfTkFNRVMgPSBuZXcgU2V0KFtcbiAgJ3NlYXJjaF9maWxlcycsICdyZWFkX2ZpbGUnLCAnd3JpdGVfZmlsZScsICd0ZXJtaW5hbCcsICdicm93c2VyX25hdmlnYXRlJyxcbiAgJ2Jyb3dzZXJfc25hcHNob3QnLCAnYnJvd3Nlcl9jbGljaycsICdicm93c2VyX3R5cGUnLCAnYnJvd3Nlcl9wcmVzcycsXG4gICd3ZWJfc2VhcmNoJywgJ2RlbGVnYXRlX3Rhc2snLCAnZXhlY3V0ZV9jb2RlJywgJ3BhdGNoJyxcbl0pO1xuXG5sZXQgX25vZGVDb3VudGVyID0gMDtcbmZ1bmN0aW9uIF9uZXh0Tm9kZUlkKGFnZW50SWQ6IHN0cmluZyk6IHN0cmluZyB7XG4gIHJldHVybiBgJHthZ2VudElkfS0keysrX25vZGVDb3VudGVyfWA7XG59XG5cbi8vIFx1MjUwMFx1MjUwMFx1MjUwMCBDb3JlIEZ1bmN0aW9ucyBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcdTI1MDBcblxuZnVuY3Rpb24gaW5pdEFjdGl2aXR5VHJlZSgpOiBBY3Rpdml0eVRyZWUge1xuICBjb25zdCB0cmVlOiBBY3Rpdml0eVRyZWUgPSB7XG4gICAgdmVyc2lvbjogMSxcbiAgICByb290SWQ6ICdyb3NlJyxcbiAgICBub2Rlczoge1xuICAgICAgcm9zZToge1xuICAgICAgICBpZDogJ3Jvc2UnLFxuICAgICAgICBwYXJlbnRJZDogbnVsbCxcbiAgICAgICAgYWdlbnRJZDogJ3Jvc2UnLFxuICAgICAgICBhZ2VudEVtb2ppOiAnXHVEODNDXHVERjM5JyxcbiAgICAgICAgYWdlbnROYW1lOiAnUm9zZScsXG4gICAgICAgIHRpZXI6IDEsXG4gICAgICAgIHN0YXR1czogJ3J1bm5pbmcnLFxuICAgICAgICB0YXNrOiAnT3JjaGVzdHJhdGluZycsXG4gICAgICAgIHRvb2xDYWxsczogW10sXG4gICAgICAgIHN0YXJ0ZWRBdDogRGF0ZS5ub3coKSxcbiAgICAgICAgZW5kZWRBdDogbnVsbCxcbiAgICAgICAgZHVyYXRpb246IG51bGwsXG4gICAgICAgIGNoaWxkcmVuOiBbXSxcbiAgICAgICAgY29sbGFwc2VkOiBmYWxzZSxcbiAgICAgICAgbWV0YWRhdGE6IHt9LFxuICAgICAgfVxuICAgIH0sXG4gICAgc3RhdHM6IF9lbXB0eVN0YXRzKCksXG4gIH07XG4gIFMuYWN0aXZpdHlUcmVlID0gdHJlZTtcbiAgUy5tY0ZpbHRlciA9IHt9O1xuICBTLm1jU29ydCA9ICdydW50aW1lJztcbiAgcmV0dXJuIHRyZWU7XG59XG5cbmZ1bmN0aW9uIF9lbXB0eVN0YXRzKCk6IE1DU3RhdHMge1xuICByZXR1cm4ge1xuICAgIHRvdGFsQWdlbnRzOiAwLFxuICAgIHJ1bm5pbmdBZ2VudHM6IDAsXG4gICAgcGVuZGluZ0FnZW50czogMCxcbiAgICBkb25lQWdlbnRzOiAwLFxuICAgIGVycm9yQWdlbnRzOiAwLFxuICAgIHRvdGFsVG9vbHM6IDAsXG4gICAgZG9uZVRvb2xzOiAwLFxuICAgIHJ1bm5pbmdUb29sczogMCxcbiAgICBhdmdSZXNwb25zZVRpbWU6IDAsXG4gICAgdG90YWxFbGFwc2VkOiAwLFxuICB9O1xufVxuXG4vLyBcdTI1MDBcdTI1MDBcdTI1MDAgTm9kZSBPcGVyYXRpb25zIFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFxuXG5mdW5jdGlvbiBhdEFkZE5vZGUob3B0czoge1xuICBwYXJlbnRJZD86IHN0cmluZztcbiAgYWdlbnRJZDogc3RyaW5nO1xuICB0YXNrOiBzdHJpbmc7XG4gIHN0YXR1cz86IEFjdGl2aXR5Tm9kZVsnc3RhdHVzJ107XG4gIHRpZXI/OiAxfDJ8Mztcbn0pOiBBY3Rpdml0eU5vZGUgfCBudWxsIHtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlO1xuICBpZiAoIXRyZWUpIHJldHVybiBudWxsO1xuXG4gIGNvbnN0IHBhcmVudElkID0gb3B0cy5wYXJlbnRJZCB8fCB0cmVlLnJvb3RJZDtcbiAgY29uc3QgcGFyZW50ID0gdHJlZS5ub2Rlc1twYXJlbnRJZF07XG4gIGlmICghcGFyZW50KSByZXR1cm4gbnVsbDtcblxuICBjb25zdCBtZXRhID0gQUdFTlRfTUVUQVtvcHRzLmFnZW50SWRdO1xuICBjb25zdCB0aWVyID0gb3B0cy50aWVyIHx8IChtZXRhID8gbWV0YS50aWVyIDogMyk7XG4gIGNvbnN0IGlkID0gX25leHROb2RlSWQob3B0cy5hZ2VudElkKTtcblxuICBjb25zdCBub2RlOiBBY3Rpdml0eU5vZGUgPSB7XG4gICAgaWQsXG4gICAgcGFyZW50SWQsXG4gICAgYWdlbnRJZDogb3B0cy5hZ2VudElkLFxuICAgIGFnZW50RW1vamk6IG1ldGE/LmVtb2ppIHx8ICdcdTI2OTlcdUZFMEYnLFxuICAgIGFnZW50TmFtZTogbWV0YT8ubmFtZSB8fCBvcHRzLmFnZW50SWQsXG4gICAgdGllcixcbiAgICBzdGF0dXM6IG9wdHMuc3RhdHVzIHx8ICdwZW5kaW5nJyxcbiAgICB0YXNrOiBvcHRzLnRhc2ssXG4gICAgdG9vbENhbGxzOiBbXSxcbiAgICBzdGFydGVkQXQ6IG9wdHMuc3RhdHVzID09PSAncnVubmluZycgPyBEYXRlLm5vdygpIDogbnVsbCxcbiAgICBlbmRlZEF0OiBudWxsLFxuICAgIGR1cmF0aW9uOiBudWxsLFxuICAgIGNoaWxkcmVuOiBbXSxcbiAgICBjb2xsYXBzZWQ6IGZhbHNlLFxuICAgIG1ldGFkYXRhOiB7fSxcbiAgfTtcblxuICB0cmVlLm5vZGVzW2lkXSA9IG5vZGU7XG4gIHBhcmVudC5jaGlsZHJlbi5wdXNoKGlkKTtcbiAgX3JlY2FsY1N0YXRzKHRyZWUpO1xuICByZXR1cm4gbm9kZTtcbn1cblxuZnVuY3Rpb24gYXRVcGRhdGVOb2RlKG5vZGVJZDogc3RyaW5nLCB1cGRhdGVzOiBQYXJ0aWFsPEFjdGl2aXR5Tm9kZT4pOiB2b2lkIHtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlO1xuICBpZiAoIXRyZWUpIHJldHVybjtcbiAgY29uc3Qgbm9kZSA9IHRyZWUubm9kZXNbbm9kZUlkXTtcbiAgaWYgKCFub2RlKSByZXR1cm47XG4gIE9iamVjdC5hc3NpZ24obm9kZSwgdXBkYXRlcyk7XG4gIF9yZWNhbGNTdGF0cyh0cmVlKTtcbn1cblxuZnVuY3Rpb24gYXRHZXROb2RlKG5vZGVJZDogc3RyaW5nKTogQWN0aXZpdHlOb2RlIHwgbnVsbCB7XG4gIHJldHVybiBTLmFjdGl2aXR5VHJlZT8ubm9kZXNbbm9kZUlkXSB8fCBudWxsO1xufVxuXG5mdW5jdGlvbiBhdEdldENoaWxkcmVuKG5vZGVJZDogc3RyaW5nKTogQWN0aXZpdHlOb2RlW10ge1xuICBjb25zdCB0cmVlID0gUy5hY3Rpdml0eVRyZWU7XG4gIGlmICghdHJlZSkgcmV0dXJuIFtdO1xuICBjb25zdCBub2RlID0gdHJlZS5ub2Rlc1tub2RlSWRdO1xuICBpZiAoIW5vZGUpIHJldHVybiBbXTtcbiAgcmV0dXJuIG5vZGUuY2hpbGRyZW4ubWFwKGlkID0+IHRyZWUubm9kZXNbaWRdKS5maWx0ZXIoQm9vbGVhbik7XG59XG5cbmZ1bmN0aW9uIGF0R2V0UnVubmluZ05vZGVzKCk6IEFjdGl2aXR5Tm9kZVtdIHtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlO1xuICBpZiAoIXRyZWUpIHJldHVybiBbXTtcbiAgcmV0dXJuIE9iamVjdC52YWx1ZXModHJlZS5ub2RlcykuZmlsdGVyKFxuICAgIG4gPT4gbi5zdGF0dXMgPT09ICdydW5uaW5nJyB8fCBuLnN0YXR1cyA9PT0gJ3RoaW5raW5nJ1xuICApO1xufVxuXG4vLyBcdTI1MDBcdTI1MDBcdTI1MDAgVG9vbCBDYWxsIE9wZXJhdGlvbnMgXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXG5cbmZ1bmN0aW9uIGF0QWRkVG9vbENhbGwobm9kZUlkOiBzdHJpbmcsIHRjOiB7XG4gIG5hbWU6IHN0cmluZztcbiAgYXJncz86IFJlY29yZDxzdHJpbmcsIGFueT47XG4gIHN0YXR1cz86IEFjdGl2aXR5VG9vbENhbGxbJ3N0YXR1cyddO1xufSk6IEFjdGl2aXR5VG9vbENhbGwgfCBudWxsIHtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlO1xuICBpZiAoIXRyZWUpIHJldHVybiBudWxsO1xuICBjb25zdCBub2RlID0gdHJlZS5ub2Rlc1tub2RlSWRdO1xuICBpZiAoIW5vZGUpIHJldHVybiBudWxsO1xuXG4gIC8vIElmIG5vZGUgd2FzIHBlbmRpbmcsIGl0J3Mgbm93IHJ1bm5pbmdcbiAgaWYgKG5vZGUuc3RhdHVzID09PSAncGVuZGluZycpIHtcbiAgICBub2RlLnN0YXR1cyA9ICdydW5uaW5nJztcbiAgICBub2RlLnN0YXJ0ZWRBdCA9IG5vZGUuc3RhcnRlZEF0IHx8IERhdGUubm93KCk7XG4gIH1cblxuICBjb25zdCB0b29sQ2FsbDogQWN0aXZpdHlUb29sQ2FsbCA9IHtcbiAgICBpZDogYHRjLSR7Kytfbm9kZUNvdW50ZXJ9YCxcbiAgICBuYW1lOiB0Yy5uYW1lLFxuICAgIHN0YXR1czogdGMuc3RhdHVzIHx8ICdydW5uaW5nJyxcbiAgICBhcmdzOiB0Yy5hcmdzIHx8IHt9LFxuICAgIHN0YXJ0ZWRBdDogRGF0ZS5ub3coKSxcbiAgICBlbmRlZEF0OiBudWxsLFxuICB9O1xuXG4gIG5vZGUudG9vbENhbGxzLnB1c2godG9vbENhbGwpO1xuICBfcmVjYWxjU3RhdHModHJlZSk7XG4gIHJldHVybiB0b29sQ2FsbDtcbn1cblxuZnVuY3Rpb24gYXRVcGRhdGVUb29sQ2FsbChub2RlSWQ6IHN0cmluZywgdG9vbElkOiBzdHJpbmcsIHVwZGF0ZXM6IFBhcnRpYWw8QWN0aXZpdHlUb29sQ2FsbD4pOiB2b2lkIHtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlO1xuICBpZiAoIXRyZWUpIHJldHVybjtcbiAgY29uc3Qgbm9kZSA9IHRyZWUubm9kZXNbbm9kZUlkXTtcbiAgaWYgKCFub2RlKSByZXR1cm47XG5cbiAgY29uc3QgdGMgPSBub2RlLnRvb2xDYWxscy5maW5kKHQgPT4gdC5pZCA9PT0gdG9vbElkKTtcbiAgaWYgKCF0YykgcmV0dXJuO1xuXG4gIE9iamVjdC5hc3NpZ24odGMsIHVwZGF0ZXMpO1xuICBpZiAodXBkYXRlcy5zdGF0dXMgPT09ICdkb25lJyB8fCB1cGRhdGVzLnN0YXR1cyA9PT0gJ2Vycm9yJykge1xuICAgIHRjLmVuZGVkQXQgPSB0Yy5lbmRlZEF0IHx8IERhdGUubm93KCk7XG4gICAgdGMuZHVyYXRpb24gPSB0Yy5zdGFydGVkQXQgPyAodGMuZW5kZWRBdCAtIHRjLnN0YXJ0ZWRBdCkgLyAxMDAwIDogbnVsbDtcbiAgfVxuICBfcmVjYWxjU3RhdHModHJlZSk7XG59XG5cbi8vIFx1MjUwMFx1MjUwMFx1MjUwMCBGaW5hbGl6YXRpb24gXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXG5cbmZ1bmN0aW9uIGF0RmluYWxpemVOb2RlKG5vZGVJZDogc3RyaW5nLCBzdGF0dXM6IEFjdGl2aXR5Tm9kZVsnc3RhdHVzJ10pOiB2b2lkIHtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlO1xuICBpZiAoIXRyZWUpIHJldHVybjtcbiAgY29uc3Qgbm9kZSA9IHRyZWUubm9kZXNbbm9kZUlkXTtcbiAgaWYgKCFub2RlKSByZXR1cm47XG5cbiAgbm9kZS5zdGF0dXMgPSBzdGF0dXM7XG4gIG5vZGUuZW5kZWRBdCA9IERhdGUubm93KCk7XG4gIG5vZGUuZHVyYXRpb24gPSBub2RlLnN0YXJ0ZWRBdCA/IChub2RlLmVuZGVkQXQgLSBub2RlLnN0YXJ0ZWRBdCkgLyAxMDAwIDogbnVsbDtcblxuICAvLyBGaW5hbGl6ZSBhbnkgc3RpbGwtcnVubmluZyB0b29sIGNhbGxzXG4gIGZvciAoY29uc3QgdGMgb2Ygbm9kZS50b29sQ2FsbHMpIHtcbiAgICBpZiAodGMuc3RhdHVzID09PSAncnVubmluZycgfHwgdGMuc3RhdHVzID09PSAncGVuZGluZycpIHtcbiAgICAgIHRjLnN0YXR1cyA9IHN0YXR1cyA9PT0gJ2Vycm9yJyA/ICdlcnJvcicgOiAnZG9uZSc7XG4gICAgICB0Yy5lbmRlZEF0ID0gbm9kZS5lbmRlZEF0O1xuICAgICAgdGMuZHVyYXRpb24gPSB0Yy5zdGFydGVkQXQgPyAodGMuZW5kZWRBdCAtIHRjLnN0YXJ0ZWRBdCkgLyAxMDAwIDogbnVsbDtcbiAgICB9XG4gIH1cblxuICAvLyBSZWN1cnNpdmVseSBmaW5hbGl6ZSBjaGlsZHJlbiB0aGF0IGFyZSBzdGlsbCBhY3RpdmVcbiAgZm9yIChjb25zdCBjaGlsZElkIG9mIG5vZGUuY2hpbGRyZW4pIHtcbiAgICBjb25zdCBjaGlsZCA9IHRyZWUubm9kZXNbY2hpbGRJZF07XG4gICAgaWYgKGNoaWxkICYmIChjaGlsZC5zdGF0dXMgPT09ICdydW5uaW5nJyB8fCBjaGlsZC5zdGF0dXMgPT09ICd0aGlua2luZycgfHwgY2hpbGQuc3RhdHVzID09PSAncGVuZGluZycpKSB7XG4gICAgICBhdEZpbmFsaXplTm9kZShjaGlsZElkLCBzdGF0dXMpO1xuICAgIH1cbiAgfVxuXG4gIF9yZWNhbGNTdGF0cyh0cmVlKTtcbn1cblxuLy8gXHUyNTAwXHUyNTAwXHUyNTAwIFN0YXRzIFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFxuXG5mdW5jdGlvbiBfcmVjYWxjU3RhdHModHJlZTogQWN0aXZpdHlUcmVlKTogdm9pZCB7XG4gIGNvbnN0IG5vZGVzID0gT2JqZWN0LnZhbHVlcyh0cmVlLm5vZGVzKS5maWx0ZXIobiA9PiBuLmlkICE9PSB0cmVlLnJvb3RJZCk7XG4gIGNvbnN0IHRvb2xzID0gbm9kZXMuZmxhdE1hcChuID0+IG4udG9vbENhbGxzKTtcblxuICBjb25zdCBkb25lRHVyYXRpb25zID0gbm9kZXNcbiAgICAuZmlsdGVyKG4gPT4gbi5kdXJhdGlvbiAhPT0gbnVsbClcbiAgICAubWFwKG4gPT4gbi5kdXJhdGlvbiEpO1xuXG4gIGNvbnN0IHJ1bm5pbmdOb2RlcyA9IG5vZGVzLmZpbHRlcihuID0+IG4uc3RhdHVzID09PSAncnVubmluZycgfHwgbi5zdGF0dXMgPT09ICd0aGlua2luZycpO1xuICBjb25zdCBtYXhFbGFwc2VkID0gcnVubmluZ05vZGVzLnJlZHVjZSgobWF4LCBuKSA9PiB7XG4gICAgY29uc3QgZWxhcHNlZCA9IG4uc3RhcnRlZEF0ID8gKERhdGUubm93KCkgLSBuLnN0YXJ0ZWRBdCkgLyAxMDAwIDogMDtcbiAgICByZXR1cm4gTWF0aC5tYXgobWF4LCBlbGFwc2VkKTtcbiAgfSwgMCk7XG5cbiAgdHJlZS5zdGF0cyA9IHtcbiAgICB0b3RhbEFnZW50czogbm9kZXMubGVuZ3RoLFxuICAgIHJ1bm5pbmdBZ2VudHM6IHJ1bm5pbmdOb2Rlcy5sZW5ndGgsXG4gICAgcGVuZGluZ0FnZW50czogbm9kZXMuZmlsdGVyKG4gPT4gbi5zdGF0dXMgPT09ICdwZW5kaW5nJykubGVuZ3RoLFxuICAgIGRvbmVBZ2VudHM6IG5vZGVzLmZpbHRlcihuID0+IG4uc3RhdHVzID09PSAnZG9uZScpLmxlbmd0aCxcbiAgICBlcnJvckFnZW50czogbm9kZXMuZmlsdGVyKG4gPT4gbi5zdGF0dXMgPT09ICdlcnJvcicpLmxlbmd0aCxcbiAgICB0b3RhbFRvb2xzOiB0b29scy5sZW5ndGgsXG4gICAgZG9uZVRvb2xzOiB0b29scy5maWx0ZXIodCA9PiB0LnN0YXR1cyA9PT0gJ2RvbmUnKS5sZW5ndGgsXG4gICAgcnVubmluZ1Rvb2xzOiB0b29scy5maWx0ZXIodCA9PiB0LnN0YXR1cyA9PT0gJ3J1bm5pbmcnKS5sZW5ndGgsXG4gICAgYXZnUmVzcG9uc2VUaW1lOiBkb25lRHVyYXRpb25zLmxlbmd0aFxuICAgICAgPyBkb25lRHVyYXRpb25zLnJlZHVjZSgoYSwgYikgPT4gYSArIGIsIDApIC8gZG9uZUR1cmF0aW9ucy5sZW5ndGhcbiAgICAgIDogMCxcbiAgICB0b3RhbEVsYXBzZWQ6IG1heEVsYXBzZWQsXG4gIH07XG59XG5cbmZ1bmN0aW9uIGF0R2V0U3RhdHMoKTogTUNTdGF0cyB7XG4gIHJldHVybiBTLmFjdGl2aXR5VHJlZT8uc3RhdHMgfHwgX2VtcHR5U3RhdHMoKTtcbn1cblxuLy8gXHUyNTAwXHUyNTAwXHUyNTAwIFJlc2V0IFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFxuXG5mdW5jdGlvbiBhdFJlc2V0KCk6IHZvaWQge1xuICBfbm9kZUNvdW50ZXIgPSAwO1xuICBpbml0QWN0aXZpdHlUcmVlKCk7XG59XG5cbi8vIFx1MjUwMFx1MjUwMFx1MjUwMCBGb3JtYXR0aW5nIFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFxuXG5mdW5jdGlvbiBmb3JtYXRFbGFwc2VkKG1zOiBudW1iZXIpOiBzdHJpbmcge1xuICBpZiAobXMgPCAxMDAwKSByZXR1cm4gYCR7TWF0aC5yb3VuZChtcyl9bXNgO1xuICBjb25zdCBzID0gTWF0aC5mbG9vcihtcyAvIDEwMDApO1xuICBpZiAocyA8IDYwKSByZXR1cm4gYCR7c31zYDtcbiAgY29uc3QgbSA9IE1hdGguZmxvb3IocyAvIDYwKTtcbiAgY29uc3QgcnMgPSBzICUgNjA7XG4gIGlmIChtIDwgNjApIHJldHVybiBgJHttfW0gJHtyc31zYDtcbiAgY29uc3QgaCA9IE1hdGguZmxvb3IobSAvIDYwKTtcbiAgY29uc3Qgcm0gPSBtICUgNjA7XG4gIHJldHVybiBgJHtofWggJHtybX1tYDtcbn1cblxuZnVuY3Rpb24gZm9ybWF0RHVyYXRpb24oc2Vjb25kczogbnVtYmVyIHwgbnVsbCk6IHN0cmluZyB7XG4gIGlmIChzZWNvbmRzID09PSBudWxsKSByZXR1cm4gJ1x1MjAxNCc7XG4gIGlmIChzZWNvbmRzIDwgMC4wMSkgcmV0dXJuICc8MC4wMXMnO1xuICBpZiAoc2Vjb25kcyA8IDEpIHJldHVybiBgJHtzZWNvbmRzLnRvRml4ZWQoMil9c2A7XG4gIGlmIChzZWNvbmRzIDwgNjApIHJldHVybiBgJHtzZWNvbmRzLnRvRml4ZWQoMSl9c2A7XG4gIGNvbnN0IG0gPSBNYXRoLmZsb29yKHNlY29uZHMgLyA2MCk7XG4gIGNvbnN0IHJzID0gTWF0aC5yb3VuZChzZWNvbmRzICUgNjApO1xuICByZXR1cm4gYCR7bX1tICR7cnN9c2A7XG59XG5cbi8vIFx1MjUwMFx1MjUwMFx1MjUwMCBNb2NrIERhdGEgXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXG5cbmZ1bmN0aW9uIGNyZWF0ZU1vY2tBY3Rpdml0eVRyZWUoKTogQWN0aXZpdHlUcmVlIHtcbiAgaW5pdEFjdGl2aXR5VHJlZSgpO1xuICBjb25zdCB0cmVlID0gUy5hY3Rpdml0eVRyZWUhO1xuICBjb25zdCBub3cgPSBEYXRlLm5vdygpO1xuXG4gIC8vIExvdHVzIFx1MjAxNCBydW5uaW5nIHdpdGggMiB0b29sIGNhbGxzXG4gIGNvbnN0IGxvdHVzID0gYXRBZGROb2RlKHsgYWdlbnRJZDogJ2xvdHVzJywgdGFzazogJ0FuYWx5c2llcmUgR2VzdW5kaGVpdHNkYXRlbicsIHN0YXR1czogJ3J1bm5pbmcnIH0pITtcbiAgbG90dXMuc3RhcnRlZEF0ID0gbm93IC0gMTIwMDA7XG4gIGF0QWRkVG9vbENhbGwobG90dXMuaWQsIHsgbmFtZTogJ3NlYXJjaF9maWxlcycsIGFyZ3M6IHsgcGF0dGVybjogJ2hlYWx0aConLCBwYXRoOiAnL2RhdGEnIH0gfSk7XG4gIGF0VXBkYXRlVG9vbENhbGwobG90dXMuaWQsIGxvdHVzLnRvb2xDYWxsc1swXS5pZCwgeyBzdGF0dXM6ICdkb25lJywgcmVzdWx0OiAnMTIgbWF0Y2hlcycsIGR1cmF0aW9uOiAwLjMgfSk7XG4gIGxvdHVzLnRvb2xDYWxsc1swXS5zdGFydGVkQXQgPSBub3cgLSAxMTAwMDtcbiAgbG90dXMudG9vbENhbGxzWzBdLmVuZGVkQXQgPSBub3cgLSAxMDk5NztcblxuICBhdEFkZFRvb2xDYWxsKGxvdHVzLmlkLCB7IG5hbWU6ICdyZWFkX2ZpbGUnLCBhcmdzOiB7IHBhdGg6ICcvZGF0YS9oZWFsdGgtbG9nLm1kJyB9IH0pO1xuXG4gIC8vIFN1bmZsb3dlciBcdTIwMTQgcnVubmluZyB3aXRoIDEgZG9uZSwgMSBydW5uaW5nIHRvb2xcbiAgY29uc3Qgc3VuZmxvd2VyID0gYXRBZGROb2RlKHsgYWdlbnRJZDogJ3N1bmZsb3dlcicsIHRhc2s6ICdQb3J0Zm9saW8tQW5hbHlzZSBRMSAyMDI2Jywgc3RhdHVzOiAncnVubmluZycgfSkhO1xuICBzdW5mbG93ZXIuc3RhcnRlZEF0ID0gbm93IC0gODAwMDtcbiAgYXRBZGRUb29sQ2FsbChzdW5mbG93ZXIuaWQsIHsgbmFtZTogJ2Jyb3dzZXJfbmF2aWdhdGUnLCBhcmdzOiB7IHVybDogJ2h0dHBzOi8vZmluYW5jZS5leGFtcGxlLmNvbScgfSB9KTtcbiAgYXRVcGRhdGVUb29sQ2FsbChzdW5mbG93ZXIuaWQsIHN1bmZsb3dlci50b29sQ2FsbHNbMF0uaWQsIHsgc3RhdHVzOiAnZG9uZScsIHJlc3VsdDogJ1BhZ2UgbG9hZGVkJywgZHVyYXRpb246IDIuMSB9KTtcbiAgc3VuZmxvd2VyLnRvb2xDYWxsc1swXS5zdGFydGVkQXQgPSBub3cgLSA3NTAwO1xuICBzdW5mbG93ZXIudG9vbENhbGxzWzBdLmVuZGVkQXQgPSBub3cgLSA1NDAwO1xuXG4gIGF0QWRkVG9vbENhbGwoc3VuZmxvd2VyLmlkLCB7IG5hbWU6ICd0ZXJtaW5hbCcsIGFyZ3M6IHsgY29tbWFuZDogJ3B5dGhvbjMgYW5hbHlzZS5weScgfSB9KTtcblxuICAvLyBEYW5kZWxpb24gXHUyMDE0IHBlbmRpbmdcbiAgY29uc3QgZGFuZGVsaW9uID0gYXRBZGROb2RlKHsgYWdlbnRJZDogJ2RhbmRlbGlvbicsIHRhc2s6ICdUcmlhZ2luZyB1bnJlYWQgbWVzc2FnZXMnLCBzdGF0dXM6ICdwZW5kaW5nJyB9KSE7XG5cbiAgLy8gQWRkIGEgdGllci0zIHN1Yi1hZ2VudCB1bmRlciBMb3R1c1xuICBjb25zdCByZXNlYXJjaGVyID0gYXRBZGROb2RlKHsgcGFyZW50SWQ6IGxvdHVzLmlkLCBhZ2VudElkOiAncmVzZWFyY2hlcicsIHRhc2s6ICdMb29raW5nIHVwIG51dHJpdGlvbiBkYXRhJywgc3RhdHVzOiAncnVubmluZycsIHRpZXI6IDMgfSkhO1xuICByZXNlYXJjaGVyLmFnZW50RW1vamkgPSAnXHVEODNEXHVERDBEJztcbiAgcmVzZWFyY2hlci5hZ2VudE5hbWUgPSAnUmVzZWFyY2hlcic7XG4gIHJlc2VhcmNoZXIuc3RhcnRlZEF0ID0gbm93IC0gMzAwMDtcblxuICBhdEFkZFRvb2xDYWxsKHJlc2VhcmNoZXIuaWQsIHsgbmFtZTogJ3dlYl9zZWFyY2gnLCBhcmdzOiB7IHF1ZXJ5OiAnbnV0cml0aW9uIGRhdGFiYXNlIEFQSScgfSB9KTtcblxuICBfcmVjYWxjU3RhdHModHJlZSk7XG4gIHJldHVybiB0cmVlO1xufVxuXG4vLyBcdTI1MDBcdTI1MDBcdTI1MDAgTWFrZSBjb3JlIGZ1bmN0aW9ucyBnbG9iYWwgKElJRkUtc2FmZSkgXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXHUyNTAwXG53aW5kb3cuaW5pdEFjdGl2aXR5VHJlZSA9IGluaXRBY3Rpdml0eVRyZWU7XG53aW5kb3cuY3JlYXRlTW9ja0FjdGl2aXR5VHJlZSA9IGNyZWF0ZU1vY2tBY3Rpdml0eVRyZWU7XG53aW5kb3cuZm9ybWF0RWxhcHNlZCA9IGZvcm1hdEVsYXBzZWQ7XG53aW5kb3cuZm9ybWF0RHVyYXRpb24gPSBmb3JtYXREdXJhdGlvbjtcbndpbmRvdy5hdEFkZE5vZGUgPSBhdEFkZE5vZGU7XG53aW5kb3cuYXRVcGRhdGVOb2RlID0gYXRVcGRhdGVOb2RlO1xud2luZG93LmF0R2V0Tm9kZSA9IGF0R2V0Tm9kZTtcbndpbmRvdy5hdEFkZFRvb2xDYWxsID0gYXRBZGRUb29sQ2FsbDtcbndpbmRvdy5hdFVwZGF0ZVRvb2xDYWxsID0gYXRVcGRhdGVUb29sQ2FsbDtcbndpbmRvdy5hdEZpbmFsaXplTm9kZSA9IGF0RmluYWxpemVOb2RlO1xud2luZG93LmF0R2V0U3RhdHMgPSBhdEdldFN0YXRzO1xud2luZG93LmF0UmVzZXQgPSBhdFJlc2V0O1xud2luZG93Ll9hdFRyYWNrVG9vbCA9IF9hdFRyYWNrVG9vbDtcbndpbmRvdy5fYXRUcmFja1Rvb2xDb21wbGV0ZSA9IF9hdFRyYWNrVG9vbENvbXBsZXRlO1xud2luZG93Ll9hdFRyYWNrRG9uZSA9IF9hdFRyYWNrRG9uZTtcblxuLy8gXHUyNTAwXHUyNTAwXHUyNTAwIEV4cG9ydCBmb3IgY29uc29sZSB0ZXN0aW5nIFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFxuKHdpbmRvdyBhcyBhbnkpLl9hdCA9IHtcbiAgaW5pdDogaW5pdEFjdGl2aXR5VHJlZSxcbiAgYWRkTm9kZTogYXRBZGROb2RlLFxuICB1cGRhdGVOb2RlOiBhdFVwZGF0ZU5vZGUsXG4gIGdldE5vZGU6IGF0R2V0Tm9kZSxcbiAgYWRkVG9vbENhbGw6IGF0QWRkVG9vbENhbGwsXG4gIHVwZGF0ZVRvb2xDYWxsOiBhdFVwZGF0ZVRvb2xDYWxsLFxuICBmaW5hbGl6ZTogYXRGaW5hbGl6ZU5vZGUsXG4gIHN0YXRzOiBhdEdldFN0YXRzLFxuICByZXNldDogYXRSZXNldCxcbiAgbW9jazogY3JlYXRlTW9ja0FjdGl2aXR5VHJlZSxcbiAgZm9ybWF0RWxhcHNlZCxcbiAgZm9ybWF0RHVyYXRpb24sXG4gIEFHRU5UX01FVEEsXG59O1xuXG4vLyBcdTI1MDBcdTI1MDBcdTI1MDAgU1NFIEV2ZW50IEJyaWRnZSAoY2FsbGVkIGZyb20gbWVzc2FnZXMudHMpIFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFx1MjUwMFxuLy8gVHJhY2tzIHdoaWNoIGFnZW50IGlzIGN1cnJlbnRseSBhY3RpdmUgYmFzZWQgb24gdG9vbCBjYWxscy5cbi8vIFdoZW4gZGVsZWdhdGVfdGFzayBpcyBkZXRlY3RlZCwgY3JlYXRlcyBhIG5ldyBhZ2VudCBub2RlLlxuLy8gT3RoZXJ3aXNlLCBhdHRhY2hlcyB0b29sIGNhbGxzIHRvIHRoZSBjdXJyZW50IGFjdGl2ZSBhZ2VudCBub2RlLlxuXG5sZXQgX2FjdGl2ZUFnZW50Tm9kZUlkOiBzdHJpbmcgfCBudWxsID0gbnVsbDsgIC8vIEN1cnJlbnQgYWdlbnQgbm9kZSByZWNlaXZpbmcgdG9vbCBjYWxsc1xubGV0IF9sYXN0VG9vbElkOiBzdHJpbmcgfCBudWxsID0gbnVsbDsgICAgICAgICAgLy8gTGFzdCB0b29sIGNhbGwgSUQgKGZvciBjb21wbGV0ZSBtYXRjaGluZylcblxuZnVuY3Rpb24gX2F0VHJhY2tUb29sKGQ6IGFueSk6IHZvaWQge1xuICBpZiAoIVMuYWN0aXZpdHlUcmVlKSBpbml0QWN0aXZpdHlUcmVlKCk7XG4gIGNvbnN0IHRyZWUgPSBTLmFjdGl2aXR5VHJlZSE7XG5cbiAgLy8gRGV0ZWN0IGRlbGVnYXRpb24gXHUyMDE0IHdoZW4gdG9vbCBuYW1lIGlzICdkZWxlZ2F0ZV90YXNrJ1xuICBpZiAoZC5uYW1lID09PSAnZGVsZWdhdGVfdGFzaycpIHtcbiAgICBjb25zdCBhZ2VudElkID0gZC5hcmdzPy5hZ2VudElkIHx8IGQuYXJncz8uYWdlbnQgfHwgZC5hcmdzPy5uYW1lIHx8ICd1bmtub3duJztcbiAgICBjb25zdCB0YXNrID0gZC5hcmdzPy5nb2FsIHx8IGQuYXJncz8ucHJvbXB0IHx8IGQuYXJncz8udGFzayB8fCBkLnByZXZpZXcgfHwgJ0RlbGVnYXRlZCB0YXNrJztcbiAgICBjb25zdCBub2RlID0gYXRBZGROb2RlKHsgYWdlbnRJZCwgdGFzaywgc3RhdHVzOiAncnVubmluZycgfSk7XG4gICAgaWYgKG5vZGUpIHtcbiAgICAgIF9hY3RpdmVBZ2VudE5vZGVJZCA9IG5vZGUuaWQ7XG4gICAgfVxuICAgIHJldHVybjtcbiAgfVxuXG4gIC8vIERldGVybWluZSB3aGljaCBub2RlIHRvIGF0dGFjaCB0aGUgdG9vbCBjYWxsIHRvXG4gIGxldCB0YXJnZXROb2RlSWQgPSBfYWN0aXZlQWdlbnROb2RlSWQgfHwgdHJlZS5yb290SWQ7XG5cbiAgLy8gQ2hlY2sgaWYgdGhpcyB0b29sIGlzIGZyb20gYSBzcGVjaWZpYyBhZ2VudCAoZnJvbSBpbmZsaWdodCBtZXRhZGF0YSlcbiAgY29uc3QgaW5mbGlnaHQgPSAod2luZG93IGFzIGFueSkuSU5GTElHSFQ/LltTLnNlc3Npb24/LnNlc3Npb25faWRdO1xuICBpZiAoaW5mbGlnaHQ/LnRoaXNUdXJuQWdlbnQpIHtcbiAgICBjb25zdCBhZ2VudEtleSA9IGluZmxpZ2h0LnRoaXNUdXJuQWdlbnQ7XG4gICAgLy8gRmluZCBvciBjcmVhdGUgbm9kZSBmb3IgdGhpcyBhZ2VudFxuICAgIGNvbnN0IGV4aXN0aW5nTm9kZSA9IE9iamVjdC52YWx1ZXModHJlZS5ub2RlcykuZmluZChcbiAgICAgIChuOiBBY3Rpdml0eU5vZGUpID0+IG4uYWdlbnRJZCA9PT0gYWdlbnRLZXkgJiYgKG4uc3RhdHVzID09PSAncnVubmluZycgfHwgbi5zdGF0dXMgPT09ICd0aGlua2luZycpXG4gICAgKTtcbiAgICBpZiAoZXhpc3RpbmdOb2RlKSB7XG4gICAgICB0YXJnZXROb2RlSWQgPSBleGlzdGluZ05vZGUuaWQ7XG4gICAgICBfYWN0aXZlQWdlbnROb2RlSWQgPSBleGlzdGluZ05vZGUuaWQ7XG4gICAgfSBlbHNlIGlmIChBR0VOVF9NRVRBW2FnZW50S2V5XSkge1xuICAgICAgLy8gTmV3IGFnZW50IGFwcGVhcmVkIFx1MjAxNCBjcmVhdGUgbm9kZVxuICAgICAgY29uc3QgdGFzayA9IGluZmxpZ2h0LnRoaXNUdXJuTW9kZWwgPyBgJHtpbmZsaWdodC50aGlzVHVybk1vZGVsfWAgOiAnV29ya2luZy4uLic7XG4gICAgICBjb25zdCBub2RlID0gYXRBZGROb2RlKHsgYWdlbnRJZDogYWdlbnRLZXksIHRhc2ssIHN0YXR1czogJ3J1bm5pbmcnIH0pO1xuICAgICAgaWYgKG5vZGUpIHtcbiAgICAgICAgX2FjdGl2ZUFnZW50Tm9kZUlkID0gbm9kZS5pZDtcbiAgICAgICAgdGFyZ2V0Tm9kZUlkID0gbm9kZS5pZDtcbiAgICAgIH1cbiAgICB9XG4gIH1cblxuICBjb25zdCB0YyA9IGF0QWRkVG9vbENhbGwodGFyZ2V0Tm9kZUlkLCB7XG4gICAgbmFtZTogZC5uYW1lLFxuICAgIGFyZ3M6IGQuYXJncyB8fCB7fSxcbiAgICBzdGF0dXM6ICdydW5uaW5nJyxcbiAgfSk7XG4gIGlmICh0Yykge1xuICAgIF9sYXN0VG9vbElkID0gdGMuaWQ7XG4gIH1cbn1cblxuZnVuY3Rpb24gX2F0VHJhY2tUb29sQ29tcGxldGUoZDogYW55KTogdm9pZCB7XG4gIGlmICghUy5hY3Rpdml0eVRyZWUpIHJldHVybjtcbiAgY29uc3QgdHJlZSA9IFMuYWN0aXZpdHlUcmVlITtcblxuICAvLyBUcnkgdG8gZmluZCB0aGUgbWF0Y2hpbmcgcnVubmluZyB0b29sIGNhbGwgYWNyb3NzIGFsbCBhY3RpdmUgbm9kZXNcbiAgY29uc3QgYWN0aXZlTm9kZXMgPSBPYmplY3QudmFsdWVzKHRyZWUubm9kZXMpLmZpbHRlcihcbiAgICAobjogQWN0aXZpdHlOb2RlKSA9PiBuLnN0YXR1cyA9PT0gJ3J1bm5pbmcnIHx8IG4uc3RhdHVzID09PSAndGhpbmtpbmcnXG4gICk7XG5cbiAgZm9yIChjb25zdCBub2RlIG9mIGFjdGl2ZU5vZGVzKSB7XG4gICAgZm9yIChjb25zdCB0YyBvZiBub2RlLnRvb2xDYWxscykge1xuICAgICAgaWYgKHRjLnN0YXR1cyA9PT0gJ3J1bm5pbmcnICYmIHRjLm5hbWUgPT09IGQubmFtZSkge1xuICAgICAgICBhdFVwZGF0ZVRvb2xDYWxsKG5vZGUuaWQsIHRjLmlkLCB7XG4gICAgICAgICAgc3RhdHVzOiBkLmlzX2Vycm9yID8gJ2Vycm9yJyA6ICdkb25lJyxcbiAgICAgICAgICByZXN1bHQ6IGQucHJldmlldyB8fCBkLnJlc3VsdCxcbiAgICAgICAgICBkdXJhdGlvbjogZC5kdXJhdGlvbixcbiAgICAgICAgfSk7XG4gICAgICAgIHJldHVybjtcbiAgICAgIH1cbiAgICB9XG4gIH1cbn1cblxuZnVuY3Rpb24gX2F0VHJhY2tEb25lKCk6IHZvaWQge1xuICBpZiAoIVMuYWN0aXZpdHlUcmVlKSByZXR1cm47XG5cbiAgLy8gRmluYWxpemUgdGhlIGFjdGl2ZSBhZ2VudCBub2RlXG4gIGlmIChfYWN0aXZlQWdlbnROb2RlSWQpIHtcbiAgICBhdEZpbmFsaXplTm9kZShfYWN0aXZlQWdlbnROb2RlSWQsICdkb25lJyk7XG4gICAgX2FjdGl2ZUFnZW50Tm9kZUlkID0gbnVsbDtcbiAgfVxuXG4gIC8vIFJlc2V0IHJvb3Qgc3RhdHVzXG4gIGNvbnN0IHJvb3QgPSBTLmFjdGl2aXR5VHJlZS5ub2Rlc1tTLmFjdGl2aXR5VHJlZS5yb290SWRdO1xuICBpZiAocm9vdCkge1xuICAgIHJvb3Quc3RhdHVzID0gJ3J1bm5pbmcnO1xuICB9XG59XG5cbmZ1bmN0aW9uIF9hdFRyYWNrTmV3U2Vzc2lvbigpOiB2b2lkIHtcbiAgX2FjdGl2ZUFnZW50Tm9kZUlkID0gbnVsbDtcbiAgX2xhc3RUb29sSWQgPSBudWxsO1xuICBpZiAoUy5hY3Rpdml0eVRyZWUpIHtcbiAgICBhdFJlc2V0KCk7XG4gIH1cbn1cbiJdLAogICJtYXBwaW5ncyI6ICI7QUFNQSxRQUFNLGFBQTJFO0FBQUEsSUFDL0UsTUFBZ0IsRUFBRSxPQUFPLGFBQU0sTUFBTSxRQUFrQixNQUFNLEVBQUU7QUFBQSxJQUMvRCxPQUFnQixFQUFFLE9BQU8sYUFBTSxNQUFNLFNBQWtCLE1BQU0sRUFBRTtBQUFBLElBQy9ELGlCQUFnQixFQUFFLE9BQU8sYUFBTSxNQUFNLGlCQUFrQixNQUFNLEVBQUU7QUFBQSxJQUMvRCxXQUFnQixFQUFFLE9BQU8sYUFBTSxNQUFNLGFBQWtCLE1BQU0sRUFBRTtBQUFBLElBQy9ELE1BQWdCLEVBQUUsT0FBTyxnQkFBTSxNQUFNLFFBQWtCLE1BQU0sRUFBRTtBQUFBLElBQy9ELEtBQWdCLEVBQUUsT0FBTyxhQUFNLE1BQU0sT0FBa0IsTUFBTSxFQUFFO0FBQUEsSUFDL0QsV0FBZ0IsRUFBRSxPQUFPLGFBQU0sTUFBTSxhQUFrQixNQUFNLEVBQUU7QUFBQSxJQUMvRCxNQUFnQixFQUFFLE9BQU8sYUFBTSxNQUFNLFFBQWtCLE1BQU0sRUFBRTtBQUFBLEVBQ2pFO0FBU0EsTUFBSSxlQUFlO0FBQ25CLFdBQVMsWUFBWSxTQUF5QjtBQUM1QyxXQUFPLEdBQUcsT0FBTyxJQUFJLEVBQUUsWUFBWTtBQUFBLEVBQ3JDO0FBSUEsV0FBUyxtQkFBaUM7QUFDeEMsVUFBTSxPQUFxQjtBQUFBLE1BQ3pCLFNBQVM7QUFBQSxNQUNULFFBQVE7QUFBQSxNQUNSLE9BQU87QUFBQSxRQUNMLE1BQU07QUFBQSxVQUNKLElBQUk7QUFBQSxVQUNKLFVBQVU7QUFBQSxVQUNWLFNBQVM7QUFBQSxVQUNULFlBQVk7QUFBQSxVQUNaLFdBQVc7QUFBQSxVQUNYLE1BQU07QUFBQSxVQUNOLFFBQVE7QUFBQSxVQUNSLE1BQU07QUFBQSxVQUNOLFdBQVcsQ0FBQztBQUFBLFVBQ1osV0FBVyxLQUFLLElBQUk7QUFBQSxVQUNwQixTQUFTO0FBQUEsVUFDVCxVQUFVO0FBQUEsVUFDVixVQUFVLENBQUM7QUFBQSxVQUNYLFdBQVc7QUFBQSxVQUNYLFVBQVUsQ0FBQztBQUFBLFFBQ2I7QUFBQSxNQUNGO0FBQUEsTUFDQSxPQUFPLFlBQVk7QUFBQSxJQUNyQjtBQUNBLE1BQUUsZUFBZTtBQUNqQixNQUFFLFdBQVcsQ0FBQztBQUNkLE1BQUUsU0FBUztBQUNYLFdBQU87QUFBQSxFQUNUO0FBRUEsV0FBUyxjQUF1QjtBQUM5QixXQUFPO0FBQUEsTUFDTCxhQUFhO0FBQUEsTUFDYixlQUFlO0FBQUEsTUFDZixlQUFlO0FBQUEsTUFDZixZQUFZO0FBQUEsTUFDWixhQUFhO0FBQUEsTUFDYixZQUFZO0FBQUEsTUFDWixXQUFXO0FBQUEsTUFDWCxjQUFjO0FBQUEsTUFDZCxpQkFBaUI7QUFBQSxNQUNqQixjQUFjO0FBQUEsSUFDaEI7QUFBQSxFQUNGO0FBSUEsV0FBUyxVQUFVLE1BTUs7QUFDdEIsVUFBTSxPQUFPLEVBQUU7QUFDZixRQUFJLENBQUMsS0FBTSxRQUFPO0FBRWxCLFVBQU0sV0FBVyxLQUFLLFlBQVksS0FBSztBQUN2QyxVQUFNLFNBQVMsS0FBSyxNQUFNLFFBQVE7QUFDbEMsUUFBSSxDQUFDLE9BQVEsUUFBTztBQUVwQixVQUFNLE9BQU8sV0FBVyxLQUFLLE9BQU87QUFDcEMsVUFBTSxPQUFPLEtBQUssU0FBUyxPQUFPLEtBQUssT0FBTztBQUM5QyxVQUFNLEtBQUssWUFBWSxLQUFLLE9BQU87QUFFbkMsVUFBTSxPQUFxQjtBQUFBLE1BQ3pCO0FBQUEsTUFDQTtBQUFBLE1BQ0EsU0FBUyxLQUFLO0FBQUEsTUFDZCxZQUFZLE1BQU0sU0FBUztBQUFBLE1BQzNCLFdBQVcsTUFBTSxRQUFRLEtBQUs7QUFBQSxNQUM5QjtBQUFBLE1BQ0EsUUFBUSxLQUFLLFVBQVU7QUFBQSxNQUN2QixNQUFNLEtBQUs7QUFBQSxNQUNYLFdBQVcsQ0FBQztBQUFBLE1BQ1osV0FBVyxLQUFLLFdBQVcsWUFBWSxLQUFLLElBQUksSUFBSTtBQUFBLE1BQ3BELFNBQVM7QUFBQSxNQUNULFVBQVU7QUFBQSxNQUNWLFVBQVUsQ0FBQztBQUFBLE1BQ1gsV0FBVztBQUFBLE1BQ1gsVUFBVSxDQUFDO0FBQUEsSUFDYjtBQUVBLFNBQUssTUFBTSxFQUFFLElBQUk7QUFDakIsV0FBTyxTQUFTLEtBQUssRUFBRTtBQUN2QixpQkFBYSxJQUFJO0FBQ2pCLFdBQU87QUFBQSxFQUNUO0FBRUEsV0FBUyxhQUFhLFFBQWdCLFNBQXNDO0FBQzFFLFVBQU0sT0FBTyxFQUFFO0FBQ2YsUUFBSSxDQUFDLEtBQU07QUFDWCxVQUFNLE9BQU8sS0FBSyxNQUFNLE1BQU07QUFDOUIsUUFBSSxDQUFDLEtBQU07QUFDWCxXQUFPLE9BQU8sTUFBTSxPQUFPO0FBQzNCLGlCQUFhLElBQUk7QUFBQSxFQUNuQjtBQUVBLFdBQVMsVUFBVSxRQUFxQztBQUN0RCxXQUFPLEVBQUUsY0FBYyxNQUFNLE1BQU0sS0FBSztBQUFBLEVBQzFDO0FBb0JBLFdBQVMsY0FBYyxRQUFnQixJQUlYO0FBQzFCLFVBQU0sT0FBTyxFQUFFO0FBQ2YsUUFBSSxDQUFDLEtBQU0sUUFBTztBQUNsQixVQUFNLE9BQU8sS0FBSyxNQUFNLE1BQU07QUFDOUIsUUFBSSxDQUFDLEtBQU0sUUFBTztBQUdsQixRQUFJLEtBQUssV0FBVyxXQUFXO0FBQzdCLFdBQUssU0FBUztBQUNkLFdBQUssWUFBWSxLQUFLLGFBQWEsS0FBSyxJQUFJO0FBQUEsSUFDOUM7QUFFQSxVQUFNLFdBQTZCO0FBQUEsTUFDakMsSUFBSSxNQUFNLEVBQUUsWUFBWTtBQUFBLE1BQ3hCLE1BQU0sR0FBRztBQUFBLE1BQ1QsUUFBUSxHQUFHLFVBQVU7QUFBQSxNQUNyQixNQUFNLEdBQUcsUUFBUSxDQUFDO0FBQUEsTUFDbEIsV0FBVyxLQUFLLElBQUk7QUFBQSxNQUNwQixTQUFTO0FBQUEsSUFDWDtBQUVBLFNBQUssVUFBVSxLQUFLLFFBQVE7QUFDNUIsaUJBQWEsSUFBSTtBQUNqQixXQUFPO0FBQUEsRUFDVDtBQUVBLFdBQVMsaUJBQWlCLFFBQWdCLFFBQWdCLFNBQTBDO0FBQ2xHLFVBQU0sT0FBTyxFQUFFO0FBQ2YsUUFBSSxDQUFDLEtBQU07QUFDWCxVQUFNLE9BQU8sS0FBSyxNQUFNLE1BQU07QUFDOUIsUUFBSSxDQUFDLEtBQU07QUFFWCxVQUFNLEtBQUssS0FBSyxVQUFVLEtBQUssT0FBSyxFQUFFLE9BQU8sTUFBTTtBQUNuRCxRQUFJLENBQUMsR0FBSTtBQUVULFdBQU8sT0FBTyxJQUFJLE9BQU87QUFDekIsUUFBSSxRQUFRLFdBQVcsVUFBVSxRQUFRLFdBQVcsU0FBUztBQUMzRCxTQUFHLFVBQVUsR0FBRyxXQUFXLEtBQUssSUFBSTtBQUNwQyxTQUFHLFdBQVcsR0FBRyxhQUFhLEdBQUcsVUFBVSxHQUFHLGFBQWEsTUFBTztBQUFBLElBQ3BFO0FBQ0EsaUJBQWEsSUFBSTtBQUFBLEVBQ25CO0FBSUEsV0FBUyxlQUFlLFFBQWdCLFFBQXNDO0FBQzVFLFVBQU0sT0FBTyxFQUFFO0FBQ2YsUUFBSSxDQUFDLEtBQU07QUFDWCxVQUFNLE9BQU8sS0FBSyxNQUFNLE1BQU07QUFDOUIsUUFBSSxDQUFDLEtBQU07QUFFWCxTQUFLLFNBQVM7QUFDZCxTQUFLLFVBQVUsS0FBSyxJQUFJO0FBQ3hCLFNBQUssV0FBVyxLQUFLLGFBQWEsS0FBSyxVQUFVLEtBQUssYUFBYSxNQUFPO0FBRzFFLGVBQVcsTUFBTSxLQUFLLFdBQVc7QUFDL0IsVUFBSSxHQUFHLFdBQVcsYUFBYSxHQUFHLFdBQVcsV0FBVztBQUN0RCxXQUFHLFNBQVMsV0FBVyxVQUFVLFVBQVU7QUFDM0MsV0FBRyxVQUFVLEtBQUs7QUFDbEIsV0FBRyxXQUFXLEdBQUcsYUFBYSxHQUFHLFVBQVUsR0FBRyxhQUFhLE1BQU87QUFBQSxNQUNwRTtBQUFBLElBQ0Y7QUFHQSxlQUFXLFdBQVcsS0FBSyxVQUFVO0FBQ25DLFlBQU0sUUFBUSxLQUFLLE1BQU0sT0FBTztBQUNoQyxVQUFJLFVBQVUsTUFBTSxXQUFXLGFBQWEsTUFBTSxXQUFXLGNBQWMsTUFBTSxXQUFXLFlBQVk7QUFDdEcsdUJBQWUsU0FBUyxNQUFNO0FBQUEsTUFDaEM7QUFBQSxJQUNGO0FBRUEsaUJBQWEsSUFBSTtBQUFBLEVBQ25CO0FBSUEsV0FBUyxhQUFhLE1BQTBCO0FBQzlDLFVBQU0sUUFBUSxPQUFPLE9BQU8sS0FBSyxLQUFLLEVBQUUsT0FBTyxPQUFLLEVBQUUsT0FBTyxLQUFLLE1BQU07QUFDeEUsVUFBTSxRQUFRLE1BQU0sUUFBUSxPQUFLLEVBQUUsU0FBUztBQUU1QyxVQUFNLGdCQUFnQixNQUNuQixPQUFPLE9BQUssRUFBRSxhQUFhLElBQUksRUFDL0IsSUFBSSxPQUFLLEVBQUUsUUFBUztBQUV2QixVQUFNLGVBQWUsTUFBTSxPQUFPLE9BQUssRUFBRSxXQUFXLGFBQWEsRUFBRSxXQUFXLFVBQVU7QUFDeEYsVUFBTSxhQUFhLGFBQWEsT0FBTyxDQUFDLEtBQUssTUFBTTtBQUNqRCxZQUFNLFVBQVUsRUFBRSxhQUFhLEtBQUssSUFBSSxJQUFJLEVBQUUsYUFBYSxNQUFPO0FBQ2xFLGFBQU8sS0FBSyxJQUFJLEtBQUssT0FBTztBQUFBLElBQzlCLEdBQUcsQ0FBQztBQUVKLFNBQUssUUFBUTtBQUFBLE1BQ1gsYUFBYSxNQUFNO0FBQUEsTUFDbkIsZUFBZSxhQUFhO0FBQUEsTUFDNUIsZUFBZSxNQUFNLE9BQU8sT0FBSyxFQUFFLFdBQVcsU0FBUyxFQUFFO0FBQUEsTUFDekQsWUFBWSxNQUFNLE9BQU8sT0FBSyxFQUFFLFdBQVcsTUFBTSxFQUFFO0FBQUEsTUFDbkQsYUFBYSxNQUFNLE9BQU8sT0FBSyxFQUFFLFdBQVcsT0FBTyxFQUFFO0FBQUEsTUFDckQsWUFBWSxNQUFNO0FBQUEsTUFDbEIsV0FBVyxNQUFNLE9BQU8sT0FBSyxFQUFFLFdBQVcsTUFBTSxFQUFFO0FBQUEsTUFDbEQsY0FBYyxNQUFNLE9BQU8sT0FBSyxFQUFFLFdBQVcsU0FBUyxFQUFFO0FBQUEsTUFDeEQsaUJBQWlCLGNBQWMsU0FDM0IsY0FBYyxPQUFPLENBQUMsR0FBRyxNQUFNLElBQUksR0FBRyxDQUFDLElBQUksY0FBYyxTQUN6RDtBQUFBLE1BQ0osY0FBYztBQUFBLElBQ2hCO0FBQUEsRUFDRjtBQUVBLFdBQVMsYUFBc0I7QUFDN0IsV0FBTyxFQUFFLGNBQWMsU0FBUyxZQUFZO0FBQUEsRUFDOUM7QUFJQSxXQUFTLFVBQWdCO0FBQ3ZCLG1CQUFlO0FBQ2YscUJBQWlCO0FBQUEsRUFDbkI7QUFJQSxXQUFTLGNBQWMsSUFBb0I7QUFDekMsUUFBSSxLQUFLLElBQU0sUUFBTyxHQUFHLEtBQUssTUFBTSxFQUFFLENBQUM7QUFDdkMsVUFBTSxJQUFJLEtBQUssTUFBTSxLQUFLLEdBQUk7QUFDOUIsUUFBSSxJQUFJLEdBQUksUUFBTyxHQUFHLENBQUM7QUFDdkIsVUFBTSxJQUFJLEtBQUssTUFBTSxJQUFJLEVBQUU7QUFDM0IsVUFBTSxLQUFLLElBQUk7QUFDZixRQUFJLElBQUksR0FBSSxRQUFPLEdBQUcsQ0FBQyxLQUFLLEVBQUU7QUFDOUIsVUFBTSxJQUFJLEtBQUssTUFBTSxJQUFJLEVBQUU7QUFDM0IsVUFBTSxLQUFLLElBQUk7QUFDZixXQUFPLEdBQUcsQ0FBQyxLQUFLLEVBQUU7QUFBQSxFQUNwQjtBQUVBLFdBQVMsZUFBZSxTQUFnQztBQUN0RCxRQUFJLFlBQVksS0FBTSxRQUFPO0FBQzdCLFFBQUksVUFBVSxLQUFNLFFBQU87QUFDM0IsUUFBSSxVQUFVLEVBQUcsUUFBTyxHQUFHLFFBQVEsUUFBUSxDQUFDLENBQUM7QUFDN0MsUUFBSSxVQUFVLEdBQUksUUFBTyxHQUFHLFFBQVEsUUFBUSxDQUFDLENBQUM7QUFDOUMsVUFBTSxJQUFJLEtBQUssTUFBTSxVQUFVLEVBQUU7QUFDakMsVUFBTSxLQUFLLEtBQUssTUFBTSxVQUFVLEVBQUU7QUFDbEMsV0FBTyxHQUFHLENBQUMsS0FBSyxFQUFFO0FBQUEsRUFDcEI7QUFJQSxXQUFTLHlCQUF1QztBQUM5QyxxQkFBaUI7QUFDakIsVUFBTSxPQUFPLEVBQUU7QUFDZixVQUFNLE1BQU0sS0FBSyxJQUFJO0FBR3JCLFVBQU0sUUFBUSxVQUFVLEVBQUUsU0FBUyxTQUFTLE1BQU0sK0JBQStCLFFBQVEsVUFBVSxDQUFDO0FBQ3BHLFVBQU0sWUFBWSxNQUFNO0FBQ3hCLGtCQUFjLE1BQU0sSUFBSSxFQUFFLE1BQU0sZ0JBQWdCLE1BQU0sRUFBRSxTQUFTLFdBQVcsTUFBTSxRQUFRLEVBQUUsQ0FBQztBQUM3RixxQkFBaUIsTUFBTSxJQUFJLE1BQU0sVUFBVSxDQUFDLEVBQUUsSUFBSSxFQUFFLFFBQVEsUUFBUSxRQUFRLGNBQWMsVUFBVSxJQUFJLENBQUM7QUFDekcsVUFBTSxVQUFVLENBQUMsRUFBRSxZQUFZLE1BQU07QUFDckMsVUFBTSxVQUFVLENBQUMsRUFBRSxVQUFVLE1BQU07QUFFbkMsa0JBQWMsTUFBTSxJQUFJLEVBQUUsTUFBTSxhQUFhLE1BQU0sRUFBRSxNQUFNLHNCQUFzQixFQUFFLENBQUM7QUFHcEYsVUFBTSxZQUFZLFVBQVUsRUFBRSxTQUFTLGFBQWEsTUFBTSw2QkFBNkIsUUFBUSxVQUFVLENBQUM7QUFDMUcsY0FBVSxZQUFZLE1BQU07QUFDNUIsa0JBQWMsVUFBVSxJQUFJLEVBQUUsTUFBTSxvQkFBb0IsTUFBTSxFQUFFLEtBQUssOEJBQThCLEVBQUUsQ0FBQztBQUN0RyxxQkFBaUIsVUFBVSxJQUFJLFVBQVUsVUFBVSxDQUFDLEVBQUUsSUFBSSxFQUFFLFFBQVEsUUFBUSxRQUFRLGVBQWUsVUFBVSxJQUFJLENBQUM7QUFDbEgsY0FBVSxVQUFVLENBQUMsRUFBRSxZQUFZLE1BQU07QUFDekMsY0FBVSxVQUFVLENBQUMsRUFBRSxVQUFVLE1BQU07QUFFdkMsa0JBQWMsVUFBVSxJQUFJLEVBQUUsTUFBTSxZQUFZLE1BQU0sRUFBRSxTQUFTLHFCQUFxQixFQUFFLENBQUM7QUFHekYsVUFBTSxZQUFZLFVBQVUsRUFBRSxTQUFTLGFBQWEsTUFBTSw0QkFBNEIsUUFBUSxVQUFVLENBQUM7QUFHekcsVUFBTSxhQUFhLFVBQVUsRUFBRSxVQUFVLE1BQU0sSUFBSSxTQUFTLGNBQWMsTUFBTSw2QkFBNkIsUUFBUSxXQUFXLE1BQU0sRUFBRSxDQUFDO0FBQ3pJLGVBQVcsYUFBYTtBQUN4QixlQUFXLFlBQVk7QUFDdkIsZUFBVyxZQUFZLE1BQU07QUFFN0Isa0JBQWMsV0FBVyxJQUFJLEVBQUUsTUFBTSxjQUFjLE1BQU0sRUFBRSxPQUFPLHlCQUF5QixFQUFFLENBQUM7QUFFOUYsaUJBQWEsSUFBSTtBQUNqQixXQUFPO0FBQUEsRUFDVDtBQUdBLFNBQU8sbUJBQW1CO0FBQzFCLFNBQU8seUJBQXlCO0FBQ2hDLFNBQU8sZ0JBQWdCO0FBQ3ZCLFNBQU8saUJBQWlCO0FBQ3hCLFNBQU8sWUFBWTtBQUNuQixTQUFPLGVBQWU7QUFDdEIsU0FBTyxZQUFZO0FBQ25CLFNBQU8sZ0JBQWdCO0FBQ3ZCLFNBQU8sbUJBQW1CO0FBQzFCLFNBQU8saUJBQWlCO0FBQ3hCLFNBQU8sYUFBYTtBQUNwQixTQUFPLFVBQVU7QUFDakIsU0FBTyxlQUFlO0FBQ3RCLFNBQU8sdUJBQXVCO0FBQzlCLFNBQU8sZUFBZTtBQUd0QixFQUFDLE9BQWUsTUFBTTtBQUFBLElBQ3BCLE1BQU07QUFBQSxJQUNOLFNBQVM7QUFBQSxJQUNULFlBQVk7QUFBQSxJQUNaLFNBQVM7QUFBQSxJQUNULGFBQWE7QUFBQSxJQUNiLGdCQUFnQjtBQUFBLElBQ2hCLFVBQVU7QUFBQSxJQUNWLE9BQU87QUFBQSxJQUNQLE9BQU87QUFBQSxJQUNQLE1BQU07QUFBQSxJQUNOO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxFQUNGO0FBT0EsTUFBSSxxQkFBb0M7QUFDeEMsTUFBSSxjQUE2QjtBQUVqQyxXQUFTLGFBQWEsR0FBYztBQUNsQyxRQUFJLENBQUMsRUFBRSxhQUFjLGtCQUFpQjtBQUN0QyxVQUFNLE9BQU8sRUFBRTtBQUdmLFFBQUksRUFBRSxTQUFTLGlCQUFpQjtBQUM5QixZQUFNLFVBQVUsRUFBRSxNQUFNLFdBQVcsRUFBRSxNQUFNLFNBQVMsRUFBRSxNQUFNLFFBQVE7QUFDcEUsWUFBTSxPQUFPLEVBQUUsTUFBTSxRQUFRLEVBQUUsTUFBTSxVQUFVLEVBQUUsTUFBTSxRQUFRLEVBQUUsV0FBVztBQUM1RSxZQUFNLE9BQU8sVUFBVSxFQUFFLFNBQVMsTUFBTSxRQUFRLFVBQVUsQ0FBQztBQUMzRCxVQUFJLE1BQU07QUFDUiw2QkFBcUIsS0FBSztBQUFBLE1BQzVCO0FBQ0E7QUFBQSxJQUNGO0FBR0EsUUFBSSxlQUFlLHNCQUFzQixLQUFLO0FBRzlDLFVBQU0sV0FBWSxPQUFlLFdBQVcsRUFBRSxTQUFTLFVBQVU7QUFDakUsUUFBSSxVQUFVLGVBQWU7QUFDM0IsWUFBTSxXQUFXLFNBQVM7QUFFMUIsWUFBTSxlQUFlLE9BQU8sT0FBTyxLQUFLLEtBQUssRUFBRTtBQUFBLFFBQzdDLENBQUMsTUFBb0IsRUFBRSxZQUFZLGFBQWEsRUFBRSxXQUFXLGFBQWEsRUFBRSxXQUFXO0FBQUEsTUFDekY7QUFDQSxVQUFJLGNBQWM7QUFDaEIsdUJBQWUsYUFBYTtBQUM1Qiw2QkFBcUIsYUFBYTtBQUFBLE1BQ3BDLFdBQVcsV0FBVyxRQUFRLEdBQUc7QUFFL0IsY0FBTSxPQUFPLFNBQVMsZ0JBQWdCLEdBQUcsU0FBUyxhQUFhLEtBQUs7QUFDcEUsY0FBTSxPQUFPLFVBQVUsRUFBRSxTQUFTLFVBQVUsTUFBTSxRQUFRLFVBQVUsQ0FBQztBQUNyRSxZQUFJLE1BQU07QUFDUiwrQkFBcUIsS0FBSztBQUMxQix5QkFBZSxLQUFLO0FBQUEsUUFDdEI7QUFBQSxNQUNGO0FBQUEsSUFDRjtBQUVBLFVBQU0sS0FBSyxjQUFjLGNBQWM7QUFBQSxNQUNyQyxNQUFNLEVBQUU7QUFBQSxNQUNSLE1BQU0sRUFBRSxRQUFRLENBQUM7QUFBQSxNQUNqQixRQUFRO0FBQUEsSUFDVixDQUFDO0FBQ0QsUUFBSSxJQUFJO0FBQ04sb0JBQWMsR0FBRztBQUFBLElBQ25CO0FBQUEsRUFDRjtBQUVBLFdBQVMscUJBQXFCLEdBQWM7QUFDMUMsUUFBSSxDQUFDLEVBQUUsYUFBYztBQUNyQixVQUFNLE9BQU8sRUFBRTtBQUdmLFVBQU0sY0FBYyxPQUFPLE9BQU8sS0FBSyxLQUFLLEVBQUU7QUFBQSxNQUM1QyxDQUFDLE1BQW9CLEVBQUUsV0FBVyxhQUFhLEVBQUUsV0FBVztBQUFBLElBQzlEO0FBRUEsZUFBVyxRQUFRLGFBQWE7QUFDOUIsaUJBQVcsTUFBTSxLQUFLLFdBQVc7QUFDL0IsWUFBSSxHQUFHLFdBQVcsYUFBYSxHQUFHLFNBQVMsRUFBRSxNQUFNO0FBQ2pELDJCQUFpQixLQUFLLElBQUksR0FBRyxJQUFJO0FBQUEsWUFDL0IsUUFBUSxFQUFFLFdBQVcsVUFBVTtBQUFBLFlBQy9CLFFBQVEsRUFBRSxXQUFXLEVBQUU7QUFBQSxZQUN2QixVQUFVLEVBQUU7QUFBQSxVQUNkLENBQUM7QUFDRDtBQUFBLFFBQ0Y7QUFBQSxNQUNGO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFFQSxXQUFTLGVBQXFCO0FBQzVCLFFBQUksQ0FBQyxFQUFFLGFBQWM7QUFHckIsUUFBSSxvQkFBb0I7QUFDdEIscUJBQWUsb0JBQW9CLE1BQU07QUFDekMsMkJBQXFCO0FBQUEsSUFDdkI7QUFHQSxVQUFNLE9BQU8sRUFBRSxhQUFhLE1BQU0sRUFBRSxhQUFhLE1BQU07QUFDdkQsUUFBSSxNQUFNO0FBQ1IsV0FBSyxTQUFTO0FBQUEsSUFDaEI7QUFBQSxFQUNGOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/static/activity-tree.ts b/static/activity-tree.ts new file mode 100644 index 0000000..f9445f5 --- /dev/null +++ b/static/activity-tree.ts @@ -0,0 +1,528 @@ +// ─── Agent Activity Tree (Mission Control) ────────────────────────── +// Full-depth parallel agent activity tracking for the 3-Tier system. +// Provides: initActivityTree, addNode, addToolCall, updateToolCall, +// finalizeNode, getStats, createMockActivityTree, formatElapsed + +// Agent metadata — maps agent IDs to display info +const AGENT_META: Record = { + rose: { emoji: '🌹', name: 'Rose', tier: 1 }, + lotus: { emoji: '🪷', name: 'Lotus', tier: 2 }, + 'forget-me-not':{ emoji: '🌼', name: 'Forget-me-not', tier: 2 }, + sunflower: { emoji: '🌻', name: 'Sunflower', tier: 2 }, + iris: { emoji: '⚜️', name: 'Iris', tier: 2 }, + ivy: { emoji: '🌿', name: 'Ivy', tier: 2 }, + dandelion: { emoji: '🛡', name: 'Dandelion', tier: 2 }, + root: { emoji: '🌳', name: 'Root', tier: 2 }, +}; + +// Tier-3 sub-agent detection — tool names that indicate sub-agent work +const TIER3_TOOL_NAMES = new Set([ + 'search_files', 'read_file', 'write_file', 'terminal', 'browser_navigate', + 'browser_snapshot', 'browser_click', 'browser_type', 'browser_press', + 'web_search', 'delegate_task', 'execute_code', 'patch', +]); + +let _nodeCounter = 0; +function _nextNodeId(agentId: string): string { + return `${agentId}-${++_nodeCounter}`; +} + +// ─── Core Functions ──────────────────────────────────────────────── + +function initActivityTree(): ActivityTree { + const tree: ActivityTree = { + version: 1, + rootId: 'rose', + nodes: { + rose: { + id: 'rose', + parentId: null, + agentId: 'rose', + agentEmoji: '🌹', + agentName: 'Rose', + tier: 1, + status: 'running', + task: 'Orchestrating', + toolCalls: [], + startedAt: Date.now(), + endedAt: null, + duration: null, + children: [], + collapsed: false, + metadata: {}, + } + }, + stats: _emptyStats(), + }; + S.activityTree = tree; + S.mcFilter = {}; + S.mcSort = 'runtime'; + return tree; +} + +function _emptyStats(): MCStats { + return { + totalAgents: 0, + runningAgents: 0, + pendingAgents: 0, + doneAgents: 0, + errorAgents: 0, + totalTools: 0, + doneTools: 0, + runningTools: 0, + avgResponseTime: 0, + totalElapsed: 0, + }; +} + +// ─── Node Operations ─────────────────────────────────────────────── + +function atAddNode(opts: { + parentId?: string; + agentId: string; + task: string; + status?: ActivityNode['status']; + tier?: 1|2|3; +}): ActivityNode | null { + const tree = S.activityTree; + if (!tree) return null; + + const parentId = opts.parentId || tree.rootId; + const parent = tree.nodes[parentId]; + if (!parent) return null; + + const meta = AGENT_META[opts.agentId]; + const tier = opts.tier || (meta ? meta.tier : 3); + const id = _nextNodeId(opts.agentId); + + const node: ActivityNode = { + id, + parentId, + agentId: opts.agentId, + agentEmoji: meta?.emoji || '⚙️', + agentName: meta?.name || opts.agentId, + tier, + status: opts.status || 'pending', + task: opts.task, + toolCalls: [], + startedAt: opts.status === 'running' ? Date.now() : null, + endedAt: null, + duration: null, + children: [], + collapsed: false, + metadata: {}, + }; + + tree.nodes[id] = node; + parent.children.push(id); + _recalcStats(tree); + return node; +} + +function atUpdateNode(nodeId: string, updates: Partial): void { + const tree = S.activityTree; + if (!tree) return; + const node = tree.nodes[nodeId]; + if (!node) return; + Object.assign(node, updates); + _recalcStats(tree); +} + +function atGetNode(nodeId: string): ActivityNode | null { + return S.activityTree?.nodes[nodeId] || null; +} + +function atGetChildren(nodeId: string): ActivityNode[] { + const tree = S.activityTree; + if (!tree) return []; + const node = tree.nodes[nodeId]; + if (!node) return []; + return node.children.map(id => tree.nodes[id]).filter(Boolean); +} + +function atGetRunningNodes(): ActivityNode[] { + const tree = S.activityTree; + if (!tree) return []; + return Object.values(tree.nodes).filter( + n => n.status === 'running' || n.status === 'thinking' + ); +} + +// ─── Tool Call Operations ────────────────────────────────────────── + +function atAddToolCall(nodeId: string, tc: { + name: string; + args?: Record; + status?: ActivityToolCall['status']; +}): ActivityToolCall | null { + const tree = S.activityTree; + if (!tree) return null; + const node = tree.nodes[nodeId]; + if (!node) return null; + + // If node was pending, it's now running + if (node.status === 'pending') { + node.status = 'running'; + node.startedAt = node.startedAt || Date.now(); + } + + const toolCall: ActivityToolCall = { + id: `tc-${++_nodeCounter}`, + name: tc.name, + status: tc.status || 'running', + args: tc.args || {}, + startedAt: Date.now(), + endedAt: null, + }; + + node.toolCalls.push(toolCall); + _recalcStats(tree); + return toolCall; +} + +function atUpdateToolCall(nodeId: string, toolId: string, updates: Partial): void { + const tree = S.activityTree; + if (!tree) return; + const node = tree.nodes[nodeId]; + if (!node) return; + + const tc = node.toolCalls.find(t => t.id === toolId); + if (!tc) return; + + Object.assign(tc, updates); + if (updates.status === 'done' || updates.status === 'error') { + tc.endedAt = tc.endedAt || Date.now(); + tc.duration = tc.startedAt ? (tc.endedAt - tc.startedAt) / 1000 : null; + } + _recalcStats(tree); +} + +// ─── Finalization ────────────────────────────────────────────────── + +function atFinalizeNode(nodeId: string, status: ActivityNode['status']): void { + const tree = S.activityTree; + if (!tree) return; + const node = tree.nodes[nodeId]; + if (!node) return; + + node.status = status; + node.endedAt = Date.now(); + node.duration = node.startedAt ? (node.endedAt - node.startedAt) / 1000 : null; + + // Finalize any still-running tool calls + for (const tc of node.toolCalls) { + if (tc.status === 'running' || tc.status === 'pending') { + tc.status = status === 'error' ? 'error' : 'done'; + tc.endedAt = node.endedAt; + tc.duration = tc.startedAt ? (tc.endedAt - tc.startedAt) / 1000 : null; + } + } + + // Recursively finalize children that are still active + for (const childId of node.children) { + const child = tree.nodes[childId]; + if (child && (child.status === 'running' || child.status === 'thinking' || child.status === 'pending')) { + atFinalizeNode(childId, status); + } + } + + _recalcStats(tree); +} + +// ─── Stats ───────────────────────────────────────────────────────── + +function _recalcStats(tree: ActivityTree): void { + const nodes = Object.values(tree.nodes).filter(n => n.id !== tree.rootId); + const tools = nodes.flatMap(n => n.toolCalls); + + const doneDurations = nodes + .filter(n => n.duration !== null) + .map(n => n.duration!); + + const runningNodes = nodes.filter(n => n.status === 'running' || n.status === 'thinking'); + const maxElapsed = runningNodes.reduce((max, n) => { + const elapsed = n.startedAt ? (Date.now() - n.startedAt) / 1000 : 0; + return Math.max(max, elapsed); + }, 0); + + tree.stats = { + totalAgents: nodes.length, + runningAgents: runningNodes.length, + pendingAgents: nodes.filter(n => n.status === 'pending').length, + doneAgents: nodes.filter(n => n.status === 'done').length, + errorAgents: nodes.filter(n => n.status === 'error').length, + totalTools: tools.length, + doneTools: tools.filter(t => t.status === 'done').length, + runningTools: tools.filter(t => t.status === 'running').length, + avgResponseTime: doneDurations.length + ? doneDurations.reduce((a, b) => a + b, 0) / doneDurations.length + : 0, + totalElapsed: maxElapsed, + }; +} + +function atGetStats(): MCStats { + return S.activityTree?.stats || _emptyStats(); +} + +// ─── Reset ───────────────────────────────────────────────────────── + +function atReset(): void { + _nodeCounter = 0; + initActivityTree(); +} + +// ─── Formatting ──────────────────────────────────────────────────── + +function formatElapsed(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m ${rs}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return '—'; + if (seconds < 0.01) return '<0.01s'; + if (seconds < 1) return `${seconds.toFixed(2)}s`; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const m = Math.floor(seconds / 60); + const rs = Math.round(seconds % 60); + return `${m}m ${rs}s`; +} + +// ─── Mock Data ───────────────────────────────────────────────────── + +function createMockActivityTree(): ActivityTree { + initActivityTree(); + const tree = S.activityTree!; + const now = Date.now(); + + // Lotus — running with 2 tool calls + const lotus = atAddNode({ agentId: 'lotus', task: 'Analysiere Gesundheitsdaten', status: 'running' })!; + lotus.startedAt = now - 12000; + atAddToolCall(lotus.id, { name: 'search_files', args: { pattern: 'health*', path: '/data' } }); + atUpdateToolCall(lotus.id, lotus.toolCalls[0].id, { status: 'done', result: '12 matches', duration: 0.3 }); + lotus.toolCalls[0].startedAt = now - 11000; + lotus.toolCalls[0].endedAt = now - 10997; + + atAddToolCall(lotus.id, { name: 'read_file', args: { path: '/data/health-log.md' } }); + + // Sunflower — running with 1 done, 1 running tool + const sunflower = atAddNode({ agentId: 'sunflower', task: 'Portfolio-Analyse Q1 2026', status: 'running' })!; + sunflower.startedAt = now - 8000; + atAddToolCall(sunflower.id, { name: 'browser_navigate', args: { url: 'https://finance.example.com' } }); + atUpdateToolCall(sunflower.id, sunflower.toolCalls[0].id, { status: 'done', result: 'Page loaded', duration: 2.1 }); + sunflower.toolCalls[0].startedAt = now - 7500; + sunflower.toolCalls[0].endedAt = now - 5400; + + atAddToolCall(sunflower.id, { name: 'terminal', args: { command: 'python3 analyse.py' } }); + + // Dandelion — pending + const dandelion = atAddNode({ agentId: 'dandelion', task: 'Triaging unread messages', status: 'pending' })!; + + // Add a tier-3 sub-agent under Lotus + const researcher = atAddNode({ parentId: lotus.id, agentId: 'researcher', task: 'Looking up nutrition data', status: 'running', tier: 3 })!; + researcher.agentEmoji = '🔍'; + researcher.agentName = 'Researcher'; + researcher.startedAt = now - 3000; + + atAddToolCall(researcher.id, { name: 'web_search', args: { query: 'nutrition database API' } }); + + _recalcStats(tree); + return tree; +} + +// ─── Make core functions global (IIFE-safe) ──────────────────────── +window.initActivityTree = initActivityTree; +window.createMockActivityTree = createMockActivityTree; +window.formatElapsed = formatElapsed; +window.formatDuration = formatDuration; +window.atAddNode = atAddNode; +window.atUpdateNode = atUpdateNode; +window.atGetNode = atGetNode; +window.atAddToolCall = atAddToolCall; +window.atUpdateToolCall = atUpdateToolCall; +window.atFinalizeNode = atFinalizeNode; +window.atGetStats = atGetStats; +window.atReset = atReset; +window._atTrackTool = _atTrackTool; +window._atTrackToolComplete = _atTrackToolComplete; +window._atTrackSubagent = _atTrackSubagent; +window._atTrackDone = _atTrackDone; + +// ─── Export for console testing ───────────────────────────────────── +(window as any)._at = { + init: initActivityTree, + addNode: atAddNode, + updateNode: atUpdateNode, + getNode: atGetNode, + addToolCall: atAddToolCall, + updateToolCall: atUpdateToolCall, + finalize: atFinalizeNode, + stats: atGetStats, + reset: atReset, + mock: createMockActivityTree, + formatElapsed, + formatDuration, + AGENT_META, + trackSubagent: _atTrackSubagent, +}; + +// ─── SSE Event Bridge (called from messages.ts) ──────────────────── +// Tracks which agent is currently active based on tool calls. +// When delegate_task is detected, creates a new agent node. +// Otherwise, attaches tool calls to the current active agent node. + +let _activeAgentNodeId: string | null = null; // Current agent node receiving tool calls +let _lastToolId: string | null = null; // Last tool call ID (for complete matching) + +function _atTrackTool(d: any): void { + if (!S.activityTree) initActivityTree(); + const tree = S.activityTree!; + + // Detect delegation — when tool name is 'delegate_task' + if (d.name === 'delegate_task') { + // Prefer explicit agent field from SSE payload (set by streaming.py + // when it detects delegate_task), then fall back to args lookup. + const agentId = d.agent || d.args?.agentId || d.args?.agent || d.args?.name || 'unknown'; + const task = d.args?.goal || d.args?.prompt || d.args?.task || d.preview || 'Delegated task'; + const node = atAddNode({ agentId, task, status: 'running' }); + if (node) { + _activeAgentNodeId = node.id; + } + return; + } + + // Determine which node to attach the tool call to + let targetNodeId = _activeAgentNodeId || tree.rootId; + + // Check if this tool is from a specific agent (from inflight metadata) + const inflight = (window as any).INFLIGHT?.[S.session?.session_id]; + if (inflight?.thisTurnAgent) { + const agentKey = inflight.thisTurnAgent; + // Find or create node for this agent + const existingNode = Object.values(tree.nodes).find( + (n: ActivityNode) => n.agentId === agentKey && (n.status === 'running' || n.status === 'thinking') + ); + if (existingNode) { + targetNodeId = existingNode.id; + _activeAgentNodeId = existingNode.id; + } else if (AGENT_META[agentKey]) { + // New agent appeared — create node + const task = inflight.thisTurnModel ? `${inflight.thisTurnModel}` : 'Working...'; + const node = atAddNode({ agentId: agentKey, task, status: 'running' }); + if (node) { + _activeAgentNodeId = node.id; + targetNodeId = node.id; + } + } + } + + const tc = atAddToolCall(targetNodeId, { + name: d.name, + args: d.args || {}, + status: 'running', + }); + if (tc) { + _lastToolId = tc.id; + } +} + +// ─── Subagent lifecycle event handler (from SSE 'subagent' events) ──── + +function _atTrackSubagent(d: any): void { + if (!S.activityTree) initActivityTree(); + const tree = S.activityTree!; + + if (d.event_type === 'subagent.start') { + // Derive agentId from subagent_id if available, otherwise from goal hint. + // subagent_id format: "lotus-1" → agentId = "lotus" + let agentId = 'unknown'; + if (d.subagent_id) { + // Strip numeric suffix e.g. "lotus-1" → "lotus" + agentId = d.subagent_id.replace(/-\d+$/, ''); + } + const task = d.goal || d.preview || `Subagent ${d.task_index ?? ''}`.trim(); + const node = atAddNode({ agentId, task, status: 'running' }); + if (node) { + _activeAgentNodeId = node.id; + } + return; + } + + if (d.event_type === 'subagent.complete' || d.event_type === 'subagent.done') { + // Find the running node for this subagent and finalize it. + // Use subagent_id to locate the right node. + let targetNodeId: string | null = null; + if (d.subagent_id) { + const agentId = d.subagent_id.replace(/-\d+$/, ''); + const found = Object.values(tree.nodes).find( + (n: ActivityNode) => n.agentId === agentId && n.status === 'running' + ); + if (found) targetNodeId = found.id; + } + // Fall back to _activeAgentNodeId if no subagent_id match + if (!targetNodeId) targetNodeId = _activeAgentNodeId; + if (targetNodeId && tree.nodes[targetNodeId]) { + const status = d.status === 'timeout' || d.status === 'error' ? 'error' : 'done'; + atFinalizeNode(targetNodeId, status); + if (_activeAgentNodeId === targetNodeId) { + _activeAgentNodeId = null; + } + } + return; + } +} + +function _atTrackToolComplete(d: any): void { + if (!S.activityTree) return; + const tree = S.activityTree!; + + // Try to find the matching running tool call across all active nodes + const activeNodes = Object.values(tree.nodes).filter( + (n: ActivityNode) => n.status === 'running' || n.status === 'thinking' + ); + + for (const node of activeNodes) { + for (const tc of node.toolCalls) { + if (tc.status === 'running' && tc.name === d.name) { + atUpdateToolCall(node.id, tc.id, { + status: d.is_error ? 'error' : 'done', + result: d.preview || d.result, + duration: d.duration, + }); + return; + } + } + } +} + +function _atTrackDone(): void { + if (!S.activityTree) return; + + // Finalize the active agent node + if (_activeAgentNodeId) { + atFinalizeNode(_activeAgentNodeId, 'done'); + _activeAgentNodeId = null; + } + + // Reset root status + const root = S.activityTree.nodes[S.activityTree.rootId]; + if (root) { + root.status = 'running'; + } +} + +function _atTrackNewSession(): void { + _activeAgentNodeId = null; + _lastToolId = null; + if (S.activityTree) { + atReset(); + } +} diff --git a/static/awesome-design-md_README.md b/static/awesome-design-md_README.md new file mode 100644 index 0000000..c1a8cea --- /dev/null +++ b/static/awesome-design-md_README.md @@ -0,0 +1,196 @@ + + claude-skills + + + +
+
+ +
+ Curated collection of DESIGN.md files inspired by developer focused websites. +
+
+ +
+ +
+ +[![Awesome](https://awesome.re/badge.svg)](https://awesome.re) +![DESIGN.md Count](https://img.shields.io/badge/DESIGN.md%20count-69-10b981?style=classic) +[![Last Update](https://img.shields.io/github/last-commit/VoltAgent/awesome-design-md?label=Last%20update&style=classic)](https://github.com/VoltAgent/awesome-design-md) +[![Discord](https://img.shields.io/discord/1361559153780195478.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://s.voltagent.dev/discord) + +
+ + +# Awesome DESIGN.md + +Copy a DESIGN.md into your project, tell your AI agent "build me a page that looks like this" and get pixel-perfect UI that actually matches. + + +## What is DESIGN.md? + +[DESIGN.md](https://stitch.withgoogle.com/docs/design-md/overview/) is a new concept introduced by Google Stitch. A plain-text design system document that AI agents read to generate consistent UI. + +It's just a markdown file. No Figma exports, no JSON schemas, no special tooling. Drop it into your project root and any AI coding agent or Google Stitch instantly understands how your UI should look. Markdown is the format LLMs read best, so there's nothing to parse or configure. + +| File | Who reads it | What it defines | +|------|-------------|-----------------| +| `AGENTS.md` | Coding agents | How to build the project | +| `DESIGN.md` | Design agents | How the project should look and feel | + +**This repo provides ready-to-use DESIGN.md files** extracted from real websites. + +## Request a DESIGN.md + +You can [request a DESIGN.md](https://getdesign.md/request) for specific website, including private requests delivered exclusively to you. + +## Sponsors ❤️ + +[Become a Sponsor](https://github.com/sponsors/VoltAgent/sponsorships?tier_id=605140) [1M+ view] — your logo here and get listed on [getdesign.md](https://getdesign.md/) + +## Collection + +### AI & LLM Platforms + +- [**Claude**](https://getdesign.md/claude/design-md) - Anthropic's AI assistant. Warm terracotta accent, clean editorial layout +- [**Cohere**](https://getdesign.md/cohere/design-md) - Enterprise AI platform. Vibrant gradients, data-rich dashboard aesthetic +- [**ElevenLabs**](https://getdesign.md/elevenlabs/design-md) - AI voice platform. Dark cinematic UI, audio-waveform aesthetics +- [**Minimax**](https://getdesign.md/minimax/design-md) - AI model provider. Bold dark interface with neon accents +- [**Mistral AI**](https://getdesign.md/mistral.ai/design-md) - Open-weight LLM provider. French-engineered minimalism, purple-toned +- [**Ollama**](https://getdesign.md/ollama/design-md) - Run LLMs locally. Terminal-first, monochrome simplicity +- [**OpenCode AI**](https://getdesign.md/opencode.ai/design-md) - AI coding platform. Developer-centric dark theme +- [**Replicate**](https://getdesign.md/replicate/design-md) - Run ML models via API. Clean white canvas, code-forward +- [**RunwayML**](https://getdesign.md/runwayml/design-md) - AI video generation. Cinematic dark UI, media-rich layout +- [**Together AI**](https://getdesign.md/together.ai/design-md) - Open-source AI infrastructure. Technical, blueprint-style design +- [**VoltAgent**](https://getdesign.md/voltagent/design-md) - AI agent framework. Void-black canvas, emerald accent, terminal-native +- [**xAI**](https://getdesign.md/x.ai/design-md) - Elon Musk's AI lab. Stark monochrome, futuristic minimalism + +### Developer Tools & IDEs + +- [**Cursor**](https://getdesign.md/cursor/design-md) - AI-first code editor. Sleek dark interface, gradient accents +- [**Expo**](https://getdesign.md/expo/design-md) - React Native platform. Dark theme, tight letter-spacing, code-centric +- [**Lovable**](https://getdesign.md/lovable/design-md) - AI full-stack builder. Playful gradients, friendly dev aesthetic +- [**Raycast**](https://getdesign.md/raycast/design-md) - Productivity launcher. Sleek dark chrome, vibrant gradient accents +- [**Superhuman**](https://getdesign.md/superhuman/design-md) - Fast email client. Premium dark UI, keyboard-first, purple glow +- [**Vercel**](https://getdesign.md/vercel/design-md) - Frontend deployment platform. Black and white precision, Geist font +- [**Warp**](https://getdesign.md/warp/design-md) - Modern terminal. Dark IDE-like interface, block-based command UI + +### Backend, Database & DevOps + +- [**ClickHouse**](https://getdesign.md/clickhouse/design-md) - Fast analytics database. Yellow-accented, technical documentation style +- [**Composio**](https://getdesign.md/composio/design-md) - Tool integration platform. Modern dark with colorful integration icons +- [**HashiCorp**](https://getdesign.md/hashicorp/design-md) - Infrastructure automation. Enterprise-clean, black and white +- [**MongoDB**](https://getdesign.md/mongodb/design-md) - Document database. Green leaf branding, developer documentation focus +- [**PostHog**](https://getdesign.md/posthog/design-md) - Product analytics. Playful hedgehog branding, developer-friendly dark UI +- [**Sanity**](https://getdesign.md/sanity/design-md) - Headless CMS. Red accent, content-first editorial layout +- [**Sentry**](https://getdesign.md/sentry/design-md) - Error monitoring. Dark dashboard, data-dense, pink-purple accent +- [**Supabase**](https://getdesign.md/supabase/design-md) - Open-source Firebase alternative. Dark emerald theme, code-first + +### Productivity & SaaS + +- [**Cal.com**](https://getdesign.md/cal/design-md) - Open-source scheduling. Clean neutral UI, developer-oriented simplicity +- [**Intercom**](https://getdesign.md/intercom/design-md) - Customer messaging. Friendly blue palette, conversational UI patterns +- [**Linear**](https://getdesign.md/linear.app/design-md) - Project management for engineers. Ultra-minimal, precise, purple accent +- [**Mintlify**](https://getdesign.md/mintlify/design-md) - Documentation platform. Clean, green-accented, reading-optimized +- [**Notion**](https://getdesign.md/notion/design-md) - All-in-one workspace. Warm minimalism, serif headings, soft surfaces +- [**Resend**](https://getdesign.md/resend/design-md) - Email API for developers. Minimal dark theme, monospace accents +- [**Zapier**](https://getdesign.md/zapier/design-md) - Automation platform. Warm orange, friendly illustration-driven + +### Design & Creative Tools + +- [**Airtable**](https://getdesign.md/airtable/design-md) - Spreadsheet-database hybrid. Colorful, friendly, structured data aesthetic +- [**Clay**](https://getdesign.md/clay/design-md) - Creative agency. Organic shapes, soft gradients, art-directed layout +- [**Figma**](https://getdesign.md/figma/design-md) - Collaborative design tool. Vibrant multi-color, playful yet professional +- [**Framer**](https://getdesign.md/framer/design-md) - Website builder. Bold black and blue, motion-first, design-forward +- [**Miro**](https://getdesign.md/miro/design-md) - Visual collaboration. Bright yellow accent, infinite canvas aesthetic +- [**Webflow**](https://getdesign.md/webflow/design-md) - Visual web builder. Blue-accented, polished marketing site aesthetic + +### Fintech & Crypto + +- [**Binance**](https://getdesign.md/binance/design-md) - Crypto exchange. Bold Binance Yellow on monochrome, trading-floor urgency +- [**Coinbase**](https://getdesign.md/coinbase/design-md) - Crypto exchange. Clean blue identity, trust-focused, institutional feel +- [**Kraken**](https://getdesign.md/kraken/design-md) - Crypto trading platform. Purple-accented dark UI, data-dense dashboards +- [**Mastercard**](https://getdesign.md/mastercard/design-md) - Global payments network. Warm cream canvas, orbital pill shapes, editorial warmth +- [**Revolut**](https://getdesign.md/revolut/design-md) - Digital banking. Sleek dark interface, gradient cards, fintech precision +- [**Stripe**](https://getdesign.md/stripe/design-md) - Payment infrastructure. Signature purple gradients, weight-300 elegance +- [**Wise**](https://getdesign.md/wise/design-md) - International money transfer. Bright green accent, friendly and clear + +### E-commerce & Retail + +- [**Airbnb**](https://getdesign.md/airbnb/design-md) - Travel marketplace. Warm coral accent, photography-driven, rounded UI +- [**Meta**](https://getdesign.md/meta/design-md) - Tech retail store. Photography-first, binary light/dark surfaces, Meta Blue CTAs +- [**Nike**](https://getdesign.md/nike/design-md) - Athletic retail. Monochrome UI, massive uppercase Futura, full-bleed photography +- [**Shopify**](https://getdesign.md/shopify/design-md) - E-commerce platform. Dark-first cinematic, neon green accent, ultra-light display type +- [**Starbucks**](https://getdesign.md/starbucks/design-md) - Coffee retail flagship. Four-tier earth-green system, warm cream canvas, proprietary SoDoSans typography + +### Media & Consumer Tech + +- [**Apple**](https://getdesign.md/apple/design-md) - Consumer electronics. Premium white space, SF Pro, cinematic imagery +- [**IBM**](https://getdesign.md/ibm/design-md) - Enterprise technology. Carbon design system, structured blue palette +- [**NVIDIA**](https://getdesign.md/nvidia/design-md) - GPU computing. Green-black energy, technical power aesthetic +- [**Pinterest**](https://getdesign.md/pinterest/design-md) - Visual discovery platform. Red accent, masonry grid, image-first +- [**PlayStation**](https://getdesign.md/playstation/design-md) - Gaming console retail. Three-surface channel layout, cyan hover-scale interaction +- [**SpaceX**](https://getdesign.md/spacex/design-md) - Space technology. Stark black and white, full-bleed imagery, futuristic +- [**Spotify**](https://getdesign.md/spotify/design-md) - Music streaming. Vibrant green on dark, bold type, album-art-driven +- [**The Verge**](https://getdesign.md/theverge/design-md) - Tech editorial media. Acid-mint and ultraviolet accents, Manuka display type +- [**Uber**](https://getdesign.md/uber/design-md) - Mobility platform. Bold black and white, tight type, urban energy +- [**Vodafone**](https://getdesign.md/vodafone/design-md) - Global telecom brand. Monumental uppercase display, Vodafone Red chapter bands +- [**WIRED**](https://getdesign.md/wired/design-md) - Tech magazine. Paper-white broadsheet density, custom serif, ink-blue links + +### Automotive + +- [**BMW**](https://getdesign.md/bmw/design-md) - Luxury automotive. Dark premium surfaces, precise German engineering aesthetic +- [**Bugatti**](https://getdesign.md/bugatti/design-md) - Luxury hypercar. Cinema-black canvas, monochrome austerity, monumental display type +- [**Ferrari**](https://getdesign.md/ferrari/design-md) - Luxury automotive. Chiaroscuro black-white editorial, Ferrari Red with extreme sparseness +- [**Lamborghini**](https://getdesign.md/lamborghini/design-md) - Luxury automotive. True black cathedral, gold accent, LamboType custom Neo-Grotesk +- [**Renault**](https://getdesign.md/renault/design-md) - French automotive. Vivid aurora gradients, NouvelR proprietary typeface, zero-radius buttons +- [**Tesla**](https://getdesign.md/tesla/design-md) - Electric vehicles. Radical subtraction, cinematic full-viewport photography, Universal Sans + + +## What's Inside Each DESIGN.md + +Every file follows the [Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/) with extended sections: + +| # | Section | What it captures | +|---|---------|-----------------| +| 1 | Visual Theme & Atmosphere | Mood, density, design philosophy | +| 2 | Color Palette & Roles | Semantic name + hex + functional role | +| 3 | Typography Rules | Font families, full hierarchy table | +| 4 | Component Stylings | Buttons, cards, inputs, navigation with states | +| 5 | Layout Principles | Spacing scale, grid, whitespace philosophy | +| 6 | Depth & Elevation | Shadow system, surface hierarchy | +| 7 | Do's and Don'ts | Design guardrails and anti-patterns | +| 8 | Responsive Behavior | Breakpoints, touch targets, collapsing strategy | +| 9 | Agent Prompt Guide | Quick color reference, ready-to-use prompts | + +Each site includes: + +| File | Purpose | +|------|---------| +| `DESIGN.md` | The design system (what agents read) | +| `preview.html` | Visual catalog showing color swatches, type scale, buttons, cards | +| `preview-dark.html` | Same catalog with dark surfaces | + +### How to Use + + +1. Copy a site's `DESIGN.md` into your project root +2. Tell your AI agent to use it. + + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +- **Improve existing files**: Fix wrong colors, missing tokens, weak descriptions +- **Report issues**: Let us know if something looks off + +Before opening a PR, please [open an issue](https://github.com/VoltAgent/awesome-design-md/issues) first to discuss your idea and get feedback from maintainers. + + +## License + +MIT License - see [LICENSE](LICENSE) + +This repository is a curated collection of design system documents extracted from public websites. All DESIGN.md files are provided "as is" without warranty. The extracted design tokens represent publicly visible CSS values. We do not claim ownership of any site's visual identity. These documents exist to help AI agents generate consistent UI. diff --git a/static/boot.js b/static/boot.js index b7a0d98..e869311 100644 --- a/static/boot.js +++ b/static/boot.js @@ -439,7 +439,6 @@ $('modelSelect').onchange=async()=>{ await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})}); S.session.model=selectedModel; if(typeof syncModelChip==='function') syncModelChip(); - if(typeof syncAgentChip==='function') syncAgentChip(); syncTopbar(); // Warn if selected model belongs to a different provider than what Hermes is configured for if(typeof _checkProviderMismatch==='function'){ diff --git a/static/boot.ts b/static/boot.ts new file mode 100644 index 0000000..2773b61 --- /dev/null +++ b/static/boot.ts @@ -0,0 +1,876 @@ +// ── Type declarations for cross-module globals ─────────────────────────────── +// (Window interface and global types are in global.d.ts) + +// External functions from other modules (these are truly external - not defined in this file) +declare function api(url: string, opts?: { method?: string; body?: string }): Promise; +declare function getMatchingCommands(prefix: string): Array<{ id: string; label: string }>; +declare function showCmdDropdown(matches: Array<{ id: string; label: string }>): void; +declare function hideCmdDropdown(): void; +declare function navigateCmdDropdown(delta: number): void; +declare function selectCmdDropdownItem(): void; +declare function ensureSkillCommandsLoadedForAutocomplete(): void; +declare function filterSessions(): void; +declare function showToast(msg: string, ms?: number): void; +declare function setStatus(msg: string): void; +declare function setComposerStatus(msg: string): void; +declare function setBusy(val: boolean): void; +declare function populateModelDropdown(): Promise; +declare function loadCommands(): Promise; +declare function loadWorkspaceList(): Promise; +declare function loadOnboardingWizard(): Promise; +declare function syncTopbar(): void; +declare function checkInflightOnBoot(sessionId: string): Promise; +declare function startGatewaySSE(): void; +declare function renderBreadcrumb(): void; +declare function setLocale(lang: string): void; +declare function resolvePreferredLocale(a: string | null, b: string | null): string; +declare function applyLocaleToDOM(): void; + +async function cancelStream(){ + const streamId = S.activeStreamId; + if(!streamId) return; + try{ + await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'}); + }catch(e){/* cancel request failed — cleanup below still runs */} + // Clear status unconditionally after the cancel request completes. + // The SSE cancel event may also fire, but if the connection is already + // closed it won't arrive — so we handle cleanup here as the guaranteed path. + const btn=$('btnCancel');if(btn)btn.style.display='none'; + S.activeStreamId=null; + setBusy(false); + if(typeof setComposerStatus==='function') setComposerStatus(''); + else setStatus(''); +} + +// ── Mobile navigation ────────────────────────────────────────────────────── +let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' + +function _isCompactWorkspaceViewport(){ + return window.matchMedia('(max-width: 900px)').matches; +} + +function _workspacePanelEls(){ + return { + layout: document.querySelector('.layout'), + panel: document.querySelector('.rightpanel'), + toggleBtn: $('btnWorkspacePanelToggle'), + collapseBtn: $('btnCollapseWorkspacePanel'), + }; +} + +function _hasWorkspacePreviewVisible(){ + const preview=$('previewArea'); + return !!(preview&&preview.classList.contains('visible')); +} + +function _setWorkspacePanelMode(mode){ + const {layout,panel}= _workspacePanelEls(); + if(!layout||!panel)return; + _workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed'; + const open=_workspacePanelMode!=='closed'; + document.documentElement.dataset.workspacePanel=open?'open':'closed'; + // Persist open/closed across refreshes (browse/preview → open; closed → closed) + localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed'); + layout.classList.toggle('workspace-panel-collapsed',!open); + if(_isCompactWorkspaceViewport()){ + panel.classList.toggle('mobile-open',open); + }else{ + panel.classList.remove('mobile-open'); + } + syncWorkspacePanelUI(); +} + +function syncWorkspacePanelState(){ + const hasPreview=_hasWorkspacePreviewVisible(); + if(hasPreview){ + if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview'); + else syncWorkspacePanelUI(); + return; + } + if(!S.session){ + _setWorkspacePanelMode('closed'); + return; + } + _setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode); +} + +function openWorkspacePanel(mode='browse'){ + if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible())return; + if(mode==='preview'&&_workspacePanelMode==='browse'){ + syncWorkspacePanelUI(); + return; + } + _setWorkspacePanelMode(mode); +} + +function closeWorkspacePanel(){ + _setWorkspacePanelMode('closed'); +} + +function ensureWorkspacePreviewVisible(){ + if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview'); + else syncWorkspacePanelUI(); +} + +function handleWorkspaceClose(){ + if(_hasWorkspacePreviewVisible()){ + clearPreview(); + return; + } + closeWorkspacePanel(); +} + +function syncWorkspacePanelUI(){ + const {layout,panel,toggleBtn,collapseBtn}= _workspacePanelEls(); + if(!layout||!panel)return; + const desktopOpen=_workspacePanelMode!=='closed'; + const mobileOpen=panel.classList.contains('mobile-open'); + const isCompact=_isCompactWorkspaceViewport(); + const isOpen=isCompact?mobileOpen:desktopOpen; + const canBrowse=!!S.session||_hasWorkspacePreviewVisible(); + const hasPreview=_hasWorkspacePreviewVisible(); + if(toggleBtn){ + toggleBtn.classList.toggle('active',isOpen); + toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false'); + toggleBtn.title=isOpen?'Hide workspace panel':'Show workspace panel'; + toggleBtn.disabled=!canBrowse; + } + if(collapseBtn){ + collapseBtn.title=isCompact?'Close workspace panel':'Hide workspace panel'; + } + const hasSession=!!S.session; + ['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{ + const el=$(id); + if(el)el.disabled=!hasSession; + }); + const clearBtn=$('btnClearPreview'); + if(clearBtn){ + clearBtn.disabled=!isOpen; + clearBtn.title=hasPreview?'Close preview':'Hide workspace panel'; + // On desktop, only show the X button when a file preview is open. + // In browse mode the chevron (btnCollapseWorkspacePanel) already serves + // as the close control, so showing both produces a duplicate X. + if(!isCompact) clearBtn.style.display=hasPreview?'':'none'; + } +} + +function toggleMobileSidebar(){ + const sidebar=document.querySelector('.sidebar'); + const overlay=$('mobileOverlay'); + if(!sidebar)return; + const isOpen=sidebar.classList.contains('mobile-open'); + if(isOpen){closeMobileSidebar();} + else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');} +} +function closeMobileSidebar(){ + const sidebar=document.querySelector('.sidebar'); + const overlay=$('mobileOverlay'); + if(sidebar)sidebar.classList.remove('mobile-open'); + if(overlay)overlay.classList.remove('visible'); +} +function toggleMobileFiles(){ + toggleWorkspacePanel(undefined); +} +function toggleWorkspacePanel(force?: boolean){ + const {panel}= _workspacePanelEls(); + if(!panel)return; + const currentlyOpen=_workspacePanelMode!=='closed'; + const nextOpen=typeof force==='boolean'?force:!currentlyOpen; + if(!nextOpen){ + closeWorkspacePanel(); + return; + } + const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse'; + openWorkspacePanel(nextMode); +} +function mobileSwitchPanel(name){ + switchPanel(name); + if(name==='chat'){ + closeMobileSidebar(); + } else { + const sidebar=document.querySelector('.sidebar'); + const overlay=$('mobileOverlay'); + if(sidebar){ + sidebar.classList.add('mobile-open'); + if(overlay)overlay.classList.add('visible'); + } + } +} + +$('btnSend').onclick=()=>{ + if(window._micActive){ + window._micPendingSend=true; + window._stopMic(); + return; + } + send(); +}; +$('btnAttach').onclick=()=>$('fileInput').click(); + +// ── Voice input (Web Speech API + MediaRecorder fallback) ─────────────────── +(function(){ + const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; + const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder); + if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden + + // Persist SR failure across reloads (e.g. Tailscale/network error) + const _micForceMediaRecorderKey='mic_force_mediarecorder'; + let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1'; + + const btn=$('btnMic'); + const status=$('micStatus'); + const ta=$('msg'); + const statusText=status?status.querySelector('.status-text'):null; + btn.style.display=''; // Show button — browser supports speech recognition or recording fallback + + let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null; + let mediaRecorder=null; + let mediaStream=null; + let audioChunks=[]; + let _finalText=''; + let _prefix=''; + let _isRecording=false; + + function _setRecording(on){ + window._micActive=on; + btn.classList.toggle('recording',on); + status.style.display=on?'':'none'; + if(statusText) statusText.textContent=on?'Listening':'Listening'; + if(!on){ _finalText=''; _prefix=''; } + } + + function _commitTranscript(text){ + const clean=(text||'').trim(); + const committed=clean + ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') + ? _prefix+' '+clean.trimStart() + : _prefix+clean) + : ta.value; + ta.value=committed; + autoResize(); + if(window._micPendingSend){ + window._micPendingSend=false; + send(); + } + } + + async function _transcribeBlob(blob){ + const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm'; + const form=new FormData(); + form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`})); + setComposerStatus('Transcribing…'); + try{ + const res=await fetch('api/transcribe',{method:'POST',body:form}); + const data=await res.json().catch(()=>({})); + if(!res.ok) throw new Error(data.error||'Transcription failed'); + _commitTranscript(data.transcript||''); + }catch(err){ + window._micPendingSend=false; + showToast(err.message||t('mic_network')); + }finally{ + setComposerStatus(''); + } + } + + function _stopTracks(){ + if(mediaStream){ + mediaStream.getTracks().forEach(track=>track.stop()); + mediaStream=null; + } + } + + function _stopMic(){ + if(!window._micActive) return; + if(recognition){ + recognition.stop(); + return; + } + if(mediaRecorder&&mediaRecorder.state!=='inactive'){ + mediaRecorder.stop(); + return; + } + _setRecording(false); + _stopTracks(); + } + window._stopMic=_stopMic; // expose for send-guard above + + if(recognition && !_forceMediaRecorder){ + recognition.continuous=false; + recognition.interimResults=true; + recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; + + recognition.onstart=()=>{ _finalText=''; }; + + recognition.onresult=(event)=>{ + let interim=''; + let final=_finalText; + for(let i=event.resultIndex;i{ + const committed=_finalText + ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') + ? _prefix+' '+_finalText.trimStart() + : _prefix+_finalText) + : ta.value; + _setRecording(false); + ta.value=committed; + autoResize(); + if(window._micPendingSend){ + window._micPendingSend=false; + send(); + } + }; + + recognition.onerror=(event)=>{ + _setRecording(false); + window._micPendingSend=false; + _isRecording=false; + if(event.error==='network'||event.error==='not-allowed'){ + // Persist SR failure: next reload will skip SpeechRecognition + localStorage.setItem(_micForceMediaRecorderKey,'1'); + _forceMediaRecorder=true; + recognition=null; + } + const msgs={ + 'not-allowed':t('mic_denied'), + 'no-speech':t('mic_no_speech'), + 'network':t('mic_network'), + }; + showToast(msgs[event.error]||t('mic_error')+event.error); + }; + } + + btn.onclick=async()=>{ + // Race-condition guard: ignore rapid double-clicks + if(_isRecording){ + _stopMic(); + _isRecording=false; + return; + } + if(window._micActive){ + _stopMic(); + return; + } + _isRecording=true; + _finalText=''; + _prefix=ta.value; + if(recognition && !_forceMediaRecorder){ + recognition.start(); + _setRecording(true); + return; + } + if(!_canRecordAudio){ + _isRecording=false; + showToast(t('mic_network')); + return; + } + try{ + mediaStream=await navigator.mediaDevices.getUserMedia({audio:true}); + const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg']; + const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||''; + mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined); + audioChunks=[]; + mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);}; + mediaRecorder.onerror=()=>{ + _isRecording=false; + _setRecording(false); + window._micPendingSend=false; + _stopTracks(); + showToast(t('mic_network')); + }; + mediaRecorder.onstop=async()=>{ + _isRecording=false; + const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'}); + _setRecording(false); + _stopTracks(); + if(blob.size){ await _transcribeBlob(blob); } + else if(window._micPendingSend){ + window._micPendingSend=false; + } + }; + mediaRecorder.start(); + _setRecording(true); + }catch(err){ + _isRecording=false; + window._micPendingSend=false; + _stopTracks(); + showToast(t('mic_denied')); + } + }; +})(); +window._micActive=window._micActive||false; +window._micPendingSend=window._micPendingSend||false; +$('fileInput').onchange=e=>{const t=e.target as HTMLInputElement;addFiles(Array.from(t.files||[]));t.value='';}; +$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}; +$('btnDownload').onclick=()=>{ + if(!S.session)return; + const blob=new Blob([transcript()],{type:'text/markdown'}); + const a=document.createElement('a');a.href=URL.createObjectURL(blob); + a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href); +}; +$('btnExportJSON').onclick=()=>{ + if(!S.session)return; + const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`; + const a=document.createElement('a');a.href=url; + a.download=`hermes-${S.session.session_id}.json`;a.click(); +}; +$('btnImportJSON').onclick=()=>$('importFileInput').click(); +$('importFileInput').onchange=async(e)=>{ + const inputEl=e.target as HTMLInputElement; + const file=inputEl.files&&inputEl.files[0]; + if(!file)return; + inputEl.value=''; + try{ + const text=await file.text(); + const data=JSON.parse(text); + const res=await api('/api/session/import',{method:'POST',body:JSON.stringify(data)}); + if(res.ok&&res.session){ + await loadSession(res.session.session_id); + await renderSessionList(); + const overlay=$('settingsOverlay'); + if(overlay) overlay.style.display='none'; + showToast(t('session_imported')); + } + }catch(err){ + showToast(t('import_failed')+(err.message||t('import_invalid_json'))); + } +}; +// btnRefreshFiles is now panel-icon-btn in header (see HTML) +function clearPreview(){ + const closePanelAfter=_workspacePanelMode==='preview'; + const pa=$('previewArea');if(pa)pa.classList.remove('visible'); + const pi=$('previewImg') as HTMLImageElement|null;if(pi){pi.onerror=null;pi.src='';} + const pm=$('previewMd');if(pm)pm.innerHTML=''; + 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=''; + window._previewCurrentPath='';window._previewCurrentMode='';window._previewDirty=false; + // Restore directory breadcrumb after closing file preview + if(typeof renderBreadcrumb==='function') renderBreadcrumb(); + if(closePanelAfter)closeWorkspacePanel(); + else syncWorkspacePanelUI(); +} +$('btnClearPreview').onclick=handleWorkspaceClose; +// workspacePath click handler removed -- use topbar workspace chip dropdown instead +$('modelSelect').onchange=async()=>{ + if(!S.session)return; + const selectedModel=$('modelSelect').value; + if(typeof closeModelDropdown==='function') closeModelDropdown(); + localStorage.setItem('hermes-webui-model', selectedModel); + await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})}); + S.session.model=selectedModel; + if(typeof syncModelChip==='function') syncModelChip(); + if(typeof syncAgentChip==='function') syncAgentChip(); + syncTopbar(); + // Warn if selected model belongs to a different provider than what Hermes is configured for + if(typeof _checkProviderMismatch==='function'){ + const warn=_checkProviderMismatch(selectedModel); + if(warn&&typeof showToast==='function') showToast(warn,4000); + } + // Notify user that model changes only take effect in the next conversation (#419) + if(S.messages && S.messages.length > 0 && typeof showToast==='function'){ + showToast('Model change takes effect in your next conversation', 3000); + } +}; +$('msg').addEventListener('input',()=>{ + autoResize(); + updateSendBtn(); + const text=$('msg').value; + if(text.startsWith('/')&&text.indexOf('\n')===-1){ + const prefix=text.slice(1); + const matches=getMatchingCommands(prefix); + if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); + if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete(); + } else { + hideCmdDropdown(); + } +}); +$('msg').addEventListener('keydown',e=>{ + // Autocomplete navigation when dropdown is open + const dd=$('cmdDropdown'); + const dropdownOpen=dd&&dd.classList.contains('open'); + if(dropdownOpen){ + if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;} + if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;} + if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;} + if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;} + if(e.key==='Enter'&&!e.shiftKey){ + if(e.isComposing){return;} + e.preventDefault(); + selectCmdDropdownItem(); + return; + } + } + // Send key: respect user preference. + // On touch-primary devices (software keyboard), default to Enter = newline + // since there's no physical Shift key. Users send via the Send button. + // The 'ctrl+enter' setting also uses this behavior (Enter = newline). + // Users can override in Settings by explicitly choosing 'enter' mode. + if(e.key==='Enter'){ + if(e.isComposing){return;} + const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'; + if(window._sendKey==='ctrl+enter'||_mobileDefault){ + if(e.ctrlKey||e.metaKey){e.preventDefault();send();} + } else { + if(!e.shiftKey){e.preventDefault();send();} + } + } +}); +// B14: Cmd/Ctrl+K creates a new chat from anywhere +document.addEventListener('keydown',async e=>{ + // Enter on approval card = Allow once (when a button inside the card is focused or + // card is visible and focus is not on an input/textarea/select) + if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){ + const card=$('approvalCard'); + const tag=(document.activeElement||{}).tagName||''; + if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){ + e.preventDefault(); + if(typeof respondApproval==='function') respondApproval('once'); + return; + } + } + if((e.metaKey||e.ctrlKey)&&e.key==='k'){ + e.preventDefault(); + if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();} + } + if(e.key==='Escape'){ + // Close onboarding overlay if open (skip/dismiss the wizard) + const onboardingOverlay=$('onboardingOverlay'); + if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){ + if(typeof skipOnboarding==='function') skipOnboarding(); + return; + } + // Close settings overlay if open + const settingsOverlay=$('settingsOverlay'); + if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;} + // Close workspace dropdown + closeWsDropdown(); + // Clear session search + const ss=$('sessionSearch'); + if(ss&&ss.value){ss.value='';filterSessions();} + // Cancel any active message edit + const editArea=document.querySelector('.msg-edit-area'); + if(editArea){ + const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar'); + if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)(cancel as unknown as HTMLElement).click();} + } + } +}); +$('msg').addEventListener('paste',e=>{ + const items=Array.from(e.clipboardData?.items||[]); + const imageItems=items.filter(i=>(i as DataTransferItem).type.startsWith('image/')); + if(!imageItems.length)return; + e.preventDefault(); + const files=imageItems.map(i=>{ + const blob=(i as DataTransferItem).getAsFile(); + const ext=(i as DataTransferItem).type.split('/')[1]||'png'; + return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:(i as DataTransferItem).type}); + }); + addFiles(files); + setStatus(t('image_pasted')+files.map(f=>f.name).join(', ')); +}); +document.querySelectorAll('.suggestion').forEach(btn=>{ + (btn as unknown as HTMLElement).onclick=()=>{($('msg') as HTMLInputElement).value=(btn as unknown as HTMLElement).dataset.msg||'';send();}; +}); + +window.addEventListener('resize',()=>{ + syncWorkspacePanelState(); +}); + +// Boot: restore last session or start fresh +// ── Resizable panels ────────────────────────────────────────────────────── +(function(){ + const SIDEBAR_MIN=180, SIDEBAR_MAX=420; + const PANEL_MIN=180, PANEL_MAX=1200; + + function initResize(handleId, targetEl, edge, minW, maxW, storageKey){ + const handle = $(handleId); + if(!handle || !targetEl) return; + + // Restore saved width + const saved = localStorage.getItem(storageKey); + if(saved) targetEl.style.width = saved + 'px'; + + let startX=0, startW=0; + + handle.addEventListener('mousedown', e=>{ + e.preventDefault(); + startX = e.clientX; + startW = targetEl.getBoundingClientRect().width; + handle.classList.add('dragging'); + document.body.classList.add('resizing'); + + const onMove = ev=>{ + const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX; + const newW = Math.min(maxW, Math.max(minW, startW + delta)); + targetEl.style.width = newW + 'px'; + }; + const onUp = ()=>{ + handle.classList.remove('dragging'); + document.body.classList.remove('resizing'); + localStorage.setItem(storageKey, parseInt(targetEl.style.width).toString()); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + + // Run after DOM ready (called from boot) + window._initResizePanels = function(){ + const sidebar = document.querySelector('.sidebar'); + const rightpanel = document.querySelector('.rightpanel'); + initResize('sidebarResize', sidebar, 'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w'); + initResize('rightpanelResize', rightpanel, 'left', PANEL_MIN, PANEL_MAX, 'hermes-panel-w'); + }; +})(); + +// ── Appearance helpers (theme = light/dark/system, skin = accent color) ────── +const _SKINS=[ + {name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']}, + {name:'Ares', colors:['#FF4444','#CC3333','#992222']}, + {name:'Mono', colors:['#CCCCCC','#999999','#666666']}, + {name:'Slate', colors:['#334155','#475569','#64748b']}, + {name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']}, + {name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']}, + {name:'Charizard',colors:['#FB923C','#F97316','#EA580C']}, +]; +const _VALID_THEMES=new Set(['system','dark','light']); +const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase())); +const _LEGACY_THEME_MAP={ + slate:{theme:'dark',skin:'slate'}, + solarized:{theme:'dark',skin:'poseidon'}, + monokai:{theme:'dark',skin:'sisyphus'}, + nord:{theme:'dark',skin:'slate'}, + oled:{theme:'dark',skin:'default'}, +}; +let _systemThemeMq=null; +let _onSystemThemeChange=null; + +function _normalizeAppearance(theme,skin){ + const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():''; + const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():''; + const legacy=_LEGACY_THEME_MAP[rawTheme]; + const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark'); + const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default'); + return {theme:nextTheme,skin:nextSkin}; +} + +function _setResolvedTheme(isDark){ + document.documentElement.classList.toggle('dark',!!isDark); + const link=document.getElementById('prism-theme') as HTMLLinkElement | null; + if(!link) return; + const want=isDark + ?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css' + :'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css'; + if(link.href!==want){ link.href=want; } +} + +function _applyTheme(name){ + const normalized=_normalizeAppearance(name,'default'); + if(_systemThemeMq&&_onSystemThemeChange){ + _systemThemeMq.removeEventListener('change',_onSystemThemeChange); + _systemThemeMq=null; + _onSystemThemeChange=null; + } + if(normalized.theme==='system'){ + _systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)'); + _onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches); + _setResolvedTheme(_systemThemeMq.matches); + _systemThemeMq.addEventListener('change',_onSystemThemeChange); + return; + } + _setResolvedTheme(normalized.theme==='dark'); +} + +function _applySkin(name){ + const key=(name||'default').toLowerCase(); + if(key==='default') delete document.documentElement.dataset.skin; + else document.documentElement.dataset.skin=key; +} + +function _pickTheme(name){ + const currentSkin=localStorage.getItem('hermes-skin'); + const appearance=_normalizeAppearance(name,currentSkin); + localStorage.setItem('hermes-theme',appearance.theme); + localStorage.setItem('hermes-skin',appearance.skin); + _applyTheme(appearance.theme); + _applySkin(appearance.skin); + _syncThemePicker(appearance.theme); + _syncSkinPicker(appearance.skin); + if(typeof _markSettingsDirty==='function') _markSettingsDirty(); + const hidden=$('settingsTheme'); + if(hidden) hidden.value=appearance.theme; + const skinHidden=$('settingsSkin'); + if(skinHidden) skinHidden.value=appearance.skin; +} + +function _pickSkin(name){ + const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name); + localStorage.setItem('hermes-theme',appearance.theme); + localStorage.setItem('hermes-skin',appearance.skin); + _applyTheme(appearance.theme); + _applySkin(appearance.skin); + _syncThemePicker(appearance.theme); + _syncSkinPicker(appearance.skin); + if(typeof _markSettingsDirty==='function') _markSettingsDirty(); + const hidden=$('settingsSkin'); + if(hidden) hidden.value=appearance.skin; + const themeHidden=$('settingsTheme'); + if(themeHidden) themeHidden.value=appearance.theme; +} + +function _syncThemePicker(active){ + document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{ + const el=btn as unknown as HTMLElement; + const sel=el.dataset.themeVal===active; + el.style.borderColor=sel?'var(--accent)':'var(--border2)'; + el.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none'; + }); +} + +function _syncSkinPicker(active){ + document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{ + const el=btn as unknown as HTMLElement; + const sel=el.dataset.skinVal===active; + el.style.borderColor=sel?'var(--accent)':'var(--border2)'; + el.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none'; + }); +} + +function _buildSkinPicker(activeSkin){ + const grid=$('skinPickerGrid'); + if(!grid) return; + grid.innerHTML=''; + for(const skin of _SKINS){ + const key=skin.name.toLowerCase(); + const btn=document.createElement('button'); + btn.type='button'; + btn.className='skin-pick-btn'; + btn.dataset.skinVal=key; + btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s'; + btn.onclick=()=>_pickSkin(skin.name); + const dots=skin.colors.map(c=>``).join(''); + btn.innerHTML=`
${dots}
${skin.name}`; + grid.appendChild(btn); + } + _syncSkinPicker((activeSkin||'default').toLowerCase()); +} + +function applyBotName(){ + const name=window._botName||'Hermes'; + document.title=name; + const sidebarH1=document.querySelector('.sidebar-header h1'); + if(sidebarH1) sidebarH1.textContent=name; + const logo=document.querySelector('.sidebar-header .logo'); + if(logo) logo.textContent=name.charAt(0).toUpperCase(); + const topbarTitle=$('topbarTitle'); + if(topbarTitle && (!S.session)) topbarTitle.textContent=name; + const msg=$('msg'); + if(msg) msg.placeholder='Message '+name+'\u2026'; +} + +(async()=>{ + // Load send key preference + let _bootSettings={}; + try{ + const s=await api('/api/settings'); + _bootSettings=s; + window._sendKey=s.send_key||'enter'; + window._showTokenUsage=!!s.show_token_usage; + window._showCliSessions=!!s.show_cli_sessions; + window._soundEnabled=!!s.sound_enabled; + window._notificationsEnabled=!!s.notifications_enabled; + window._botName=s.bot_name||'Hermes'; + const appearance=_normalizeAppearance(s.theme,s.skin); + localStorage.setItem('hermes-theme',appearance.theme); + _applyTheme(appearance.theme); + localStorage.setItem('hermes-skin',appearance.skin); + _applySkin(appearance.skin); + document.body.classList.toggle('bubble-layout', s.bubble_layout !== false); + if(typeof setLocale==='function'){ + const _lang=typeof resolvePreferredLocale==='function' + ? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang')) + : (s.language || localStorage.getItem('hermes-lang') || 'en'); + setLocale(_lang); + if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); + } + applyBotName(); + }catch(e){ + window._sendKey='enter'; + window._showTokenUsage=false; + window._showCliSessions=false; + window._soundEnabled=false; + window._notificationsEnabled=false; + window._botName='Hermes'; + _bootSettings={check_for_updates:false}; + document.body.classList.toggle('bubble-layout', _bootSettings.bubble_layout !== false); + if(typeof setLocale==='function'){ + const _lang=typeof resolvePreferredLocale==='function' + ? resolvePreferredLocale(null, localStorage.getItem('hermes-lang')) + : (localStorage.getItem('hermes-lang') || 'en'); + setLocale(_lang); + if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); + } + applyBotName(); + } + // Non-blocking update check (fire-and-forget, once per tab session) + // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) + const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; + if(_testUpdates||((_bootSettings as any).check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){ + const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':''); + api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); + } + // Fetch active profile + try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} + // Update profile chip label immediately + const profileLabel=$('profileChipLabel'); + 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')){ + $('modelSelect').value=savedModel; + // If the value didn't take (model not in list), clear the bad pref + if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model'); + } + // Pre-load workspace list so sidebar name is correct from first render + await loadWorkspaceList(); + await loadOnboardingWizard(); + window._initResizePanels(); + // Workspace panel restore happens AFTER loadSession so we know if + // the session has a workspace — prevents the snap-open-then-closed flash (#576). + const saved=localStorage.getItem('hermes-webui-session'); + if(saved){ + try{ + await loadSession(saved); + // Only restore the panel from localStorage when the session actually has a workspace. + // Without this guard, sessions without a workspace snap open then immediately closed. + if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){ + _workspacePanelMode='browse'; + } + S._bootReady=true; + syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;} + catch(e){localStorage.removeItem('hermes-webui-session');} + } + // no saved session - show empty state, wait for user to hit + + S._bootReady=true; + syncTopbar(); + syncWorkspacePanelState(); + $('emptyState').style.display=''; + await renderSessionList(); + // Start real-time gateway session sync if setting is enabled + if(typeof startGatewaySSE==='function') startGatewaySSE(); +})(); diff --git a/static/commands.js b/static/commands.js index 5f25b03..cfc9907 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1,379 +1,400 @@ -// ── Slash commands ────────────────────────────────────────────────────────── -// 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. - -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=[]; +(() => { + 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" + ]; + let COMMANDS = []; + const AGENT_INFO = { + "sunflower": { emoji: "\u{1F33B}", name: "Sunflower", file: "sunflower/soul.md", domain: "Finance, Wealth & Subscriptions" }, + "lotus": { emoji: "\u{1F9D7}", name: "Lotus", file: "lotus/soul.md", domain: "Health, Fitness & Recovery" }, + "forget-me-not": { emoji: "\u{1F33C}", name: "Forget-me-not", file: "forget-me-not/soul.md", domain: "Calendar, Time & Social" }, + "iris": { emoji: "\u2695\uFE0F", name: "Iris", file: "iris/soul.md", domain: "Career, Learning & Focus" }, + "ivy": { emoji: "\u{1F33F}", name: "Ivy", file: "ivy/soul.md", domain: "Smart Home & Environment" }, + "dandelion": { emoji: "\u{1F6E1}", name: "Dandelion", file: "dandelion/soul.md", domain: "Communication Triage & Gatekeeping" }, + "root": { emoji: "\u{1F333}", name: "Root", file: "root/soul.md", domain: "DevOps, Logs & System Health" }, + "back": { emoji: "\u{1F339}", name: "Rose", file: "rose/soul.md", domain: "Orchestrator (return from agent)" } + }; + 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; + return cmdPassthrough; } -} - -function parseCommand(text){ - if(!text.startsWith('/'))return null; - const parts=text.slice(1).split(/\s+/); - const name=parts[0].toLowerCase(); - const args=parts.slice(1).join(' ').trim(); - return {name,args}; -} - -function executeCommand(text){ - const parsed=parseCommand(text); - if(!parsed)return false; - const cmd=COMMANDS.find(c=>c.name===parsed.name); - if(!cmd)return false; - cmd.fn(parsed.args); - return true; -} - -function getMatchingCommands(prefix){ - const q=prefix.toLowerCase(); - 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; - }); -} - -// ── 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 ──────────────────────────────────────────────────── - -function cmdHelp(){ - // 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 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}`); + async function loadCommands() { + try { + const data = await api("/api/commands"); + if (data.error) throw new Error(data.error); + const cats = data.categories || {}; + const merged = []; + for (const [, 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) }); + } + } + const agentNames = ["sunflower", "lotus", "forget-me-not", "iris", "ivy", "dandelion", "root", "back", "inbox"]; + const filtered = merged.filter((c) => !agentNames.includes(c.name)); + filtered.push( + { name: "sunflower", desc: "\u{1F33B} Finance, Wealth & Subscriptions", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "lotus", desc: "\u{1F9D7} Health, Fitness & Recovery", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "forget-me-not", desc: "\u{1F33C} Calendar, Time & Social", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "iris", desc: "\u2695\uFE0F Career, Learning & Focus", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "ivy", desc: "\u{1F33F} Smart Home & Environment", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "dandelion", desc: "\u{1F6E1} Communication Triage & Gatekeeping", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "root", desc: "\u{1F333} DevOps, Logs & System Health", fn: cmdAgent, arg: "message", aliases: [] }, + { name: "back", desc: "\u{1F339} Return to Rose (orchestrator)", fn: cmdAgent, arg: "message", aliases: [] } + ); + COMMANDS = filtered; + } catch (e) { + console.warn("[commands] Failed to load from API, using fallback:", e instanceof Error ? e.message : String(e)); + COMMANDS = []; + } + } + function parseCommand(text) { + if (!text.startsWith("/")) return null; + const parts = text.slice(1).split(/\s+/); + const name = parts[0].toLowerCase(); + const args = parts.slice(1).join(" ").trim(); + return { name, args }; + } + function executeCommand(text) { + const parsed = parseCommand(text); + if (!parsed) return false; + const cmd = COMMANDS.find((c) => c.name === parsed.name); + if (!cmd) return false; + cmd.fn(parsed.args); + return true; + } + function getMatchingCommands(prefix) { + const q = prefix.toLowerCase(); + return COMMANDS.filter((c) => { + if (c.name.startsWith(q)) return true; + if (c.aliases && c.aliases.some((a) => a.startsWith(q))) return true; + return false; }); } - const msg={role:'assistant',content:'Available commands:\n'+lines.join('\n')}; - S.messages.push(msg); - renderMessages(); - showToast('Type / to see commands'); -} - -function cmdClear(){ - if(!S.session)return; - S.messages=[];S.toolCalls=[]; - clearLiveToolCards(); - renderMessages(); - $('emptyState').style.display=''; - showToast(t('conversation_cleared')); -} - -async function cmdModel(args){ - if(!args){showToast('Usage: /model ');return;} - const sel=$('modelSelect'); - if(!sel)return; - const q=args.toLowerCase(); - let match=null; - for(const opt of sel.options){ - if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){ - match=opt.value;break; - } + function cmdPassthrough(_args) { + const msgEl = $("msg"); + if (!msgEl) return; + const parsed = parseCommand(msgEl.value); + if (!parsed) return; + send(); } - 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('Usage: /workspace ');return;} - try{ - const data=await api('/api/workspaces'); - const q=args.toLowerCase(); - const ws=(data.workspaces||[]).find(w=> - (w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q) - ); - if(!ws){showToast('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(){ - await newSession(); - await renderSessionList(); - $('msg').focus(); - showToast(t('new_session')); -} - -function cmdCompact(){ - $('msg').value='Please compress and summarize the conversation context to free up space.'; - send(); - showToast(t('compressing')); -} - -async function cmdUsage(){ - const next=!window._showTokenUsage; - window._showTokenUsage=next; - try{ - await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})}); - }catch(e){} - const cb=$('settingsShowTokenUsage'); - if(cb) cb.checked=next; - renderMessages(); - showToast(next?t('token_usage_on'):t('token_usage_off')); -} - -async function cmdTheme(args){ - const themes=['system','dark','light','slate','solarized','monokai','nord','oled']; - if(!args||!themes.includes(args.toLowerCase())){ - showToast('Themes: '+themes.join(' | ')); - return; - } - 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){ - try{ - const data = await api('/api/skills'); - let skills = data.skills || []; - if(args){ - const q = args.toLowerCase(); - skills = skills.filter(s => - (s.name||'').toLowerCase().includes(q) || - (s.description||'').toLowerCase().includes(q) || - (s.category||'').toLowerCase().includes(q) - ); - } - if(!skills.length){ - const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'}; - S.messages.push(msg); renderMessages(); return; - } - const byCategory = {}; - skills.forEach(s => { - const cat = s.category || 'General'; - if(!byCategory[cat]) byCategory[cat] = []; - byCategory[cat].push(s); + function cmdHelp() { + 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 lines = []; - for(const [cat, items] of Object.entries(byCategory).sort()){ - lines.push(`**${cat}**`); - items.forEach(s => { - const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : ''; - lines.push(` \`${s.name}\`${desc}`); + for (const [cat, cmds] of Object.entries(categories)) { + if (!cmds.length) continue; + lines.push(` +**${cat}**`); + cmds.forEach((c) => { + const usage = c.arg && c.arg !== "(none)" ? ` <${c.arg}>` : ""; + lines.push(` /${c.name}${usage} \u2014 ${c.desc}`); }); - lines.push(''); } - const header = args - ? `Skills matching "${args}" (${skills.length}):\n\n` - : `Available skills (${skills.length}):\n\n`; - S.messages.push({role:'assistant', content: header + lines.join('\n')}); + const msg = { role: "assistant", content: "Available commands:\n" + lines.join("\n") }; + S.messages.push(msg); renderMessages(); - }catch(e){ - showToast('Failed to load skills: '+e.message); + showToast(t("type_slash")); } -} - -async function cmdPersonality(args){ - if(!S.session){showToast(t('no_active_session'));return;} - if(!args){ - try{ - const data=await api('/api/personalities'); - if(!data.personalities||!data.personalities.length){ - showToast(t('no_personalities')); + function cmdClear() { + if (!S.session) return; + S.messages = []; + S.toolCalls = []; + clearLiveToolCards(); + renderMessages(); + const emptyState = $("emptyState"); + if (emptyState) emptyState.style.display = ""; + showToast(t("conversation_cleared")); + } + async function cmdModel(args) { + if (!args) { + showToast("Usage: /model "); + return; + } + const sel = $("modelSelect"); + if (!sel) return; + const q = args.toLowerCase(); + 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('No model matching "' + args + '"'); + return; + } + sel.value = match; + if (sel.onchange) await sel.onchange(null); + showToast(t("switched_to") + match); + } + async function cmdWorkspace(args) { + if (!args) { + showToast("Usage: /workspace "); + return; + } + try { + const data = await api("/api/workspaces"); + const q = args.toLowerCase(); + const ws = (data.workspaces || []).find( + (w) => (w.name || "").toLowerCase().includes(q) || w.path.toLowerCase().includes(q) + ); + if (!ws) { + showToast('No workspace matching "' + 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+'\n\nSwitch with: /personality '}); + 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 instanceof Error ? e.message : String(e))); + } + } + async function cmdNew() { + await newSession(); + await renderSessionList(); + const msgEl = $("msg"); + if (msgEl) msgEl.focus(); + showToast(t("new_session")); + } + function cmdCompact() { + const msgEl = $("msg"); + if (msgEl) msgEl.value = "Please compress and summarize the conversation context to free up space."; + send(); + showToast(t("compressing")); + } + async function cmdUsage() { + const next = !window._showTokenUsage; + window._showTokenUsage = next; + try { + await api("/api/settings", { method: "POST", body: JSON.stringify({ show_token_usage: next }) }); + } catch { + } + const cb = $("settingsShowTokenUsage"); + if (cb) cb.checked = next; + renderMessages(); + showToast(next ? t("token_usage_on") : t("token_usage_off")); + } + async function cmdTheme(args) { + const themes = ["system", "dark", "light", "slate", "solarized", "monokai", "nord", "oled"]; + if (!args || !themes.includes(args.toLowerCase())) { + showToast("Themes: " + themes.join(" | ")); + return; + } + const themeName = args.toLowerCase(); + localStorage.setItem("hermes-theme", themeName); + _applyTheme(themeName); + try { + await api("/api/settings", { method: "POST", body: JSON.stringify({ theme: themeName }) }); + } catch { + } + const sel = $("settingsTheme"); + if (sel) sel.value = themeName; + showToast(t("theme_set") + themeName); + } + async function cmdSkills(args) { + try { + const data = await api("/api/skills"); + let skills = data.skills || []; + if (args) { + const q = args.toLowerCase(); + skills = skills.filter( + (s) => (s.name || "").toLowerCase().includes(q) || (s.description || "").toLowerCase().includes(q) || (s.category || "").toLowerCase().includes(q) + ); + } + if (!skills.length) { + const msg = { role: "assistant", content: args ? `No skills matching "${args}".` : "No skills found." }; + S.messages.push(msg); + renderMessages(); + return; + } + const byCategory = {}; + skills.forEach((s) => { + const cat = s.category || "General"; + if (!byCategory[cat]) byCategory[cat] = []; + byCategory[cat].push(s); + }); + const lines = []; + for (const [cat, items] of Object.entries(byCategory).sort()) { + lines.push(`**${cat}**`); + items.forEach((s) => { + const desc = s.description ? ` \u2014 ${s.description.slice(0, 80)}${s.description.length > 80 ? "..." : ""}` : ""; + lines.push(` \`${s.name}\`${desc}`); + }); + lines.push(""); + } + const header = args ? `Skills matching "${args}" (${skills.length}): + +` : `Available skills (${skills.length}): + +`; + S.messages.push({ role: "assistant", content: header + lines.join("\n") }); renderMessages(); - }catch(e){showToast(t('personalities_load_failed'));} - return; + } catch (e) { + showToast("Failed to load skills: " + (e instanceof Error ? e.message : String(e))); + } } - const name=args.trim(); - if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){ - try{ - await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})}); - showToast(t('personality_cleared')); - }catch(e){showToast(t('failed_colon')+e.message);} - return; + async function cmdPersonality(args) { + if (!S.session) { + showToast(t("no_active_session")); + return; + } + if (!args) { + try { + const data = await api("/api/personalities"); + if (!data.personalities || !data.personalities.length) { + showToast(t("no_personalities")); + return; + } + const list = data.personalities.map((p) => ` **${p.name}**${p.description ? " \u2014 " + p.description : ""}`).join("\n"); + S.messages.push({ role: "assistant", content: t("available_personalities") + "\n\n" + list + "\n\nSwitch with: /personality " }); + renderMessages(); + } catch { + showToast(t("personalities_load_failed")); + } + return; + } + const name = args.trim(); + if (["none", "default", "clear"].includes(name.toLowerCase())) { + try { + await api("/api/personality/set", { method: "POST", body: JSON.stringify({ session_id: S.session.session_id, name: "" }) }); + showToast(t("personality_cleared")); + } catch (e) { + showToast(t("failed_colon") + (e instanceof Error ? e.message : String(e))); + } + return; + } + try { + await api("/api/personality/set", { method: "POST", body: JSON.stringify({ session_id: S.session.session_id, name }) }); + showToast(t("personality_set") + name); + } catch (e) { + showToast(t("failed_colon") + (e instanceof Error ? e.message : String(e))); + } } - try{ - const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})}); - showToast(t('personality_set')+name); - }catch(e){showToast(t('failed_colon')+e.message);} -} - -// ── 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 ─────────────────────────────────────────────── - -let _cmdSelectedIdx=-1; - -function showCmdDropdown(matches){ - const dd=$('cmdDropdown'); - if(!dd)return; - dd.innerHTML=''; - _cmdSelectedIdx=-1; - for(let i=0;i${esc(c.arg)}`:''; - el.innerHTML=`
/${esc(c.name)}${usage}
${esc(c.desc)}
`; - el.onmousedown=(e)=>{ - e.preventDefault(); - $('msg').value='/'+c.name+(c.arg&&c.arg!=='(none)'?' ':''); - hideCmdDropdown(); - $('msg').focus(); - }; - dd.appendChild(el); + function cmdAgent(_args) { + const msgEl = $("msg"); + if (!msgEl) return; + const parsed = parseCommand(msgEl.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}] +Load ~/.hermes/agents/${info.file} and handle this request as ${info.name} (${info.domain}).${userMsg ? "\n\nUser message: " + userMsg : ""}`; + msgEl.value = contextMsg; + send(); } - dd.classList.add('open'); -} - -function hideCmdDropdown(){ - const dd=$('cmdDropdown'); - if(dd)dd.classList.remove('open'); - _cmdSelectedIdx=-1; -} - -function navigateCmdDropdown(dir){ - const dd=$('cmdDropdown'); - if(!dd)return; - const items=dd.querySelectorAll('.cmd-item'); - if(!items.length)return; - items.forEach(el=>el.classList.remove('selected')); - _cmdSelectedIdx+=dir; - if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1; - if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0; - items[_cmdSelectedIdx].classList.add('selected'); -} - -function selectCmdDropdownItem(){ - const dd=$('cmdDropdown'); - if(!dd)return; - const items=dd.querySelectorAll('.cmd-item'); - if(_cmdSelectedIdx>=0&&_cmdSelectedIdx{}}); - } else if(items.length===1){ - items[0].onmousedown({preventDefault:()=>{}}); + let _cmdSelectedIdx = -1; + function showCmdDropdown(matches) { + const dd = $("cmdDropdown"); + if (!dd) return; + dd.innerHTML = ""; + _cmdSelectedIdx = -1; + for (let i = 0; i < matches.length; i++) { + const c = matches[i]; + const el = document.createElement("div"); + el.className = "cmd-item"; + el.dataset.idx = String(i); + const usage = c.arg && c.arg !== "(none)" ? ` ${esc(c.arg)}` : ""; + el.innerHTML = `
/${esc(c.name)}${usage}
${esc(c.desc)}
`; + el.addEventListener("mousedown", (e) => { + e.preventDefault(); + const msgEl2 = $("msg"); + if (msgEl2) { + msgEl2.value = "/" + c.name + (c.arg && c.arg !== "(none)" ? " " : ""); + msgEl2.focus(); + } + hideCmdDropdown(); + }); + dd.appendChild(el); + } + dd.classList.add("open"); } - hideCmdDropdown(); -} + function hideCmdDropdown() { + const dd = $("cmdDropdown"); + if (dd) dd.classList.remove("open"); + _cmdSelectedIdx = -1; + } + function navigateCmdDropdown(dir) { + const dd = $("cmdDropdown"); + if (!dd) return; + const items = dd.querySelectorAll(".cmd-item"); + if (!items.length) return; + items.forEach((el) => el.classList.remove("selected")); + _cmdSelectedIdx += dir; + if (_cmdSelectedIdx < 0) _cmdSelectedIdx = items.length - 1; + if (_cmdSelectedIdx >= items.length) _cmdSelectedIdx = 0; + items[_cmdSelectedIdx].classList.add("selected"); + } + function selectCmdDropdownItem() { + const dd = $("cmdDropdown"); + if (!dd) return; + const items = dd.querySelectorAll(".cmd-item"); + if (_cmdSelectedIdx >= 0 && _cmdSelectedIdx < items.length) { + const item = items[_cmdSelectedIdx]; + const ev = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + Object.defineProperty(ev, "preventDefault", { value: () => { + } }); + item.dispatchEvent(ev); + } else if (items.length === 1) { + const ev = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + Object.defineProperty(ev, "preventDefault", { value: () => { + } }); + items[0].dispatchEvent(ev); + } + hideCmdDropdown(); + } + function _applyTheme(themeName) { + document.documentElement.dataset.theme = themeName; + } + window.loadCommands = loadCommands; +})(); +//# sourceMappingURL=commands.js.map diff --git a/static/commands.ts b/static/commands.ts new file mode 100644 index 0000000..f5348e6 --- /dev/null +++ b/static/commands.ts @@ -0,0 +1,358 @@ +/* commands.ts — Slash commands (/command) for the WebUI */ +/// + +interface CmdDef { + name: string; + desc: string; + arg: string; + aliases: string[]; + fn: (args: string) => void; +} + +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']; + +let COMMANDS: CmdDef[] = []; + +const AGENT_INFO: Record = { + 'sunflower': { emoji: '\uD83C\uDF3B', name: 'Sunflower', file: 'sunflower/soul.md', domain: 'Finance, Wealth & Subscriptions' }, + 'lotus': { emoji: '\uD83E\uDDD7', name: 'Lotus', file: 'lotus/soul.md', domain: 'Health, Fitness & Recovery' }, + 'forget-me-not': { emoji: '\uD83C\uDF3C', name: 'Forget-me-not', file: 'forget-me-not/soul.md', domain: 'Calendar, Time & Social' }, + 'iris': { emoji: '\u2695\uFE0F', name: 'Iris', file: 'iris/soul.md', domain: 'Career, Learning & Focus' }, + 'ivy': { emoji: '\uD83C\uDF3F', name: 'Ivy', file: 'ivy/soul.md', domain: 'Smart Home & Environment' }, + 'dandelion': { emoji: '\uD83D\uDEE1', name: 'Dandelion', file: 'dandelion/soul.md', domain: 'Communication Triage & Gatekeeping' }, + 'root': { emoji: '\uD83C\uDF33', name: 'Root', file: 'root/soul.md', domain: 'DevOps, Logs & System Health' }, + 'back': { emoji: '\uD83C\uDF39', name: 'Rose', file: 'rose/soul.md', domain: 'Orchestrator (return from agent)' }, +}; + +function _fnFor(name: string): (args: string) => void { + 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; + return cmdPassthrough; +} + +async function loadCommands(): Promise { + try { + const data = await api('/api/commands') as { error?: string; categories?: Record> }; + if (data.error) throw new Error(data.error); + const cats = data.categories || {}; + const merged: CmdDef[] = []; + for (const [, 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) }); + } + } + const agentNames = ['sunflower','lotus','forget-me-not','iris','ivy','dandelion','root','back','inbox']; + const filtered = merged.filter(c => !agentNames.includes(c.name)); + filtered.push( + { name: 'sunflower', desc: '\uD83C\uDF3B Finance, Wealth & Subscriptions', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'lotus', desc: '\uD83E\uDDD7 Health, Fitness & Recovery', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'forget-me-not', desc: '\uD83C\uDF3C Calendar, Time & Social', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'iris', desc: '\u2695\uFE0F Career, Learning & Focus', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'ivy', desc: '\uD83C\uDF3F Smart Home & Environment', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'dandelion', desc: '\uD83D\uDEE1 Communication Triage & Gatekeeping', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'root', desc: '\uD83C\uDF33 DevOps, Logs & System Health', fn: cmdAgent, arg: 'message', aliases: [] }, + { name: 'back', desc: '\uD83C\uDF39 Return to Rose (orchestrator)', fn: cmdAgent, arg: 'message', aliases: [] }, + ); + COMMANDS = filtered; + } catch (e: unknown) { + console.warn('[commands] Failed to load from API, using fallback:', (e instanceof Error) ? e.message : String(e)); + COMMANDS = []; + } +} + +interface ParsedCommand { name: string; args: string; } + +function parseCommand(text: string): ParsedCommand | null { + if (!text.startsWith('/')) return null; + const parts = text.slice(1).split(/\s+/); + const name = parts[0].toLowerCase(); + const args = parts.slice(1).join(' ').trim(); + return { name, args }; +} + +function executeCommand(text: string): boolean { + const parsed = parseCommand(text); + if (!parsed) return false; + const cmd = COMMANDS.find(c => c.name === parsed.name); + if (!cmd) return false; + cmd.fn(parsed.args); + return true; +} + +function getMatchingCommands(prefix: string): CmdDef[] { + const q = prefix.toLowerCase(); + return COMMANDS.filter(c => { + if (c.name.startsWith(q)) return true; + if (c.aliases && c.aliases.some(a => a.startsWith(q))) return true; + return false; + }); +} + +function cmdPassthrough(_args: string): void { + const msgEl = $('msg') as HTMLTextAreaElement | null; + if (!msgEl) return; + const parsed = parseCommand(msgEl.value); + if (!parsed) return; + send(); +} + +function cmdHelp(): void { + const categories: Record = { '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 lines: string[] = []; + 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} \u2014 ${c.desc}`); + }); + } + const msg = { role: 'assistant' as const, content: 'Available commands:\n' + lines.join('\n') }; + S.messages.push(msg); + renderMessages(); + showToast(t('type_slash')); +} + +function cmdClear(): void { + if (!S.session) return; + S.messages = []; S.toolCalls = []; + clearLiveToolCards(); + renderMessages(); + const emptyState = $('emptyState'); + if (emptyState) emptyState.style.display = ''; + showToast(t('conversation_cleared')); +} + +async function cmdModel(args: string): Promise { + if (!args) { showToast('Usage: /model '); return; } + const sel = $('modelSelect') as unknown as HTMLSelectElement | null; + if (!sel) return; + const q = args.toLowerCase(); + let match: string | null = 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('No model matching "' + args + '"'); return; } + sel.value = match; + if (sel.onchange) await (sel as HTMLSelectElement).onchange!(null as unknown as Event); + showToast(t('switched_to') + match); +} + +async function cmdWorkspace(args: string): Promise { + if (!args) { showToast('Usage: /workspace '); return; } + try { + const data = await api('/api/workspaces') as { workspaces?: Array<{ name?: string; path: string }> }; + const q = args.toLowerCase(); + const ws = (data.workspaces || []).find(w => + (w.name || '').toLowerCase().includes(q) || w.path.toLowerCase().includes(q) + ); + 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: unknown) { showToast(t('workspace_switch_failed') + ((e instanceof Error) ? e.message : String(e))); } +} + +async function cmdNew(): Promise { + await newSession(); + await renderSessionList(); + const msgEl = $('msg') as HTMLTextAreaElement | null; + if (msgEl) msgEl.focus(); + showToast(t('new_session')); +} + +function cmdCompact(): void { + const msgEl = $('msg') as HTMLTextAreaElement | null; + if (msgEl) msgEl.value = 'Please compress and summarize the conversation context to free up space.'; + send(); + showToast(t('compressing')); +} + +async function cmdUsage(): Promise { + const next = !window._showTokenUsage; + window._showTokenUsage = next; + try { await api('/api/settings', { method: 'POST', body: JSON.stringify({ show_token_usage: next }) }); } catch { /* noop */ } + const cb = $('settingsShowTokenUsage') as HTMLInputElement | null; + if (cb) cb.checked = next; + renderMessages(); + showToast(next ? t('token_usage_on') : t('token_usage_off')); +} + +async function cmdTheme(args: string): Promise { + const themes = ['system','dark','light','slate','solarized','monokai','nord','oled']; + if (!args || !themes.includes(args.toLowerCase())) { showToast('Themes: ' + themes.join(' | ')); return; } + const themeName = args.toLowerCase(); + localStorage.setItem('hermes-theme', themeName); + _applyTheme(themeName); + try { await api('/api/settings', { method: 'POST', body: JSON.stringify({ theme: themeName }) }); } catch { /* noop */ } + const sel = $('settingsTheme') as unknown as HTMLSelectElement | null; + if (sel) sel.value = themeName; + showToast(t('theme_set') + themeName); +} + +async function cmdSkills(args: string): Promise { + try { + const data = await api('/api/skills') as { skills?: Array<{ name?: string; description?: string; category?: string }> }; + let skills = data.skills || []; + if (args) { + const q = args.toLowerCase(); + skills = skills.filter(s => + (s.name || '').toLowerCase().includes(q) || + (s.description || '').toLowerCase().includes(q) || + (s.category || '').toLowerCase().includes(q) + ); + } + if (!skills.length) { + const msg = { role: 'assistant' as const, content: args ? `No skills matching "${args}".` : 'No skills found.' }; + S.messages.push(msg); renderMessages(); return; + } + const byCategory: Record = {}; + skills.forEach(s => { + const cat = s.category || 'General'; + if (!byCategory[cat]) byCategory[cat] = []; + byCategory[cat].push(s); + }); + const lines: string[] = []; + for (const [cat, items] of Object.entries(byCategory).sort()) { + lines.push(`**${cat}**`); + items.forEach(s => { + const desc = s.description ? ` \u2014 ${s.description.slice(0, 80)}${s.description.length > 80 ? '...' : ''}` : ''; + lines.push(` \`${s.name}\`${desc}`); + }); + lines.push(''); + } + const header = args ? `Skills matching "${args}" (${skills.length}):\n\n` : `Available skills (${skills.length}):\n\n`; + S.messages.push({ role: 'assistant' as const, content: header + lines.join('\n') }); + renderMessages(); + } catch (e: unknown) { showToast('Failed to load skills: ' + (e instanceof Error ? e.message : String(e))); } +} + +async function cmdPersonality(args: string): Promise { + if (!S.session) { showToast(t('no_active_session')); return; } + if (!args) { + try { + const data = await api('/api/personalities') as { personalities?: Array<{ name: string; description?: string }> }; + if (!data.personalities || !data.personalities.length) { showToast(t('no_personalities')); return; } + const list = data.personalities.map(p => ` **${p.name}**${p.description ? ' \u2014 ' + p.description : ''}`).join('\n'); + S.messages.push({ role: 'assistant' as const, content: t('available_personalities') + '\n\n' + list + '\n\nSwitch with: /personality ' }); + renderMessages(); + } catch { showToast(t('personalities_load_failed')); } + return; + } + const name = args.trim(); + if (['none','default','clear'].includes(name.toLowerCase())) { + try { + await api('/api/personality/set', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, name: '' }) }); + showToast(t('personality_cleared')); + } catch (e: unknown) { showToast(t('failed_colon') + (e instanceof Error ? e.message : String(e))); } + return; + } + try { + await api('/api/personality/set', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, name }) }); + showToast(t('personality_set') + name); + } catch (e: unknown) { showToast(t('failed_colon') + (e instanceof Error ? e.message : String(e))); } +} + +function cmdAgent(_args: string): void { + const msgEl = $('msg') as HTMLTextAreaElement | null; + if (!msgEl) return; + const parsed = parseCommand(msgEl.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 : ''}`; + msgEl.value = contextMsg; + send(); +} + +let _cmdSelectedIdx = -1; + +function showCmdDropdown(matches: CmdDef[]): void { + const dd = $('cmdDropdown'); + if (!dd) return; + dd.innerHTML = ''; + _cmdSelectedIdx = -1; + for (let i = 0; i < matches.length; i++) { + const c = matches[i]; + const el = document.createElement('div'); + el.className = 'cmd-item'; + (el as HTMLElement).dataset.idx = String(i); + const usage = c.arg && c.arg !== '(none)' ? ` ${esc(c.arg)}` : ''; + el.innerHTML = `
/${esc(c.name)}${usage}
${esc(c.desc)}
`; + el.addEventListener('mousedown', (e) => { + e.preventDefault(); + const msgEl2 = $('msg') as HTMLTextAreaElement | null; + if (msgEl2) { + msgEl2.value = '/' + c.name + (c.arg && c.arg !== '(none)' ? ' ' : ''); + msgEl2.focus(); + } + hideCmdDropdown(); + }); + dd.appendChild(el); + } + dd.classList.add('open'); +} + +function hideCmdDropdown(): void { + const dd = $('cmdDropdown'); + if (dd) dd.classList.remove('open'); + _cmdSelectedIdx = -1; +} + +function navigateCmdDropdown(dir: number): void { + const dd = $('cmdDropdown'); + if (!dd) return; + const items = dd.querySelectorAll('.cmd-item'); + if (!items.length) return; + items.forEach(el => el.classList.remove('selected')); + _cmdSelectedIdx += dir; + if (_cmdSelectedIdx < 0) _cmdSelectedIdx = items.length - 1; + if (_cmdSelectedIdx >= items.length) _cmdSelectedIdx = 0; + items[_cmdSelectedIdx].classList.add('selected'); +} + +function selectCmdDropdownItem(): void { + const dd = $('cmdDropdown'); + if (!dd) return; + const items = dd.querySelectorAll('.cmd-item'); + if (_cmdSelectedIdx >= 0 && _cmdSelectedIdx < items.length) { + const item = items[_cmdSelectedIdx] as unknown as HTMLElement; + const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperty(ev, 'preventDefault', { value: () => {} }); + item.dispatchEvent(ev); + } else if (items.length === 1) { + const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperty(ev, 'preventDefault', { value: () => {} }); + (items[0] as unknown as HTMLElement).dispatchEvent(ev); + } + hideCmdDropdown(); +} + +// Theme application stub (actual implementation is in ui.ts) +function _applyTheme(themeName: string): void { + document.documentElement.dataset.theme = themeName; +} + +export { COMMANDS, PASSTHROUGH, AGENT_INFO, loadCommands, parseCommand, executeCommand, getMatchingCommands, showCmdDropdown, hideCmdDropdown, navigateCmdDropdown, selectCmdDropdownItem }; diff --git a/static/global.d.ts b/static/global.d.ts new file mode 100644 index 0000000..5929459 --- /dev/null +++ b/static/global.d.ts @@ -0,0 +1,389 @@ +// Global type extensions for Hermes WebUI +// Patches for loosely-typed legacy JavaScript code + +interface WsEntry { + name: string; + path: string; + type: 'file' | 'dir' | string; + size?: number; + modified?: number; +} + +interface State { + session: any; + messages: any[]; + entries: any[]; + busy: boolean; + pendingFiles: any[]; + toolCalls: any[]; + activeStreamId: any; + currentDir: string; + activeProfile: string; + _expandedDirs?: Set; + _dirCache?: Record; + _profileDefaultWorkspace?: string; + lastUsage?: Record; + activityTree?: ActivityTree | null; + mcFilter?: MCFilter; + mcSort?: 'runtime' | 'agent' | 'status'; + [key: string]: any; +} + +declare const t: any; + +interface HTMLInputElement { + value: string; + disabled: boolean; + files: FileList | null; + checked?: boolean; +} + +interface HTMLTextAreaElement { + value: string; +} + +interface HTMLSelectElement { + value: string; + options: HTMLOptionsCollection; + selectedIndex: number; + readonly children: HTMLCollectionOf; + readonly childElementCount: number; + item(index: number): HTMLOptGroupElement | HTMLOptionElement | null; + namedItem(name: string): HTMLOptionElement | null; +} + +interface HTMLOptionElement { + value: string; + text: string; + disabled: boolean; + defaultSelected: boolean; + selected: boolean; + index: number; + form: HTMLFormElement | null; +} + +interface HTMLOptGroupElement { + disabled: boolean; + label: string; + readonly children: HTMLCollectionOf; +} + +interface HTMLElement { + disabled?: boolean; + value?: string; + files?: FileList | null; + checked?: boolean; + placeholder?: string; + innerHTML?: string; + innerText?: string; + textContent?: string; + title?: string; + style?: CSSStyleDeclaration & { [key: string]: string } & { cssText?: string }; + className?: string; + id?: string; + dataset?: DOMStringMap; + offsetWidth?: number; + offsetHeight?: number; + offsetLeft?: number; + offsetTop?: number; + scrollIntoView?: (options?: any) => void; + getBoundingClientRect?: () => DOMRect; + contains?: (node: Node) => boolean; + closest?: (selectors: string) => HTMLElement | null; + classList?: DOMTokenList & { add(...classes: string[]): void; remove(...classes: string[]): void; toggle(c: string, force?: boolean): boolean; contains(c: string): boolean; }; + setAttribute(name: string, value: string): void; + getAttribute(name: string): string | null; + removeAttribute(name: string): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + dispatchEvent(event: Event): boolean; + appendChild(node: Node | Element | HTMLElement | HTMLButtonElement | HTMLDivElement | HTMLSpanElement | HTMLInputElement | HTMLOptGroupElement): Node; + removeChild(node: Node | Element | HTMLElement | HTMLButtonElement | HTMLDivElement | HTMLSpanElement | HTMLInputElement | HTMLOptGroupElement): Node; + replaceWith(...nodes: (Node | string)[]): void; + insertAdjacentHTML(position: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend', html: string): void; + insertAdjacentElement(position: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend', element: HTMLElement): HTMLElement | null; + click(): void; + focus(): void; + blur(): void; + scrollTo(x: number, y: number): void; + requestFullscreen?: () => Promise; + matches?(selector: string): boolean; + replaceWith(...nodes: any[]): void; + _t?: ReturnType; + after(...nodes: (Node | string)[]): void; +} + +interface Window { + webkitSpeechRecognition: any; + SpeechRecognition: any; + _stopMic: () => void; + _micActive: boolean; + _micPendingSend: boolean; + _sendKey: string; + _showTokenUsage: boolean; + _showCliSessions: boolean; + _soundEnabled: boolean; + _notificationsEnabled: boolean; + _botName: string; + _previewCurrentPath: string; + _previewCurrentMode: string; + _previewDirty: boolean; + _initResizePanels: () => void; + showSaveFilePicker?: (options?: any) => Promise; + showOpenFilePicker?: (options?: any) => Promise; + _activeProvider: string | null; + t: any; + _userEmoji: string; + _userName: string; + _updateData: any; + _escHandler: any; + _saveExpandedDirs: any; + _mcPriorityFilter: any; + cancelEditMode: () => void; + cancelEdit: () => void; + closeAgentDetail: () => void; + switchToChatPanel: () => void; + stopGatewaySSE: () => void; + updateWorkspaceChip: () => void; + _showAllProfiles: () => void; + showPreview: (type: string) => void; + cancelEditMode: () => void; + _previewDirty: boolean; +} + +declare var Prism: any; +declare var mermaid: any; +declare var katex: any; + +declare function li(name: string, size?: number): string; +declare function openFile(path: string, line?: number): void; + +interface Session { + session_id: string; + messages?: Message[]; + workspace?: string; + input_tokens?: number; + output_tokens?: number; + estimated_cost?: number; + last_usage?: Record; + tool_calls?: any[]; + active_stream_id?: string; + pending_attachments?: unknown[]; + [key: string]: any; +} + +interface Message { + id?: string; + role: string; + content?: string | any[]; + tool_calls?: any[]; + name?: string; + [key: string]: any; +} + +declare const IMAGE_EXTS: Set; +declare const MD_EXTS: Set; +declare const DOWNLOAD_EXTS: Set; + +interface Document { + exitFullscreen?: () => Promise; + fullscreenElement?: Element; + pictureInPictureElement?: Element; + documentMode?: any; +} + +interface Element { + scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void; + onclick?: (ev: MouseEvent) => any; + oninput?: (ev: InputEvent) => any; + onkeydown?: (ev: KeyboardEvent) => any; + onkeyup?: (ev: KeyboardEvent) => any; + onchange?: (ev: Event) => any; + style?: CSSStyleDeclaration & { [key: string]: string }; + closest?: (selectors: string) => HTMLElement | null; + dataset?: DOMStringMap; + classList?: DOMTokenList & { add(...classes: string[]): void; remove(...classes: string[]): void; toggle(c: string, force?: boolean): boolean; contains(c: string): boolean; }; + appendChild(node: T): T; + removeChild(node: T): T; + getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; + appendChild(node: Node | Element | HTMLElement | HTMLButtonElement | HTMLDivElement | HTMLSpanElement | HTMLInputElement | HTMLOptGroupElement): Node; + removeChild(node: Node | Element | HTMLElement | HTMLButtonElement | HTMLDivElement | HTMLSpanElement | HTMLInputElement | HTMLOptGroupElement): Node; + addEventListener(type: K, listener: (this: Element, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: Element, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + contains(node: Node): boolean; + replaceWith(...nodes: any[]): void; + textContent?: string; + innerHTML?: string; +} + +declare function escape(html: string): string; +declare function highlightCode(container?: any): void; +declare function showToast(msg: string, ms?: number): void; +declare function setStatus(msg: string): void; +declare function setComposerStatus(msg: string): void; +declare function loadWorkspaceList(): Promise; +declare function syncOnboardingProvider(value: string): void; +declare function syncOnboardingWorkspaceSelect(value: string): void; +declare function fileIcon(name: string, type: string): string; +declare function loadOnboardingWizard(): Promise; +declare function nextOnboardingStep(): Promise; +declare function prevOnboardingStep(): void; +declare function skipOnboarding(): Promise; +declare function renderSessionList(): Promise; +declare function newSession(flash?: boolean, agentOverride?: string): Promise; +declare function updateQueueBadge(sid?: string): void; +declare function stopApprovalPolling(): void; +declare function hideApprovalCard(): void; +declare function updateSendBtn(): void; +declare function syncTopbar(): void; +declare function renderMessages(): void; +declare function loadDir(path: string): Promise; +declare function loadSession(sid: string): Promise; +declare function executeCommand(text: string): boolean; +declare function isCompressionUiRunning(): boolean; +declare const _allSessions: any[]; +declare function renderSessionListFromCache(): void; +declare const _assistantTurnBlocks: any; +declare function placeLiveToolCardsHost(): void; +declare function finalizeThinkingCard(): void; + +declare const _: any; +declare const jQuery: any; +declare const LOCALES: any; + +interface ConfigData { + default_model?: string; + send_key?: string; + theme?: string; + language?: string; + show_token_usage?: boolean; + show_cli_sessions?: boolean; + sync_to_insights?: boolean; + check_for_updates?: boolean; + sound_enabled?: boolean; + notifications_enabled?: boolean; + bubble_layout?: string; + bot_name?: string; + user_emoji?: string; + user_name?: string; + [key: string]: any; +} + +declare function stopGateway(): void; +declare function stopGatewaySSE(): void; +declare function cancelEdit(): void; +declare function watchInflightSession(sid: string, activeStreamId: string): void; +declare function showPreview(type: string): void; +declare function cancelEditMode(): void; +declare function clearPreview(): void; +declare function closeAgentDetail(): void; +declare function switchToChatPanel(): void; +declare function updateWorkspaceChip(): void; +declare let _showAllProfiles: boolean; +declare let _escHandler: any; +declare let _previewDirty: boolean; +declare function _saveExpandedDirs(): void; +declare function highlightCode(container?: any): void; +declare function addCopyButtons(): void; +declare function renderMermaidBlocks(): void; +declare function renderKatexBlocks(): void; +declare function openFile(path: string, line?: number): void; +declare const fileExt: any; +interface DialogOpts { + title?: string; + message?: string; + inputType?: string; + value?: string; + placeholder?: string; + cancelLabel?: string; + confirmLabel?: string; + danger?: boolean; + focusCancel?: boolean; +} +declare function showConfirmDialog(opts?: DialogOpts): Promise; +declare function showPromptDialog(opts?: DialogOpts): Promise; + +// Global state variables used across modules +declare let assistantText: string; +declare let _reasoningText: string; +declare let liveReasoningText: string; +declare function persistInflightState(sid: string, state: any): void; +declare function clearInflightState(sid: string): void; +declare function markInflight(sid: string, streamId: string): void; +declare function clearInflight(): void; +declare function saveInflightState(sid: string, state: any): void; +declare function loadInflightState(sid: string, streamId: string): any; +declare function startApprovalPolling(sid: string): void; +declare function stopApprovalPolling(): void; +declare function startClarifyPolling(sid: string): void; +declare function stopClarifyPolling(): void; +declare function cancelStream(): void; +declare function appendLiveToolCard(tc: any): void; +declare function buildToolCard(tc: any, live?: boolean): string; +declare function finalizeThinkingCard(): void; +declare function syncInflightAssistantMessage(): void; +declare function setBusy(busy: boolean): void; +declare function attachLiveStream(sid: string, streamId: string, uploaded?: any[], opts?: any): void; +declare function isCompressionUiRunning(): boolean; + +// ─── Agent Activity Tree (Mission Control) ────────────────────────── +interface ActivityToolCall { + id: string; + name: string; + status: 'pending' | 'running' | 'done' | 'error'; + args: Record; + result?: string; + duration?: number; + startedAt: number | null; + endedAt: number | null; +} + +interface ActivityNode { + id: string; + parentId: string | null; + agentId: string; + agentEmoji: string; + agentName: string; + tier: 1 | 2 | 3; + status: 'pending' | 'running' | 'thinking' | 'done' | 'error' | 'cancelled'; + task: string; + toolCalls: ActivityToolCall[]; + startedAt: number | null; + endedAt: number | null; + duration: number | null; + children: string[]; + collapsed: boolean; + metadata: Record; +} + +interface MCStats { + totalAgents: number; + runningAgents: number; + pendingAgents: number; + doneAgents: number; + errorAgents: number; + totalTools: number; + doneTools: number; + runningTools: number; + avgResponseTime: number; + totalElapsed: number; +} + +interface ActivityTree { + version: 1; + rootId: string; + nodes: Record; + stats: MCStats; +} + +interface MCFilter { + agent?: string; + status?: string; + search?: string; +} + +declare function initActivityTree(): ActivityTree; +declare function createMockActivityTree(): ActivityTree; +declare function formatElapsed(ms: number): string; diff --git a/static/i18n.js b/static/i18n.js index 23110dd..2aa9121 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1,2408 +1,521 @@ -// ── i18n: locale bundles and t() helper ────────────────────────────────────── -// To add a new language: add an entry to LOCALES below with all keys translated. -// The language code must match a valid BCP 47 tag (used for speech recognition). -// Keys missing in a non-English locale fall back to English automatically. - -const LOCALES = { - en: { - _lang: 'en', - _label: 'English', - _speech: 'en-US', - // boot.js - cancelling: 'Cancelling\u2026', - cancel_failed: 'Cancel failed: ', - mic_denied: 'Microphone access denied. Check browser permissions.', - mic_no_speech: 'No speech detected. Try again.', - mic_network: 'Speech recognition unavailable.', - mic_error: 'Voice input error: ', - session_imported: 'Session imported', - import_failed: 'Import failed: ', - import_invalid_json: 'Invalid JSON', - image_pasted: 'Image pasted: ', - // messages.js - edit_message: 'Edit message', - regenerate: 'Regenerate response', - copy: 'Copy', - copied: 'Copied!', - you: 'You', - 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?', - reconnect_finished: 'A response was in progress when you last left. Messages may have updated.', - // approval card - approval_heading: 'Approval required', - approval_desc_prefix: 'Dangerous command detected', - approval_btn_once: 'Allow once', - approval_btn_once_title: 'Allow this one command (Enter)', - approval_btn_session: 'Allow session', - approval_btn_session_title: 'Allow for this conversation session', - approval_btn_always: 'Always allow', - approval_btn_always_title: 'Always allow this command pattern', - approval_btn_deny: 'Deny', - approval_btn_deny_title: 'Deny — do not run this command', - approval_responding: 'Responding\u2026', - clarify_heading: 'Clarification needed', - clarify_hint: 'Pick a choice, or type your own answer below.', - clarify_other: 'Other', - clarify_send: 'Send', - clarify_input_placeholder: 'Type your response…', - clarify_responding: 'Responding\u2026', - untitled: 'Untitled', - n_messages: (n) => `${n} messages`, - model_unavailable: ' (unavailable)', - model_unavailable_title: 'This model is no longer in your current provider list', - provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`, - provider_mismatch_label: 'Provider mismatch', - model_custom_label: 'Custom model ID', - model_custom_placeholder: 'e.g. openai/gpt-5.4', - model_search_placeholder: 'Search models…', - model_search_no_results: 'No models found', - // commands.js - cmd_clear: 'Clear conversation messages', - cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])', - cmd_compact_alias: 'Legacy alias for /compress', - cmd_model: 'Switch model (e.g. /model gpt-4o)', - cmd_workspace: 'Switch workspace by name', - cmd_new: 'Start a new chat session', - cmd_usage: 'Toggle token usage display on/off', - cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)', - cmd_personality: 'Switch agent personality', - cmd_skills: 'List available Hermes skills', - available_commands: 'Available commands:', - type_slash: 'Type / to see commands', - conversation_cleared: 'Conversation cleared', - command_label: 'Command', - context_compaction_label: 'Context compaction', - reference_only_label: 'Reference only', - model_usage: 'Usage: /model ', - no_model_match: 'No model matching "', - switched_to: 'Switched to ', - workspace_usage: 'Usage: /workspace ', - no_workspace_match: 'No workspace matching "', - switched_workspace: 'Switched to workspace: ', - workspace_switch_failed: 'Workspace switch failed: ', - new_session: 'New session created', - compressing: 'Requesting context compression...', - compress_running_label: 'Compressing', - compress_complete_label: 'Compression complete', - compress_failed_label: 'Compression failed', - focus_label: 'Focus', - token_usage_on: 'Token usage on', - token_usage_off: 'Token usage off', - theme_usage: 'Usage: /theme ', - theme_set: 'Theme: ', - no_active_session: 'No active session', - - slash_skill_badge:'Skill', - slash_skill_desc:'Invoke this skill', - cmd_stop:'Stop the current response', - cmd_title:'Get or set the session title', - cmd_retry:'Resend the last message', - cmd_undo:'Remove the last exchange', - cmd_status:'Show session info', - cmd_voice:'Toggle microphone input', - stream_stopped:'Response stopped.', - no_active_task:'No active task to stop.', - cancel_unavailable:'Cancel not available.', - retry_failed:'Retry failed: ', - undo_failed:'Undo failed: ', - undid_n_messages:'Removed', - undid_messages_suffix:'message(s).', - status_heading:'Session Status', - status_session_id:'Session ID', - status_title:'Title', - status_model:'Model', - status_workspace:'Workspace', - status_personality:'Personality', - status_messages:'Messages', - status_agent_running:'Agent running', - status_yes:'Yes', - status_no:'No', - status_load_failed:'Failed to load status: ', - title_current:'Current title', - title_change_hint:'Use `/title ` to rename.', - title_set:'Title set to', - cmd_webui_only_session:'This command is not available for CLI-imported sessions.', - cmd_voice_use_mic:'Click the mic button in the composer.', - usage_heading:'Token Usage', - usage_default_model:'default', - usage_unknown:'unknown', - usage_input_tokens:'Input tokens', - usage_output_tokens:'Output tokens', - usage_total:'Total tokens', - usage_estimated_cost:'Estimated cost', - usage_settings_tip:'Note: cost estimates are approximate.', - usage_load_failed:'Failed to load usage: ', - usage_personality_none:'none', - untitled:'Untitled', - no_personalities: 'No personalities found (add them to ~/.hermes/personalities/)', - available_personalities: 'Available personalities:', - personality_switch_hint: '\n\nUse `/personality ` to switch, or `/personality none` to clear.', - personalities_load_failed: 'Failed to load personalities', - personality_cleared: 'Personality cleared', - personality_set: 'Personality: ', - failed_colon: 'Failed: ', - // ui.js - no_workspace: 'No workspace', - workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.', - workspace_empty_dir: 'This workspace is empty.', - dialog_confirm_title: 'Confirm action', - dialog_prompt_title: 'Enter a value', - dialog_confirm_btn: 'Confirm', - // workspace.js - unsaved_confirm: 'You have unsaved changes in the preview. Discard and navigate?', - discard: 'Discard', - save: 'Save', - edit: 'Edit', - clear: 'Clear', - create: 'Create', - remove: 'Remove', - save_title: 'Save changes', - edit_title: 'Edit this file', - saved: 'Saved', - save_failed: 'Save failed: ', - image_load_failed: 'Could not load image', - file_open_failed: 'Could not open file', - downloading: (name) => `Downloading ${name}\u2026`, - double_click_rename: 'Double-click to rename', - renamed_to: 'Renamed to ', - rename_failed: 'Rename failed: ', - delete_title: 'Delete', - delete_confirm: (name) => `Delete ${name}?`, - deleted: 'Deleted ', - delete_failed: 'Delete failed: ', - new_file_prompt: 'New file name (e.g. notes.md):', - project_name_prompt: 'Project name:', - created: 'Created ', - create_failed: 'Create failed: ', - new_folder_prompt: 'New folder name:', - folder_created: 'Created folder ', - folder_create_failed: 'Create folder failed: ', - remove_title: 'Remove', - empty_dir: '(empty)', - upload_failed: 'Upload failed: ', - all_uploads_failed: (n) => `All ${n} upload(s) failed`, - // settings panel - settings_title: 'Settings', - settings_save_btn: 'Save Settings', - settings_label_model: 'Default Model', - settings_label_send_key: 'Send Key', - settings_label_theme: 'Theme', - settings_label_skin: 'Skin', - settings_label_language: 'Language', - settings_label_token_usage: 'Show token usage', - settings_label_bubble_layout: 'Chat bubble layout', - settings_label_cli_sessions: 'Show agent sessions', - settings_label_sync_insights: 'Sync to insights', - settings_label_check_updates: 'Check for updates', - settings_label_bot_name: 'Assistant Name', - settings_label_password: 'Access Password', - settings_saved: 'Settings saved', - settings_save_failed: 'Save failed: ', - settings_load_failed: 'Failed to load settings: ', - settings_saved_pw: 'Settings saved — password protection enabled and this browser stays signed in', - settings_saved_pw_updated: 'Settings saved — password updated', - // login page (used server-side via /api/i18n/login endpoint) - login_title: 'Sign in', - login_subtitle: 'Enter your password to continue', - login_placeholder: 'Password', - login_btn: 'Sign in', - login_invalid_pw: 'Invalid password', - login_conn_failed: 'Connection failed', - dialog_confirm_title: 'Confirm action', - dialog_prompt_title: 'Enter a value', - dialog_confirm_btn: 'Confirm', - discard: 'Discard', - clear: 'Clear', - create: 'Create', - remove: 'Remove', - project_name_prompt: 'Project name:', - // Sidebar & Tabs - tab_chat: 'Chat', - tab_tasks: 'Tasks', - tab_skills: 'Skills', - tab_memory: 'Memory', - tab_workspaces: 'Spaces', - tab_profiles: 'Profiles', - - new_conversation: 'New conversation', - filter_conversations: 'Filter conversations...', - session_time_unknown: 'Unknown', - session_time_just_now: 'just now', - session_time_minutes_ago: (n) => `${n} minute${n === 1 ? '' : 's'} ago`, - session_time_hours_ago: (n) => `${n} hour${n === 1 ? '' : 's'} ago`, - session_time_days_ago: (n) => `${n} day${n === 1 ? '' : 's'} ago`, - session_time_last_week: 'last week', - session_time_bucket_today: 'Today', - session_time_bucket_yesterday: 'Yesterday', - session_time_bucket_this_week: 'This week', - session_time_bucket_last_week: 'Last week', - session_time_bucket_older: 'Older', - scheduled_jobs: 'Scheduled jobs', - new_job: 'New job', - loading: 'Loading...', - search_skills: 'Search skills...', - new_skill: 'New skill', - personal_memory: 'Personal memory', - - workspace_desc: 'Add and switch workspaces for your sessions.', - new_profile: 'New profile', - transcript: 'Transcript', - download_transcript: 'Download as Markdown', - import: 'Import', - // Settings detail - settings_label_sound: 'Notification sound', - settings_desc_sound: 'Play a sound when the assistant finishes a response.', - settings_label_notifications: 'Browser notifications', - settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.', - settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.', - settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.', - settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.', - settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', - settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', - settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.', - settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.', - password_placeholder: 'Enter new password…', - disable_auth: 'Disable Auth', - sign_out: 'Sign Out', - cancel: 'Cancel', - create_job: 'Create job', - save_skill: 'Save skill', - editing: 'Editing', - // Empty state - empty_title: 'What can I help with?', - empty_subtitle: 'Ask anything, run commands, explore files, or manage your scheduled tasks.', - suggest_files: 'What files are in this workspace?', - suggest_schedule: "What's on my schedule today?", - suggest_plan: 'Help me plan a small project.', - // onboarding - onboarding_badge: 'FIRST RUN', - onboarding_title: 'Welcome to Hermes Web UI', - onboarding_lead: 'A quick guided setup will verify Hermes, save a real provider configuration, choose a workspace and model, and optionally protect the app with a password.', - onboarding_back: 'Back', - onboarding_continue: 'Continue', - onboarding_skip: 'Skip setup', - onboarding_skipped: 'Setup skipped — using existing config.', - onboarding_open: 'Open Hermes', - onboarding_step_system_title: 'System check', - onboarding_step_system_desc: 'Verify Hermes Agent and config visibility.', - onboarding_step_setup_title: 'Provider setup', - onboarding_step_setup_desc: 'Save the minimum Hermes provider config.', - onboarding_step_workspace_title: 'Workspace + model', - onboarding_step_workspace_desc: 'Pick defaults for new sessions and chat.', - onboarding_step_password_title: 'Optional password', - onboarding_step_password_desc: 'Protect the Web UI before sharing it.', - onboarding_step_finish_title: 'Finish', - onboarding_step_finish_desc: 'Review and enter the app.', - onboarding_notice_system_ready: 'Hermes Agent looks reachable from the Web UI.', - onboarding_notice_system_unavailable: 'Hermes Agent is not fully available yet. Bootstrap can install it, but provider setup may still require a terminal.', - onboarding_check_agent: 'Hermes Agent', - onboarding_check_agent_ready: 'Detected and importable', - onboarding_check_agent_missing: 'Missing or partially importable', - onboarding_check_password: 'Password', - onboarding_check_password_enabled: 'Already enabled', - onboarding_check_password_disabled: 'Not enabled yet', - onboarding_check_provider: 'Provider config', - onboarding_check_provider_ready: 'Ready to chat', - onboarding_check_provider_partial: 'Saved but incomplete', - onboarding_check_provider_pending: 'Needs verification', - onboarding_config_file: 'Config file:', - onboarding_env_file: '.env file:', - onboarding_unknown: 'Unknown', - onboarding_current_provider: 'Current setup:', - onboarding_missing_imports: 'Missing imports:', - onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.', - onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.', - onboarding_oauth_provider_ready_title: 'Provider already authenticated', - onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider ({provider}) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.', - onboarding_oauth_provider_not_ready_title: 'OAuth provider not yet authenticated', - onboarding_oauth_provider_not_ready_body: 'This instance is configured to use {provider}, which uses OAuth rather than an API key. Run hermes auth or hermes model in a terminal to authenticate, then reload the Web UI.', - onboarding_oauth_switch_hint: 'Or choose a different provider below to switch to an API-key setup:', - onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.', - onboarding_workspace_label: 'Workspace', - onboarding_workspace_or_path: 'Or enter a workspace path', - onboarding_workspace_placeholder: '/home/you/workspace', - onboarding_provider_label: 'Setup mode', - onboarding_quick_setup_badge: 'quick setup', - onboarding_api_key_label: 'API key', - onboarding_api_key_placeholder: 'Leave blank to keep an existing saved key', - onboarding_api_key_help_prefix: 'Saved as a secret in your Hermes .env file using', - onboarding_base_url_label: 'Base URL', - onboarding_base_url_placeholder: 'https://your-endpoint.example/v1', - onboarding_base_url_help: 'Use this for OpenAI-compatible routers, self-hosted servers, LiteLLM, Ollama, LM Studio, vLLM, or similar endpoints.', - onboarding_model_label: 'Default model', - onboarding_workspace_help: 'Pick the model Hermes should use for new chats after setup completes.', - onboarding_custom_model_placeholder: 'your-model-name', - onboarding_custom_model_help: 'For custom endpoints, enter the exact model ID your server expects.', - onboarding_notice_password_enabled: 'A password is already configured. Enter a new one only if you want to replace it.', - onboarding_notice_password_recommended: 'Optional but recommended if you will expose the UI beyond localhost.', - onboarding_password_label: 'Password (optional)', - onboarding_password_placeholder: 'Leave blank to skip', - onboarding_password_help: 'Passwords are stored through the existing settings API and hashed server-side.', - onboarding_notice_finish: 'You can reopen Settings later to change any of this.', - onboarding_not_set: 'Not set', - onboarding_password_will_enable: 'Will be enabled', - onboarding_password_will_replace: 'Will be replaced', - onboarding_password_keep_existing: 'Keep current password', - onboarding_password_remains_disabled: 'Will remain disabled', - onboarding_password_skipped: 'Skipped for now', - onboarding_finish_help: 'Finishing stores onboarding_completed in settings and drops you into the normal app.', - onboarding_error_choose_workspace: 'Choose a workspace before continuing.', - onboarding_error_choose_model: 'Choose a model before continuing.', - onboarding_error_provider_required: 'Choose a setup mode before continuing.', - onboarding_error_base_url_required: 'Base URL is required for custom endpoints.', - onboarding_error_workspace_required: 'Workspace is required.', - onboarding_error_model_required: 'Model is required.', - onboarding_complete: 'Onboarding complete', - // panel/runtime i18n - error_prefix: 'Error: ', - not_available: 'N/A', - never: 'never', - add: 'Add', - add_failed: 'Add failed: ', - remove_failed: 'Remove failed: ', - switch_failed: 'Switch failed: ', - name_required: 'Name is required', - content_required: 'Content is required', - view: 'View', - dismiss: 'Dismiss', - disable: 'Disable', - cron_no_jobs: 'No scheduled jobs found.', - cron_status_off: 'off', - cron_status_paused: 'paused', - cron_status_error: 'error', - cron_status_active: 'active', - cron_next: 'Next', - cron_last: 'Last', - cron_run_now: 'Run now', - cron_pause: 'Pause', - cron_resume: 'Resume', - cron_job_name_placeholder: 'Job name', - cron_schedule_placeholder: 'Schedule', - cron_prompt_placeholder: 'Prompt', - cron_last_output: 'Last output', - cron_all_runs: 'All runs', - cron_hide_runs: 'Hide runs', - cron_no_runs_yet: '(no runs yet)', - cron_schedule_required_example: 'Schedule is required (e.g. "0 9 * * *" or "every 1h")', - cron_schedule_required: 'Schedule is required', - cron_prompt_required: 'Prompt is required', - cron_job_created: 'Job created', - cron_job_triggered: 'Job triggered', - cron_job_paused: 'Job paused', - cron_job_resumed: 'Job resumed', - cron_job_updated: 'Job updated', - cron_delete_confirm_title: 'Delete cron job', - cron_delete_confirm_message: 'This cannot be undone.', - cron_job_deleted: 'Job deleted', - cron_completion_status: (name, status) => `Cron "${name}" ${status}`, - status_failed: 'failed', - status_completed: 'completed', - - clear_conversation_title: 'Clear conversation', - clear_conversation_message: 'Clear all messages? This cannot be undone.', - clear_failed: 'Clear failed: ', - skills_no_match: 'No skills match.', - linked_files: 'Linked Files', - skill_load_failed: 'Could not load skill: ', - skill_file_load_failed: 'Could not load file: ', - skill_name_required: 'Skill name is required', - skill_updated: 'Skill updated', - skill_created: 'Skill created', - memory_notes_label: 'memory (notes)', - memory_saved: 'Memory saved', - my_notes: 'My Notes', - user_profile: 'User Profile', - no_notes_yet: 'No notes yet.', - no_profile_yet: 'No profile yet.', - workspace_choose_path: 'Choose workspace path', - workspace_choose_path_meta: 'Add a validated path and switch this conversation', - workspace_manage: 'Manage workspaces', - workspace_manage_meta: 'Open the Spaces panel', - workspace_use_title: 'Use in current session', - workspace_use: 'Use', - workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)', - workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.', - workspace_added: 'Workspace added', - workspace_remove_confirm_title: 'Remove workspace', - workspace_remove_confirm_message: (path) => `Remove "${path}"?`, - workspace_removed: 'Workspace removed', - workspace_switch_prompt_title: 'Switch workspace', - workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.', - workspace_switch_prompt_confirm: 'Switch', - workspace_switch_prompt_placeholder: '/Users/you/project', - workspace_not_added: 'Workspace was not added', - workspace_already_saved: 'Workspace already saved — choose it from the list', - workspace_busy_switch: 'Cannot switch workspace while agent is running', - discard_file_edits_title: 'Discard file edits?', - discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.', - workspace_switched_to: (name) => `Switched to ${name}`, - profiles_no_profiles: 'No profiles found.', - profile_api_keys_configured: 'API keys configured', - profile_gateway_running: 'Gateway running', - profile_gateway_stopped: 'Gateway stopped', - profile_active: 'ACTIVE', - profile_no_configuration: 'No configuration', - profile_skill_count: (count) => `${count} skill${count === 1 ? '' : 's'}`, - profile_use: 'Use', - profile_switch_title: 'Switch to this profile', - profile_delete_title: 'Delete this profile', - profile_default_label: '(default)', - profile_name_placeholder: 'Profile name (lowercase, a-z 0-9 hyphens)', - profile_clone_label: 'Clone config from active profile', - profile_base_url_placeholder: 'Base URL (optional, e.g. http://localhost:11434)', - profile_api_key_placeholder: 'API key (optional)', - manage_profiles: 'Manage profiles', - profiles_load_failed: 'Failed to load profiles', - profiles_busy_switch: 'Cannot switch profiles while agent is running', - profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, - profile_switched: (name) => `Switched to profile: ${name}`, - profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', - profile_base_url_rule: 'Base URL must start with http:// or https://', - profile_created: (name) => `Profile created: ${name}`, - 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.', - sign_out_failed: 'Sign out failed: ', - disable_auth_confirm_title: 'Disable password protection', - disable_auth_confirm_message: 'Anyone will be able to access this instance.', - auth_disabled: 'Auth disabled — password protection removed', - disable_auth_failed: 'Failed to disable auth: ', - bg_error_single: (title) => `"${title}" has encountered an error`, - bg_error_multi: (count) => `${count} sessions have encountered an error`, - }, - - ru: { - _lang: 'ru', - _label: 'Русский', - _speech: 'ru-RU', - cancelling: 'Отменяю…', - cancel_failed: 'Не удалось отменить: ', - mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.', - mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.', - mic_network: 'Распознавание речи недоступно.', - mic_error: 'Ошибка ввода речи: ', - session_imported: 'Сеанс импортирован', - import_failed: 'Не удалось импортировать: ', - import_invalid_json: 'Неверный JSON', - image_pasted: 'Изображение вставлено: ', - edit_message: 'Редактировать сообщение', - regenerate: 'Сгенерировать ответ заново', - copy: 'Копировать', - copied: 'Скопировано!', - you: 'Вы', - thinking: 'Думаю', - expand_all: 'Развернуть всё', - collapse_all: 'Свернуть всё', - edit_failed: 'Не удалось отредактировать: ', - regen_failed: 'Не удалось сгенерировать заново: ', - reconnect_active: 'Ответ всё ещё генерируется. Обновить, когда будет готово?', - reconnect_finished: 'Когда вы уходили, ответ ещё генерировался. Сообщения могли обновиться.', - approval_heading: 'Требуется подтверждение', - approval_desc_prefix: 'Обнаружена опасная команда', - approval_btn_once: 'Разрешить один раз', - approval_btn_once_title: 'Разрешить только эту команду (Enter)', - approval_btn_session: 'Разрешить на этот сеанс', - approval_btn_session_title: 'Разрешить для этого сеанса разговора', - approval_btn_always: 'Всегда разрешать', - approval_btn_always_title: 'Всегда разрешать команды по этому шаблону', - approval_btn_deny: 'Запретить', - approval_btn_deny_title: 'Запретить — не выполнять эту команду', - approval_responding: 'Отвечаю…', - untitled: 'Без названия', - n_messages: (n) => `${n} сообщений`, - model_unavailable: ' (недоступна)', - model_unavailable_title: 'Эта модель больше не входит в ваш текущий список провайдеров', - provider_mismatch_warning: (m, p) => - `"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`, - provider_mismatch_label: 'Несовпадение провайдера', - model_custom_label: 'Пользовательский ID модели', - model_custom_placeholder: 'например, openai/gpt-5.4', - cmd_help: 'Показать доступные команды', - cmd_clear: 'Очистить сообщения беседы', - cmd_compact: 'Сжать контекст беседы', - cmd_model: 'Переключить модель (например, /model gpt-4o)', - cmd_workspace: 'Переключить рабочее пространство по названию', - cmd_new: 'Начать новую сессию чата', - cmd_usage: 'Показать или скрыть использование токенов', - cmd_theme: 'Переключить тему (dark/light/slate/solarized/monokai/nord/oled)', - cmd_personality: 'Переключить личность агента', - cmd_skills: 'Показать доступные навыки Hermes', - available_commands: 'Доступные команды:', - type_slash: 'Введите /, чтобы увидеть команды', - conversation_cleared: 'Беседа очищена', - model_usage: 'Использование: /model ', - no_model_match: 'Нет модели, соответствующей "', - switched_to: 'Переключено на ', - workspace_usage: 'Использование: /workspace ', - no_workspace_match: 'Нет рабочего пространства, соответствующего "', - switched_workspace: 'Переключено на рабочее пространство: ', - workspace_switch_failed: 'Не удалось переключить рабочее пространство: ', - new_session: 'Новая сессия создана', - compressing: 'Запрашиваю сжатие контекста...', - token_usage_on: 'Отображение токенов включено', - token_usage_off: 'Отображение токенов выключено', - theme_usage: 'Использование: /theme ', - theme_set: 'Тема: ', - no_active_session: 'Нет активной сессии', - - no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)', - clarify_heading: 'Требуется уточнение', - clarify_hint: 'Выберите вариант или введите свой ответ ниже.', - clarify_input_placeholder: 'Введите ответ…', - clarify_other: 'Другое', - clarify_responding: 'Отвечаю…', - clarify_send: 'Отправить', - cmd_compact_alias: 'Устаревший псевдоним для /compress', - cmd_compress: 'Сжать контекст беседы (использование: /compress [тема])', - command_label: 'Команда', - compress_complete_label: 'Сжатие завершено', - compress_failed_label: 'Ошибка сжатия', - compress_running_label: 'Сжатие…', - context_compaction_label: 'Сжатие контекста', - focus_label: 'Фокус', - model_search_no_results: 'Модели не найдены', - model_search_placeholder: 'Поиск моделей…', - reference_only_label: 'Только справка', - settings_label_skin: 'Скин', - workspace_empty_dir: 'Это рабочее пространство пусто.', - workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.', - available_personalities: 'Доступные личности:', - personality_switch_hint: '\n\nИспользуйте `/personality ` для переключения или `/personality none` для сброса.', - personalities_load_failed: 'Не удалось загрузить личности', - personality_cleared: 'Личность очищена', - personality_set: 'Личность: ', - failed_colon: 'Не удалось: ', - no_workspace: 'Нет рабочего пространства', - dialog_confirm_title: 'Подтвердить действие', - dialog_prompt_title: 'Введите значение', - dialog_confirm_btn: 'Подтвердить', - unsaved_confirm: 'У вас есть несохранённые изменения в предпросмотре. Отменить и перейти дальше?', - discard: 'Отменить', - save: 'Сохранить', - edit: 'Редактировать', - clear: 'Очистить', - create: 'Создать', - remove: 'Удалить', - save_title: 'Сохранить изменения', - edit_title: 'Редактировать этот файл', - saved: 'Сохранено', - save_failed: 'Не удалось сохранить: ', - image_load_failed: 'Не удалось загрузить изображение', - file_open_failed: 'Не удалось открыть файл', - downloading: (name) => `Скачиваю ${name}…`, - double_click_rename: 'Дважды щёлкните, чтобы переименовать', - renamed_to: 'Переименовано в ', - rename_failed: 'Не удалось переименовать: ', - delete_title: 'Удалить', - delete_confirm: (name) => `Удалить ${name}?`, - deleted: 'Удалено ', - delete_failed: 'Не удалось удалить: ', - new_file_prompt: 'Имя нового файла (например, notes.md):', - project_name_prompt: 'Имя проекта:', - created: 'Создано ', - create_failed: 'Не удалось создать: ', - new_folder_prompt: 'Имя новой папки:', - folder_created: 'Папка создана ', - folder_create_failed: 'Не удалось создать папку: ', - remove_title: 'Удаление', - empty_dir: '(пусто)', - upload_failed: 'Не удалось загрузить: ', - all_uploads_failed: (n) => `Не удалось загрузить все ${n} файлов`, - settings_title: 'Настройки', - settings_save_btn: 'Сохранить настройки', - settings_label_model: 'Модель по умолчанию', - settings_label_send_key: 'Клавиша отправки', - settings_label_theme: 'Тема', - settings_label_language: 'Язык', - settings_label_token_usage: 'Показывать использование токенов', - settings_label_bubble_layout: 'Раскладка пузырьков чата', - settings_label_cli_sessions: 'Показывать сеансы агента', - settings_label_sync_insights: 'Синхронизировать с Insights', - settings_label_check_updates: 'Проверять обновления', - settings_label_bot_name: 'Имя помощника', - settings_label_password: 'Пароль доступа', - settings_saved: 'Настройки сохранены', - settings_save_failed: 'Не удалось сохранить: ', - settings_load_failed: 'Не удалось загрузить настройки: ', - settings_saved_pw: 'Настройки сохранены (пароль задан — теперь требуется вход)', - settings_saved_pw_updated: 'Настройки сохранены (пароль обновлён)', - login_title: 'Вход', - login_subtitle: 'Введите пароль, чтобы продолжить', - login_placeholder: 'Пароль', - login_btn: 'Войти', - login_invalid_pw: 'Неверный пароль', - login_conn_failed: 'Не удалось подключиться', - tab_chat: 'Чат', - tab_tasks: 'Задачи', - tab_skills: 'Навыки', - tab_memory: 'Память', - tab_workspaces: 'Рабочие пространства', - tab_profiles: 'Профили', - - new_conversation: 'Новая беседа', - filter_conversations: 'Фильтр бесед...', - session_time_unknown: 'Неизвестно', - session_time_just_now: 'только что', - session_time_minutes_ago: (n) => { - const mod10 = n % 10; - const mod100 = n % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'минута' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'минуты' - : 'минут'); - return `${n} ${word} назад`; - }, - session_time_hours_ago: (n) => { - const mod10 = n % 10; - const mod100 = n % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'час' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'часа' - : 'часов'); - return `${n} ${word} назад`; - }, - session_time_days_ago: (n) => { - const mod10 = n % 10; - const mod100 = n % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'день' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'дня' - : 'дней'); - return `${n} ${word} назад`; - }, - session_time_last_week: 'на прошлой неделе', - session_time_bucket_today: 'Сегодня', - session_time_bucket_yesterday: 'Вчера', - session_time_bucket_this_week: 'На этой неделе', - session_time_bucket_last_week: 'На прошлой неделе', - session_time_bucket_older: 'Ранее', - scheduled_jobs: 'Запланированные задания', - new_job: 'Новое задание', - loading: 'Загрузка...', - search_skills: 'Поиск навыков...', - new_skill: 'Новый навык', - personal_memory: 'Личная память', - - workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.', - new_profile: 'Новый профиль', - transcript: 'Транскрипт', - download_transcript: 'Скачать как Markdown', - import: 'Импорт', - settings_label_sound: 'Звук уведомления', - settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.', - settings_label_notifications: 'Уведомления браузера', - settings_desc_notifications: 'Показывать системное уведомление, когда ответ готов, а вкладка находится в фоне.', - settings_desc_token_usage: 'Показывает количество входных и выходных токенов под каждым ответом помощника. Также переключается через /usage.', - settings_desc_bubble_layout: 'Выравнивает сообщения пользователя справа, а ответы помощника слева. Выключено по умолчанию, чтобы блоки кода и вывод инструментов занимали всю ширину.', - settings_desc_cli_sessions: 'Объединяет сеансы из Hermes CLI (state.db) в список сеансов. Нажмите на CLI-сеанс, чтобы импортировать его и продолжить разговор.', - settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.', - settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.', - settings_desc_bot_name: 'Отображаемое имя помощника во всём интерфейсе. По умолчанию Hermes.', - settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.', - password_placeholder: 'Введите новый пароль…', - disable_auth: 'Отключить авторизацию', - sign_out: 'Выйти', - cancel: 'Отмена', - create_job: 'Создать задание', - save_skill: 'Сохранить навык', - editing: 'Редактирование', - empty_title: 'Чем я могу помочь?', - empty_subtitle: 'Спрашивайте что угодно, запускайте команды, изучайте файлы или управляйте запланированными задачами.', - suggest_files: 'Какие файлы есть в этом рабочем пространстве?', - suggest_schedule: 'Что у меня сегодня в расписании?', - suggest_plan: 'Помоги спланировать небольшой проект.', - onboarding_badge: 'ПЕРВЫЙ ЗАПУСК', - onboarding_title: 'Добро пожаловать в Hermes Web UI', - onboarding_lead: 'Краткая пошаговая настройка проверит Hermes, сохранит рабочую конфигурацию провайдера, выберет рабочее пространство и модель и при желании защитит приложение паролем.', - onboarding_back: 'Назад', - onboarding_continue: 'Продолжить', - onboarding_skip: 'Пропустить настройку', - onboarding_skipped: 'Настройка пропущена — используется существующая конфигурация.', - onboarding_open: 'Открыть Hermes', - onboarding_step_system_title: 'Проверка системы', - onboarding_step_system_desc: 'Проверить Hermes Agent и видимость конфигурации.', - onboarding_step_setup_title: 'Настройка провайдера', - onboarding_step_setup_desc: 'Сохранить минимальную рабочую конфигурацию провайдера Hermes.', - onboarding_step_workspace_title: 'Рабочее пространство и модель', - onboarding_step_workspace_desc: 'Выбрать значения по умолчанию для новых сеансов и чатов.', - onboarding_step_password_title: 'Необязательный пароль', - onboarding_step_password_desc: 'Защитить Web UI перед тем, как делиться им.', - onboarding_step_finish_title: 'Готово', - onboarding_step_finish_desc: 'Проверьте настройки и войдите в приложение.', - onboarding_notice_system_ready: 'Hermes Agent, похоже, доступен из Web UI.', - onboarding_notice_system_unavailable: 'Hermes Agent ещё не полностью доступен. Bootstrap может установить его, но для настройки провайдера всё ещё может понадобиться терминал.', - onboarding_check_agent: 'Hermes Agent', - onboarding_check_agent_ready: 'Обнаружен и доступен для импорта', - onboarding_check_agent_missing: 'Отсутствует или доступен только частично', - onboarding_check_password: 'Пароль', - onboarding_check_password_enabled: 'Уже включён', - onboarding_check_password_disabled: 'Пока не включён', - onboarding_check_provider: 'Конфигурация провайдера', - onboarding_check_provider_ready: 'Готова к чату', - onboarding_check_provider_partial: 'Сохранена, но не завершена', - onboarding_check_provider_pending: 'Требует проверки', - onboarding_config_file: 'Файл конфигурации:', - onboarding_env_file: 'Файл .env:', - onboarding_unknown: 'Неизвестно', - onboarding_current_provider: 'Текущая конфигурация:', - onboarding_missing_imports: 'Отсутствующие импорты:', - onboarding_notice_setup_required: 'Выберите здесь простой путь настройки провайдера. Продвинутые OAuth-сценарии пока остаются в Hermes CLI.', - onboarding_notice_setup_already_ready: 'Уже обнаружена рабочая конфигурация провайдера Hermes. Вы можете оставить её или заменить здесь.', - onboarding_oauth_provider_ready_title: 'Провайдер уже авторизован', - onboarding_oauth_provider_ready_body: 'Этот экземпляр настроен на использование OAuth-провайдера ({provider}), настроенного через Hermes CLI. API-ключ здесь не нужен — нажмите «Продолжить», чтобы завершить настройку.', - onboarding_oauth_provider_not_ready_title: 'OAuth-провайдер ещё не авторизован', - onboarding_oauth_provider_not_ready_body: 'Этот экземпляр настроен на использование {provider}, который работает через OAuth, а не через API-ключ. Запустите hermes auth или hermes model в терминале, чтобы пройти авторизацию, затем обновите Web UI.', - onboarding_oauth_switch_hint: 'Или выберите ниже другой провайдер, чтобы перейти на настройку с ключом API:', - onboarding_notice_workspace: 'Эти значения используют те же API настроек, что и обычное приложение.', - onboarding_workspace_label: 'Рабочее пространство', - onboarding_workspace_or_path: 'Или укажите путь к рабочему пространству', - onboarding_workspace_placeholder: '/home/you/workspace', - onboarding_provider_label: 'Режим настройки', - onboarding_quick_setup_badge: 'Быстрая настройка', - onboarding_api_key_label: 'Ключ API', - onboarding_api_key_placeholder: 'Оставьте пустым, чтобы сохранить уже сохранённый ключ', - onboarding_api_key_help_prefix: 'Сохраняется как секрет в вашем файле `.env` Hermes с помощью', - onboarding_base_url_label: 'Базовый URL', - onboarding_base_url_placeholder: 'https://your-endpoint.example/v1', - onboarding_base_url_help: 'Используйте это для OpenAI-compatible маршрутизаторов, self-hosted серверов, LiteLLM, Ollama, LM Studio, vLLM и похожих endpoint-ов.', - onboarding_model_label: 'Модель по умолчанию', - onboarding_workspace_help: 'Выберите модель, которую Hermes должен использовать для новых чатов после завершения настройки.', - onboarding_custom_model_placeholder: 'имя_вашей_модели', - onboarding_custom_model_help: 'Для собственных endpoint-ов укажите точный ID модели, который ожидает ваш сервер.', - onboarding_notice_password_enabled: 'Пароль уже настроен. Вводите новый только если хотите заменить текущий.', - onboarding_notice_password_recommended: 'Необязательно, но рекомендуется, если вы собираетесь открывать UI не только на localhost.', - onboarding_password_label: 'Пароль (необязательно)', - onboarding_password_placeholder: 'Оставьте пустым, чтобы пропустить', - onboarding_password_help: 'Пароли сохраняются через существующий API настроек и хэшируются на сервере.', - onboarding_notice_finish: 'Позже вы сможете снова открыть настройки и изменить любое из этих значений.', - onboarding_not_set: 'Не задано', - onboarding_password_will_enable: 'Будет включён', - onboarding_password_will_replace: 'Будет заменён текущий пароль', - onboarding_password_keep_existing: 'Оставить текущий пароль', - onboarding_password_remains_disabled: 'Останется отключённым', - onboarding_password_skipped: 'Пропустить пока', - onboarding_finish_help: 'После завершения в настройках сохранится onboarding_completed, и вы попадёте в обычное приложение.', - onboarding_error_choose_workspace: 'Выберите рабочее пространство перед продолжением.', - onboarding_error_choose_model: 'Выберите модель перед продолжением.', - onboarding_error_provider_required: 'Выберите режим настройки перед продолжением.', - onboarding_error_base_url_required: 'Для собственных endpoint-ов требуется базовый URL.', - onboarding_error_workspace_required: 'Рабочее пространство обязательно.', - onboarding_error_model_required: 'Модель обязательна.', - onboarding_complete: 'Первичная настройка завершена', - error_prefix: 'Ошибка: ', - not_available: 'н/д', - never: 'никогда', - add: 'Добавить', - add_failed: 'Не удалось добавить: ', - remove_failed: 'Не удалось удалить: ', - switch_failed: 'Не удалось переключить: ', - name_required: 'Требуется имя', - content_required: 'Требуется содержимое', - view: 'Просмотр', - dismiss: 'Скрыть', - disable: 'Отключить', - cron_no_jobs: 'Запланированные задания не найдены.', - cron_status_off: 'неактивно', - cron_status_paused: 'на паузе', - cron_status_error: 'ошибка', - cron_status_active: 'активно', - cron_next: 'Следующий', - cron_last: 'Последний', - cron_run_now: 'Запустить сейчас', - cron_pause: 'Пауза', - cron_resume: 'Возобновить', - cron_job_name_placeholder: 'Имя задания', - cron_schedule_placeholder: 'Расписание', - cron_prompt_placeholder: 'Промпт', - cron_last_output: 'Последний вывод', - cron_all_runs: 'Все запуски', - cron_hide_runs: 'Скрыть запуски', - cron_no_runs_yet: '(пока запусков нет)', - cron_schedule_required_example: 'Требуется расписание (например, "0 9 * * *" или "every 1h")', - cron_schedule_required: 'Требуется расписание', - cron_prompt_required: 'Требуется промпт', - cron_job_created: 'Задание создано', - cron_job_triggered: 'Задание запущено', - cron_job_paused: 'Задание поставлено на паузу', - cron_job_resumed: 'Задание возобновлено', - cron_job_updated: 'Задание обновлено', - cron_delete_confirm_title: 'Удалить cron-задание', - cron_delete_confirm_message: 'Это действие нельзя отменить.', - cron_job_deleted: 'Задание удалено', - cron_completion_status: (name, status) => `Cron-задание «${name}» — ${status}`, - status_failed: 'неудачно', - status_completed: 'завершено', - - clear_conversation_title: 'Очистить беседу', - clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.', - clear_failed: 'Не удалось очистить: ', - skills_no_match: 'Подходящих навыков не найдено.', - linked_files: 'Связанные файлы', - skill_load_failed: 'Не удалось загрузить навык: ', - skill_file_load_failed: 'Не удалось загрузить файл: ', - skill_name_required: 'Требуется имя навыка', - skill_updated: 'Навык обновлён', - skill_created: 'Навык создан', - memory_notes_label: 'память (заметки)', - memory_saved: 'Память сохранена', - my_notes: 'Мои заметки', - user_profile: 'Пользовательский профиль', - no_notes_yet: 'Пока нет заметок.', - no_profile_yet: 'Пока нет профиля.', - workspace_choose_path: 'Выберите путь к рабочему пространству', - workspace_choose_path_meta: 'Добавьте проверенный путь и переключите эту беседу', - workspace_manage: 'Управление рабочими пространствами', - workspace_manage_meta: 'Открыть панель Spaces', - workspace_use_title: 'Использовать в текущем сеансе', - workspace_use: 'Использовать', - workspace_add_path_placeholder: 'Добавьте путь к рабочему пространству (например, /Users/you/project)', - workspace_paths_validated_hint: 'Перед сохранением пути проверяются на существование.', - workspace_added: 'Рабочее пространство добавлено', - workspace_remove_confirm_title: 'Удалить рабочее пространство', - workspace_remove_confirm_message: (path) => `Удалить «${path}»?`, - workspace_removed: 'Рабочее пространство удалено', - workspace_switch_prompt_title: 'Переключить рабочее пространство', - workspace_switch_prompt_message: 'Введите абсолютный путь к рабочему пространству, чтобы добавить его и переключить эту беседу.', - workspace_switch_prompt_confirm: 'Переключить', - workspace_switch_prompt_placeholder: '/Users/you/project', - workspace_not_added: 'Рабочее пространство не добавлено', - workspace_already_saved: 'Рабочее пространство уже сохранено — выберите его из списка', - workspace_busy_switch: 'Нельзя переключать рабочее пространство, пока агент работает', - discard_file_edits_title: 'Отменить изменения файлов?', - discard_file_edits_message: 'При переключении рабочих пространств несохранённые изменения в предпросмотре будут потеряны.', - workspace_switched_to: (name) => `Переключено на ${name}`, - profiles_no_profiles: 'Профили не найдены.', - profile_api_keys_configured: 'API-ключи настроены', - profile_gateway_running: 'Gateway запущен', - profile_gateway_stopped: 'Gateway остановлен', - profile_active: 'АКТИВЕН', - profile_no_configuration: 'Нет конфигурации', - profile_skill_count: (count) => { - const mod10 = count % 10; - const mod100 = count % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'навык' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'навыка' - : 'навыков'); - return `${count} ${word}`; - }, - profile_use: 'Использовать', - profile_switch_title: 'Переключиться на этот профиль', - profile_delete_title: 'Удалить этот профиль', - profile_default_label: '(по умолчанию)', - profile_name_placeholder: 'Название профиля (строчные буквы, a-z, 0-9, дефисы)', - profile_clone_label: 'Скопировать конфигурацию из активного профиля', - profile_base_url_placeholder: 'Базовый URL (необязательно, например http://localhost:11434)', - profile_api_key_placeholder: 'API-ключ (необязательно)', - manage_profiles: 'Управление профилями', - profiles_load_failed: 'Не удалось загрузить профили', - profiles_busy_switch: 'Нельзя переключать профили, пока агент работает', - profile_switched_new_conversation: (name) => `Переключено на профиль: ${name} — начата новая беседа`, - profile_switched: (name) => `Переключено на профиль: ${name}`, - profile_name_rule: 'Только строчные буквы, цифры, дефисы и подчёркивания', - profile_base_url_rule: 'Базовый URL должен начинаться с http:// или https://', - profile_created: (name) => `Профиль создан: ${name}`, - profile_delete_confirm_title: (name) => `Удалить профиль «${name}»?`, - profile_delete_confirm_message: 'Это удалит всю конфигурацию, навыки, память и сеансы этого профиля.', - profile_deleted: (name) => `Профиль удалён: ${name}`, - active_conversation_none: 'Активная беседа не выбрана.', - active_conversation_meta: (title, count) => { - const mod10 = count % 10; - const mod100 = count % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'сообщение' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'сообщения' - : 'сообщений'); - return `${title} · ${count} ${word}`; - }, - settings_unsaved_changes: 'У вас есть несохранённые изменения.', - sign_out_failed: 'Не удалось выйти: ', - disable_auth_confirm_title: 'Отключить защиту паролем', - disable_auth_confirm_message: 'Любой сможет получить доступ к этому экземпляру.', - auth_disabled: 'Авторизация отключена — защита паролем снята', - disable_auth_failed: 'Не удалось отключить авторизацию: ', - bg_error_single: (title) => `В "${title}" возникла ошибка`, - bg_error_multi: (count) => `${count} сеансов столкнулись с ошибкой`, - }, - - es: { - _lang: 'es', - _label: 'Español', - _speech: 'es-ES', - // boot.js - cancelling: 'Cancelando…', - cancel_failed: 'Error al cancelar: ', - mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.', - mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.', - mic_network: 'El reconocimiento de voz no está disponible.', - mic_error: 'Error de entrada por voz: ', - session_imported: 'Sesión importada', - import_failed: 'Error al importar: ', - import_invalid_json: 'JSON inválido', - image_pasted: 'Imagen pegada: ', - // messages.js - edit_message: 'Editar mensaje', - regenerate: 'Regenerar respuesta', - copy: 'Copiar', - copied: '¡Copiado!', - you: 'Tú', - thinking: 'Pensando', - expand_all: 'Expandir todo', - collapse_all: 'Contraer todo', - edit_failed: 'Error al editar: ', - regen_failed: 'Error al regenerar: ', - reconnect_active: 'Todavía se está generando una respuesta. ¿Recargar cuando termine?', - reconnect_finished: 'Había una respuesta en curso cuando te fuiste. Puede que los mensajes se hayan actualizado.', - // approval card - approval_heading: 'Se requiere aprobación', - approval_desc_prefix: 'Se detectó un comando peligroso', - approval_btn_once: 'Permitir una vez', - approval_btn_once_title: 'Permitir solo este comando (Enter)', - approval_btn_session: 'Permitir en la sesión', - approval_btn_session_title: 'Permitir durante esta sesión de conversación', - approval_btn_always: 'Permitir siempre', - approval_btn_always_title: 'Permitir siempre este patrón de comando', - approval_btn_deny: 'Denegar', - approval_btn_deny_title: 'Denegar — no ejecutar este comando', - approval_responding: 'Respondiendo…', - clarify_heading: 'Se necesita aclaración', - clarify_hint: 'Elige una opción o escribe tu propia respuesta abajo.', - clarify_other: 'Otra', - clarify_send: 'Enviar', - clarify_input_placeholder: 'Escribe tu respuesta…', - clarify_responding: 'Respondiendo…', - untitled: 'Sin título', - n_messages: (n) => `${n} mensajes`, - model_unavailable: ' (no disponible)', - model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores', - provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`, - provider_mismatch_label: 'Proveedor incompatible', - model_custom_label: 'ID de modelo personalizado', - model_custom_placeholder: 'p. ej. openai/gpt-5.4', - model_search_placeholder: 'Buscar modelos…', - model_search_no_results: 'No se encontraron modelos', - // commands.js - cmd_help: 'Listar los comandos disponibles', - cmd_clear: 'Borrar los mensajes de la conversación', - cmd_compress: 'Comprimir manualmente el contexto de la conversación (uso: /compress [tema])', - cmd_compact_alias: 'Alias antiguo de /compress', - cmd_compact: 'Comprimir contexto de la conversación', - cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)', - cmd_workspace: 'Cambiar de espacio de trabajo por nombre', - cmd_new: 'Iniciar una nueva sesión de chat', - cmd_usage: 'Activar o desactivar el uso de tokens', - cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)', - cmd_personality: 'Cambiar la personalidad del agente', - cmd_skills: 'Listar las skills de Hermes disponibles', - available_commands: 'Comandos disponibles:', - type_slash: 'Escribe / para ver los comandos', - conversation_cleared: 'Conversación borrada', - command_label: 'Comando', - context_compaction_label: 'Compacción de contexto', - reference_only_label: 'Solo referencia', - model_usage: 'Uso: /model ', - no_model_match: 'No hay ningún modelo que coincida con "', - switched_to: 'Se cambió a ', - workspace_usage: 'Uso: /workspace ', - no_workspace_match: 'No hay ningún espacio de trabajo que coincida con "', - switched_workspace: 'Se cambió al espacio de trabajo: ', - workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ', - new_session: 'Nueva sesión creada', - compressing: 'Solicitando compresión del contexto...', - compress_running_label: 'Comprimiendo', - compress_complete_label: 'Compresión completa', - compress_failed_label: 'La compresión falló', - focus_label: 'Tema', - token_usage_on: 'Uso de tokens activado', - token_usage_off: 'Uso de tokens desactivado', - theme_usage: 'Uso: /theme ', - theme_set: 'Tema: ', - no_active_session: 'No hay ninguna sesión activa', - no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)', - available_personalities: 'Personalidades disponibles:', - personality_switch_hint: '\n\nUsa `/personality ` para cambiar, o `/personality none` para limpiar.', - personalities_load_failed: 'No se pudieron cargar las personalidades', - personality_cleared: 'Personalidad borrada', - personality_set: 'Personalidad: ', - failed_colon: 'Error: ', - // ui.js - no_workspace: 'Sin espacio de trabajo', - workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.', - workspace_empty_dir: 'Este espacio de trabajo está vacío.', - // workspace.js - unsaved_confirm: 'Tienes cambios sin guardar en la vista previa. ¿Descartar y navegar?', - save: 'Guardar', - edit: 'Editar', - save_title: 'Guardar cambios', - edit_title: 'Editar este archivo', - saved: 'Guardado', - save_failed: 'Error al guardar: ', - image_load_failed: 'No se pudo cargar la imagen', - file_open_failed: 'No se pudo abrir el archivo', - downloading: (name) => `Descargando ${name}…`, - double_click_rename: 'Haz doble clic para renombrar', - renamed_to: 'Renombrado a ', - rename_failed: 'Error al renombrar: ', - delete_title: 'Eliminar', - delete_confirm: (name) => `¿Eliminar ${name}?`, - deleted: 'Eliminado ', - delete_failed: 'Error al eliminar: ', - new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):', - created: 'Creado ', - create_failed: 'Error al crear: ', - new_folder_prompt: 'Nombre de la carpeta nueva:', - folder_created: 'Carpeta creada ', - folder_create_failed: 'Error al crear la carpeta: ', - remove_title: 'Quitar', - empty_dir: '(vacío)', - upload_failed: 'Error al subir: ', - all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`, - // settings panel - settings_title: 'Configuración', - settings_save_btn: 'Guardar configuración', - settings_label_model: 'Modelo predeterminado', - settings_label_send_key: 'Tecla de envío', - settings_label_theme: 'Tema', - settings_label_skin: 'Piel', - settings_label_language: 'Idioma', - settings_label_token_usage: 'Mostrar uso de tokens', - settings_label_bubble_layout: 'Disposición en burbujas', - settings_label_cli_sessions: 'Mostrar sesiones de CLI', - settings_label_sync_insights: 'Sincronizar con insights', - settings_label_check_updates: 'Buscar actualizaciones', - settings_label_bot_name: 'Nombre del asistente', - settings_label_password: 'Contraseña de acceso', - settings_saved: 'Configuración guardada', - settings_save_failed: 'Error al guardar: ', - settings_load_failed: 'Error al cargar la configuración: ', - settings_saved_pw: 'Configuración guardada — la contraseña queda activada y este navegador sigue autenticado', - settings_saved_pw_updated: 'Configuración guardada — contraseña actualizada', - // login page (used server-side via /api/i18n/login endpoint) - login_title: 'Iniciar sesión', - login_subtitle: 'Introduce tu contraseña para continuar', - login_placeholder: 'Contraseña', - login_btn: 'Entrar', - login_invalid_pw: 'Contraseña inválida', - login_conn_failed: 'Error de conexión', - dialog_confirm_title: 'Confirmar acción', - dialog_prompt_title: 'Introduce un valor', - dialog_confirm_btn: 'Confirmar', - discard: 'Descartar', - clear: 'Borrar', - create: 'Crear', - remove: 'Quitar', - project_name_prompt: 'Nombre del proyecto:', - // Sidebar & Tabs - tab_chat: 'Chat', - tab_tasks: 'Tareas', - tab_skills: 'Habilidades', - tab_memory: 'Memoria', - tab_workspaces: 'Espacios', - tab_profiles: 'Perfiles', - - new_conversation: 'Nueva conversación', - filter_conversations: 'Filtrar conversaciones...', - session_time_unknown: 'Desconocido', - session_time_just_now: 'justo ahora', - session_time_minutes_ago: (n) => `hace ${n} minuto${n === 1 ? '' : 's'}`, - session_time_hours_ago: (n) => `hace ${n} hora${n === 1 ? '' : 's'}`, - session_time_days_ago: (n) => `hace ${n} día${n === 1 ? '' : 's'}`, - session_time_last_week: 'la semana pasada', - session_time_bucket_today: 'Hoy', - session_time_bucket_yesterday: 'Ayer', - session_time_bucket_this_week: 'Esta semana', - session_time_bucket_last_week: 'La semana pasada', - session_time_bucket_older: 'Más antiguo', - scheduled_jobs: 'Tareas programadas', - new_job: 'Nueva tarea', - loading: 'Cargando...', - search_skills: 'Buscar skills...', - new_skill: 'Nueva skill', - personal_memory: 'Memoria personal', - - workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.', - new_profile: 'Nuevo perfil', - transcript: 'Transcripción', - download_transcript: 'Descargar como Markdown', - import: 'Importar', - // Settings detail - settings_label_sound: 'Sonido de notificación', - settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.', - settings_label_notifications: 'Notificaciones del navegador', - settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.', - settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.', - settings_desc_bubble_layout: 'Alinea los mensajes del usuario a la derecha y las respuestas del asistente a la izquierda. Desactivado por defecto para mantener los bloques de código y la salida de herramientas a ancho completo.', - settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.', - settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', - settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', - settings_desc_bot_name: 'Nombre visible del asistente en toda la UI. Por defecto es Hermes.', - settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.', - password_placeholder: 'Introduce una contraseña nueva…', - disable_auth: 'Desactivar autenticación', - sign_out: 'Cerrar sesión', - cancel: 'Cancelar', - create_job: 'Crear tarea', - save_skill: 'Guardar skill', - editing: 'Editando', - // Empty state - empty_title: '¿En qué puedo ayudarte?', - empty_subtitle: 'Pregunta lo que quieras, ejecuta comandos, explora archivos o gestiona tus tareas programadas.', - suggest_files: '¿Qué archivos hay en este espacio de trabajo?', - suggest_schedule: '¿Qué tengo hoy en mi agenda?', - suggest_plan: 'Ayúdame a planificar un proyecto pequeño.', - // onboarding - onboarding_badge: 'PRIMER USO', - onboarding_title: 'Bienvenido a Hermes Web UI', - onboarding_lead: 'Una guía rápida verificará Hermes, guardará una configuración real del proveedor, elegirá un espacio de trabajo y un modelo, y opcionalmente protegerá la app con una contraseña.', - onboarding_back: 'Atrás', - onboarding_continue: 'Continuar', - onboarding_skip: 'Omitir configuración', - onboarding_skipped: 'Configuración omitida — se usa la configuración existente.', - onboarding_open: 'Abrir Hermes', - onboarding_step_system_title: 'Comprobación del sistema', - onboarding_step_system_desc: 'Verifica Hermes Agent y la visibilidad de la configuración.', - onboarding_step_setup_title: 'Configuración del proveedor', - onboarding_step_setup_desc: 'Guarda la configuración mínima real de Hermes.', - onboarding_step_workspace_title: 'Espacio de trabajo + modelo', - onboarding_step_workspace_desc: 'Elige los valores predeterminados para nuevas sesiones y chats.', - onboarding_step_password_title: 'Contraseña opcional', - onboarding_step_password_desc: 'Protege la Web UI antes de compartirla.', - onboarding_step_finish_title: 'Finalizar', - onboarding_step_finish_desc: 'Revisa todo y entra en la app.', - onboarding_notice_system_ready: 'Parece que Hermes Agent está accesible desde la Web UI.', - onboarding_notice_system_unavailable: 'Hermes Agent todavía no está totalmente disponible. Bootstrap puede instalarlo, pero la configuración del proveedor quizá aún requiera una terminal.', - onboarding_check_agent: 'Hermes Agent', - onboarding_check_agent_ready: 'Detectado e importable', - onboarding_check_agent_missing: 'Falta o solo es parcialmente importable', - onboarding_check_password: 'Contraseña', - onboarding_check_password_enabled: 'Ya está activada', - onboarding_check_password_disabled: 'Todavía no está activada', - onboarding_check_provider: 'Configuración del proveedor', - onboarding_check_provider_ready: 'Listo para chatear', - onboarding_check_provider_partial: 'Guardado pero incompleto', - onboarding_check_provider_pending: 'Necesita verificación', - onboarding_config_file: 'Archivo de configuración:', - onboarding_env_file: 'Archivo .env:', - onboarding_unknown: 'Desconocido', - onboarding_current_provider: 'Configuración actual:', - onboarding_missing_imports: 'Importaciones faltantes:', - onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.', - onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.', - onboarding_oauth_provider_ready_title: 'Proveedor ya autenticado', - onboarding_oauth_provider_ready_body: 'Esta instancia está configurada para usar un proveedor OAuth ({provider}) configurado mediante la CLI de Hermes. No se necesita clave API aquí — haz clic en Continuar para finalizar la configuración.', - onboarding_oauth_provider_not_ready_title: 'Proveedor OAuth no autenticado aún', - onboarding_oauth_provider_not_ready_body: 'Esta instancia está configurada para usar {provider}, que utiliza OAuth en lugar de una clave API. Ejecuta hermes auth o hermes model en una terminal para autenticarte y recarga la interfaz web.', - onboarding_oauth_switch_hint: 'O elige un proveedor diferente a continuación para cambiar a la configuración con clave API:', - onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.', - onboarding_workspace_label: 'Espacio de trabajo', - onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo', - onboarding_workspace_placeholder: '/home/you/workspace', - onboarding_provider_label: 'Modo de configuración', - onboarding_quick_setup_badge: 'configuración rápida', - onboarding_api_key_label: 'API key', - onboarding_api_key_placeholder: 'Déjala en blanco para conservar una key ya guardada', - onboarding_api_key_help_prefix: 'Se guarda como secreto en tu archivo .env de Hermes usando', - onboarding_base_url_label: 'Base URL', - onboarding_base_url_placeholder: 'https://tu-endpoint.example/v1', - onboarding_base_url_help: 'Úsalo para routers OpenAI-compatible, servidores autoalojados, LiteLLM, Ollama, LM Studio, vLLM o endpoints parecidos.', - onboarding_model_label: 'Modelo predeterminado', - onboarding_workspace_help: 'Elige el modelo que Hermes debe usar para nuevos chats cuando termine la configuración.', - onboarding_custom_model_placeholder: 'tu-modelo', - onboarding_custom_model_help: 'Para endpoints personalizados, introduce el identificador exacto del modelo que espera tu servidor.', - onboarding_notice_password_enabled: 'Ya hay una contraseña configurada. Introduce una nueva solo si quieres reemplazarla.', - onboarding_notice_password_recommended: 'Es opcional, pero recomendable si vas a exponer la UI más allá de localhost.', - onboarding_password_label: 'Contraseña (opcional)', - onboarding_password_placeholder: 'Déjala en blanco para omitirla', - onboarding_password_help: 'Las contraseñas se guardan mediante la API de configuración existente y se hashean en el servidor.', - onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.', - onboarding_not_set: 'Sin definir', - onboarding_password_will_enable: 'Se activará', - onboarding_password_will_replace: 'Se reemplazará', - onboarding_password_keep_existing: 'Mantener la contraseña actual', - onboarding_password_remains_disabled: 'Seguirá desactivada', - onboarding_password_skipped: 'Se omitirá por ahora', - onboarding_finish_help: 'Al finalizar se guarda onboarding_completed en la configuración y entras en la app normal.', - onboarding_error_choose_workspace: 'Elige un espacio de trabajo antes de continuar.', - onboarding_error_choose_model: 'Elige un modelo antes de continuar.', - onboarding_error_provider_required: 'Elige un modo de configuración antes de continuar.', - onboarding_error_base_url_required: 'La base URL es obligatoria para endpoints personalizados.', - onboarding_error_workspace_required: 'El espacio de trabajo es obligatorio.', - onboarding_error_model_required: 'El modelo es obligatorio.', - onboarding_complete: 'Onboarding completado', - // panel/runtime i18n - error_prefix: 'Error: ', - not_available: 'N/A', - never: 'never', - add: 'Add', - add_failed: 'Add failed: ', - remove_failed: 'Remove failed: ', - switch_failed: 'Switch failed: ', - name_required: 'Name is required', - content_required: 'Content is required', - view: 'View', - dismiss: 'Dismiss', - disable: 'Disable', - cron_no_jobs: 'No scheduled jobs found.', - cron_status_off: 'off', - cron_status_paused: 'paused', - cron_status_error: 'error', - cron_status_active: 'active', - cron_next: 'Next', - cron_last: 'Last', - cron_run_now: 'Run now', - cron_pause: 'Pause', - cron_resume: 'Resume', - cron_job_name_placeholder: 'Job name', - cron_schedule_placeholder: 'Schedule', - cron_prompt_placeholder: 'Prompt', - cron_last_output: 'Last output', - cron_all_runs: 'All runs', - cron_hide_runs: 'Hide runs', - cron_no_runs_yet: '(no runs yet)', - cron_schedule_required_example: 'Schedule is required (e.g. "0 9 * * *" or "every 1h")', - cron_schedule_required: 'Schedule is required', - cron_prompt_required: 'Prompt is required', - cron_job_created: 'Job created', - cron_job_triggered: 'Job triggered', - cron_job_paused: 'Job paused', - cron_job_resumed: 'Job resumed', - cron_job_updated: 'Job updated', - cron_delete_confirm_title: 'Delete cron job', - cron_delete_confirm_message: 'This cannot be undone.', - cron_job_deleted: 'Job deleted', - cron_completion_status: (name, status) => `Cron "${name}" ${status}`, - status_failed: 'failed', - status_completed: 'completed', - - clear_conversation_title: 'Clear conversation', - clear_conversation_message: 'Clear all messages? This cannot be undone.', - clear_failed: 'Clear failed: ', - skills_no_match: 'No skills match.', - linked_files: 'Linked Files', - skill_load_failed: 'Could not load skill: ', - skill_file_load_failed: 'Could not load file: ', - skill_name_required: 'Skill name is required', - skill_updated: 'Skill updated', - skill_created: 'Skill created', - memory_notes_label: 'memory (notes)', - memory_saved: 'Memory saved', - my_notes: 'My Notes', - user_profile: 'User Profile', - no_notes_yet: 'No notes yet.', - no_profile_yet: 'No profile yet.', - workspace_choose_path: 'Choose workspace path', - workspace_choose_path_meta: 'Add a validated path and switch this conversation', - workspace_manage: 'Manage workspaces', - workspace_manage_meta: 'Open the Spaces panel', - workspace_use_title: 'Use in current session', - workspace_use: 'Use', - workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)', - workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.', - workspace_added: 'Workspace added', - workspace_remove_confirm_title: 'Remove workspace', - workspace_remove_confirm_message: (path) => `Remove "${path}"?`, - workspace_removed: 'Workspace removed', - workspace_switch_prompt_title: 'Switch workspace', - workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.', - workspace_switch_prompt_confirm: 'Switch', - workspace_switch_prompt_placeholder: '/Users/you/project', - workspace_not_added: 'Workspace was not added', - workspace_already_saved: 'Workspace already saved — choose it from the list', - workspace_busy_switch: 'Cannot switch workspace while agent is running', - discard_file_edits_title: 'Discard file edits?', - discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.', - workspace_switched_to: (name) => `Switched to ${name}`, - profiles_no_profiles: 'No profiles found.', - profile_api_keys_configured: 'API keys configured', - profile_gateway_running: 'Gateway running', - profile_gateway_stopped: 'Gateway stopped', - profile_active: 'ACTIVE', - profile_no_configuration: 'No configuration', - profile_skill_count: (count) => `${count} habilidad${count === 1 ? '' : 'es'}`, - profile_use: 'Use', - profile_switch_title: 'Switch to this profile', - profile_delete_title: 'Eliminar este perfil', - profile_default_label: '(predeterminado)', - profile_name_placeholder: 'Nombre del perfil (minúsculas, a-z, 0-9, guiones)', - profile_clone_label: 'Clonar configuración del perfil activo', - profile_base_url_placeholder: 'URL base (opcional, p. ej. http://localhost:11434)', - profile_api_key_placeholder: 'Clave API (opcional)', - manage_profiles: 'Manage profiles', - profiles_load_failed: 'Failed to load profiles', - profiles_busy_switch: 'Cannot switch profiles while agent is running', - profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, - profile_switched: (name) => `Switched to profile: ${name}`, - profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', - profile_base_url_rule: 'Base URL must start with http:// or https://', - profile_created: (name) => `Profile created: ${name}`, - 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.', - sign_out_failed: 'Sign out failed: ', - disable_auth_confirm_title: 'Disable password protection', - disable_auth_confirm_message: 'Anyone will be able to access this instance.', - auth_disabled: 'Auth disabled — password protection removed', - disable_auth_failed: 'Failed to disable auth: ', - bg_error_single: (title) => `"${title}" has encountered an error`, - bg_error_multi: (count) => `${count} sessions have encountered an error`, - }, - - de: { - _lang: 'de', - _label: 'Deutsch', - _speech: 'de-DE', - // boot.js - cancelling: 'Wird abgebrochen\u2026', - cancel_failed: 'Abbrechen fehlgeschlagen: ', - mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.', - mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.', - mic_network: 'Spracherkennung nicht verfügbar.', - mic_error: 'Spracheingabefehler: ', - session_imported: 'Sitzung importiert', - import_failed: 'Import fehlgeschlagen: ', - import_invalid_json: 'Ungültiges JSON', - image_pasted: 'Bild eingefügt: ', - // messages.js - edit_message: 'Nachricht bearbeiten', - regenerate: 'Antwort regenerieren', - copy: 'Kopieren', - copied: 'Kopiert!', - you: 'Du', - thinking: 'Nachdenken', - expand_all: 'Alle ausklappen', - collapse_all: 'Alle einklappen', - edit_failed: 'Bearbeiten fehlgeschlagen: ', - regen_failed: 'Regeneration fehlgeschlagen: ', - reconnect_active: 'Eine Antwort wird noch generiert. Neu laden, wenn bereit?', - reconnect_finished: 'Eine Antwort war in Arbeit, als Sie zuletzt gegangen sind. Nachrichten könnten aktualisiert worden sein.', - // approval card - approval_heading: 'Genehmigung erforderlich', - approval_desc_prefix: 'Gefährlicher Befehl erkannt', - approval_btn_once: 'Einmal zulassen', - approval_btn_once_title: 'Diesen einen Befehl zulassen (Enter)', - approval_btn_session: 'Sitzung zulassen', - approval_btn_session_title: 'Für diese Konversationssitzung zulassen', - approval_btn_always: 'Immer zulassen', - approval_btn_always_title: 'Dieses Befehlsmuster immer zulassen', - approval_btn_deny: 'Ablehnen', - approval_btn_deny_title: 'Ablehnen \u2014 diesen Befehl nicht ausführen', - approval_responding: 'Antwortet\u2026', - clarify_heading: 'Klärung erforderlich', - clarify_hint: 'Wähle eine Option oder schreibe deine eigene Antwort unten.', - clarify_other: 'Andere', - clarify_send: 'Senden', - clarify_input_placeholder: 'Gib deine Antwort ein…', - clarify_responding: 'Antwortet\u2026', - untitled: 'Unbenannt', - n_messages: (n) => `${n} Nachrichten`, - model_unavailable: ' (nicht verfügbar)', - model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste', - provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`, - provider_mismatch_label: 'Provider-Konflikt', - // commands.js - cmd_help: 'Verfügbare Befehle auflisten', - cmd_clear: 'Konversationsverlauf löschen', - cmd_compress: 'Kontext manuell komprimieren (Nutzung: /compress [Thema])', - cmd_compact_alias: 'Alte Alias für /compress', - cmd_model: 'Modell wechseln (z.B. /model gpt-4o)', - cmd_workspace: 'Workspace nach Namen wechseln', - cmd_new: 'Neue Chat-Sitzung starten', - cmd_usage: 'Token-Verbrauchsanzeige umschalten', - cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard)', - cmd_personality: 'Agenten-Persönlichkeit wechseln', - cmd_skills: 'Verfügbare Hermes-Skills auflisten', - available_commands: 'Verfügbare Befehle:', - type_slash: 'Tippe / für Befehle', - conversation_cleared: 'Konversation gelöscht', - command_label: 'Befehl', - context_compaction_label: 'Kontextkomprimierung', - reference_only_label: 'Nur Referenz', - model_usage: 'Nutzung: /model ', - no_model_match: 'Kein Modell gefunden für "', - switched_to: 'Gewechselt zu ', - workspace_usage: 'Nutzung: /workspace ', - no_workspace_match: 'Kein Workspace gefunden für "', - switched_workspace: 'Gewechselt zu Workspace: ', - workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ', - new_session: 'Neue Sitzung erstellt', - compressing: 'Kontext-Komprimierung wird angefordert...', - compress_running_label: 'Komprimierung', - compress_complete_label: 'Komprimierung abgeschlossen', - compress_failed_label: 'Komprimierung fehlgeschlagen', - focus_label: 'Thema', - token_usage_on: 'Token-Verbrauch an', - token_usage_off: 'Token-Verbrauch aus', - theme_usage: 'Nutzung: /theme ', - theme_set: 'Theme: ', - no_active_session: 'Keine aktive Sitzung', - no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)', - available_personalities: 'Verfügbare Persönlichkeiten:', - personality_switch_hint: '\n\nNutze `/personality ` zum Wechseln, oder `/personality none` zum Löschen.', - personalities_load_failed: 'Fehler beim Laden der Persönlichkeiten', - personality_cleared: 'Persönlichkeit gelöscht', - personality_set: 'Persönlichkeit: ', - failed_colon: 'Fehlgeschlagen: ', - // ui.js - no_workspace: 'Kein Workspace', - workspace_empty_no_path: 'Kein Workspace ausgewählt. Wähle einen Workspace unter Einstellungen \u2192 Workspace, um Dateien zu durchsuchen.', - workspace_empty_dir: 'Dieser Workspace ist leer.', - dialog_confirm_title: 'Aktion bestätigen', - dialog_prompt_title: 'Wert eingeben', - dialog_confirm_btn: 'Bestätigen', - // workspace.js - unsaved_confirm: 'Sie haben ungespeicherte Änderungen in der Vorschau. Verwerfen und fortfahren?', - discard: 'Verwerfen', - save: 'Speichern', - edit: 'Bearbeiten', - clear: 'Leeren', - create: 'Erstellen', - remove: 'Entfernen', - save_title: 'Änderungen speichern', - edit_title: 'Diese Datei bearbeiten', - saved: 'Gespeichert', - save_failed: 'Speichern fehlgeschlagen: ', - image_load_failed: 'Bild konnte nicht geladen werden', - file_open_failed: 'Datei konnte nicht geöffnet werden', - downloading: (name) => `Lade ${name} herunter\u2026`, - double_click_rename: 'Doppelklick zum Umbenennen', - renamed_to: 'Umbenannt in ', - rename_failed: 'Umbenennen fehlgeschlagen: ', - delete_title: 'Löschen', - delete_confirm: (name) => `${name} löschen?`, - deleted: 'Gelöscht ', - delete_failed: 'Löschen fehlgeschlagen: ', - new_file_prompt: 'Neuer Dateiname (z.B. notes.md):', - project_name_prompt: 'Projektname:', - created: 'Erstellt ', - create_failed: 'Erstellen fehlgeschlagen: ', - new_folder_prompt: 'Neuer Ordnername:', - folder_created: 'Ordner erstellt ', - folder_create_failed: 'Ordner erstellen fehlgeschlagen: ', - remove_title: 'Entfernen', - empty_dir: '(leer)', - upload_failed: 'Upload fehlgeschlagen: ', - all_uploads_failed: (n) => `Alle ${n} Upload(s) fehlgeschlagen`, - // settings panel - settings_title: 'Einstellungen', - settings_save_btn: 'Einstellungen speichern', - settings_label_model: 'Standard-Modell', - settings_label_send_key: 'Sende-Taste', - settings_label_theme: 'Theme', - settings_label_skin: 'Skin', - settings_label_language: 'Sprache', - settings_label_token_usage: 'Token-Verbrauch anzeigen', - settings_label_cli_sessions: 'Agent-Sitzungen anzeigen', - settings_label_sync_insights: 'Mit Insights synchronisieren', - settings_label_check_updates: 'Nach Updates suchen', - settings_label_bot_name: 'Assistenten-Name', - settings_label_password: 'Zugangspasswort', - settings_saved: 'Einstellungen gespeichert', - settings_save_failed: 'Speichern fehlgeschlagen: ', - settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ', - settings_saved_pw: 'Einstellungen gespeichert — Passwortschutz aktiviert und dieser Browser bleibt angemeldet', - settings_saved_pw_updated: 'Einstellungen gespeichert — Passwort aktualisiert', - // login page - login_title: 'Anmelden', - login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren', - login_placeholder: 'Passwort', - login_btn: 'Anmelden', - login_invalid_pw: 'Ungültiges Passwort', - login_conn_failed: 'Verbindung fehlgeschlagen', - dialog_confirm_title: 'Aktion bestätigen', - dialog_prompt_title: 'Wert eingeben', - dialog_confirm_btn: 'Bestätigen', - discard: 'Verwerfen', - clear: 'Leeren', - create: 'Erstellen', - remove: 'Entfernen', - project_name_prompt: 'Projektname:', - // Sidebar & Tabs - tab_chat: 'Chat', - tab_tasks: 'Aufgaben', - tab_skills: 'Skills', - tab_memory: 'Gedächtnis', - tab_workspaces: 'Spaces', - tab_profiles: 'Profile', - - new_conversation: 'Neuer Chat', - filter_conversations: 'Chats filtern...', - scheduled_jobs: 'Geplante Aufgaben', - new_job: 'Neuer Job', - loading: 'Lädt...', - search_skills: 'Skills suchen...', - new_skill: 'Neuer Skill', - personal_memory: 'Persönliches Gedächtnis', - - workspace_desc: 'Workspaces hinzufügen und wechseln.', - new_profile: 'Neues Profil', - transcript: 'Protokoll', - download_transcript: 'Als Markdown herunterladen', - import: 'Importieren', - // Settings detail - settings_label_sound: 'Benachrichtigungston', - settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.', - settings_label_notifications: 'Browser-Benachrichtigungen', - settings_desc_notifications: 'Zeigt eine Systembenachrichtigung an, wenn eine Antwort fertiggestellt wird, während der Tab im Hintergrund ist.', - settings_desc_token_usage: 'Zeigt die Anzahl der Input/Output-Token unter jeder Antwort des Assistenten an. Auch umschaltbar mit /usage.', - settings_desc_cli_sessions: 'Fügt Sitzungen aus der Hermes CLI (state.db) in die Sitzungsliste ein. Klicken Sie auf eine CLI-Sitzung, um sie zu importieren und das Gespräch fortzusetzen.', - settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.', - settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.', - settings_desc_bot_name: 'Anzeigename für den Assistenten in der UI. Standardmäßig Hermes.', - settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.', - password_placeholder: 'Neues Passwort eingeben…', - disable_auth: 'Authentifizierung deaktivieren', - sign_out: 'Abmelden', - cancel: 'Abbrechen', - create_job: 'Job erstellen', - save_skill: 'Skill speichern', - editing: 'Bearbeitung', - // Empty state - empty_title: 'Wie kann ich helfen?', - empty_subtitle: 'Frage mich alles, führe Befehle aus, erkunde Dateien oder verwalte deine Aufgaben.', - suggest_files: 'Welche Dateien sind in diesem Workspace?', - suggest_schedule: 'Was steht heute auf meinem Plan?', - suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.', - onboarding_password_will_enable: 'Wird aktiviert', - onboarding_password_will_replace: 'Wird ersetzt', - onboarding_password_keep_existing: 'Aktuelles Passwort beibehalten', - onboarding_password_remains_disabled: 'Bleibt deaktiviert', - }, - - zh: { - _lang: 'zh', - _label: '\u7b80\u4f53\u4e2d\u6587', - _speech: 'zh-CN', - // boot.js - cancelling: '\u6b63\u5728\u53d6\u6d88...', - cancel_failed: '\u53d6\u6d88\u5931\u8d25\uff1a', - mic_denied: '\u9ea6\u514b\u98ce\u8bbf\u95ee\u88ab\u62d2\u7edd\uff0c\u8bf7\u68c0\u67e5\u6d4f\u89c8\u5668\u6743\u9650\u3002', - mic_no_speech: '\u6ca1\u6709\u68c0\u6d4b\u5230\u8bed\u97f3\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002', - mic_network: '\u8bed\u97f3\u8bc6\u522b\u5f53\u524d\u4e0d\u53ef\u7528\u3002', - mic_error: '\u8bed\u97f3\u8f93\u5165\u51fa\u9519\uff1a', - session_imported: '\u4f1a\u8bdd\u5df2\u5bfc\u5165', - import_failed: '\u5bfc\u5165\u5931\u8d25\uff1a', - import_invalid_json: 'JSON \u65e0\u6548', - image_pasted: '\u5df2\u7c98\u8d34\u56fe\u7247\uff1a', - // messages.js - edit_message: '\u7f16\u8f91\u6d88\u606f', - regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u590d', - copy: '\u590d\u5236', - copied: '\u5df2\u590d\u5236', - you: '\u4f60', - thinking: '\u601d\u8003\u8fc7\u7a0b', - expand_all: '\u5168\u90e8\u5c55\u5f00', - collapse_all: '\u5168\u90e8\u6298\u53e0', - edit_failed: '\u7f16\u8f91\u5931\u8d25\uff1a', - regen_failed: '\u91cd\u65b0\u751f\u6210\u5931\u8d25\uff1a', - reconnect_active: '\u56de\u590d\u4ecd\u5728\u751f\u6210\u4e2d\uff0c\u51c6\u5907\u597d\u540e\u8981\u91cd\u65b0\u52a0\u8f7d\u5417\uff1f', - reconnect_finished: '\u4f60\u79bb\u5f00\u65f6\u6709\u56de\u590d\u6b63\u5728\u751f\u6210\uff0c\u6d88\u606f\u5185\u5bb9\u53ef\u80fd\u5df2\u7ecf\u66f4\u65b0\u3002', - // approval card - approval_heading: '需要审批', - approval_desc_prefix: '检测到危险命令', - approval_btn_once: '允许一次', - approval_btn_once_title: '允许执行此命令一次(Enter)', - approval_btn_session: '本次允许', - approval_btn_session_title: '本次会话期间允许', - approval_btn_always: '始终允许', - approval_btn_always_title: '始终允许此命令模式', - approval_btn_deny: '拒绝', - approval_btn_deny_title: '拒绝 — 不执行此命令', - approval_responding: '处理中…', - clarify_heading: '需要澄清', - clarify_hint: '请选择一个选项,或在下方输入你自己的回答。', - clarify_other: '其他', - clarify_send: '发送', - clarify_input_placeholder: '请输入你的回答…', - clarify_responding: '处理中…', - untitled: '\u672a\u547d\u540d', - n_messages: (n) => `${n} \u6761\u6d88\u606f`, - model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09', - model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d', - provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`, - provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d', - model_custom_label: '\u81ea\u5b9a\u4e49\u6a21\u578b ID', - model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4', - model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026', - model_search_no_results: '\u672a\u627e\u5230\u6a21\u578b', - // commands.js - cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4', - cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f', - cmd_compress: '\u624b\u52a8\u538b\u7f29\u5bf9\u8bdd\u4e0a\u4e0b\u6587\uff08\u7528\u6cd5\uff1a/compress [\u4e3b\u9898]\uff09', - cmd_compact_alias: '\u65e7\u522b\u540d\uff1a/compress', - cmd_model: '\u5207\u6362\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09', - cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a', - cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd', - cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a', - cmd_theme: '\u5207\u6362\u5916\u89c2\uff08\u4e3b\u9898\uff1asystem/dark/light\uff0c\u76ae\u80a4\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard\uff09', - cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe', - cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd', - available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a', - type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4', - conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a', - command_label: '\u547d\u4ee4', - context_compaction_label: '\u4e0a\u4e0b\u6587\u538b\u7f29', - reference_only_label: '\u4ec5\u4f9b\u53c2\u8003', - model_usage: '\u7528\u6cd5\uff1a/model ', - no_model_match: '\u6ca1\u6709\u5339\u914d\u201c', - switched_to: '\u5df2\u5207\u6362\u5230 ', - workspace_usage: '\u7528\u6cd5\uff1a/workspace ', - no_workspace_match: '\u6ca1\u6709\u5339\u914d\u201c', - switched_workspace: '\u5df2\u5207\u6362\u5de5\u4f5c\u533a\uff1a', - workspace_switch_failed: '\u5de5\u4f5c\u533a\u5207\u6362\u5931\u8d25\uff1a', - new_session: '\u5df2\u65b0\u5efa\u4f1a\u8bdd', - compressing: '\u6b63\u5728\u8bf7\u6c42\u538b\u7f29\u4e0a\u4e0b\u6587...', - compress_running_label: '\u538b\u7f29\u4e2d', - compress_complete_label: '\u538b\u7f29\u5b8c\u6210', - compress_failed_label: '\u538b\u7f29\u5931\u8d25', - focus_label: '\u4e3b\u9898', - token_usage_on: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5f00\u542f', - token_usage_off: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5173\u95ed', - theme_usage: '\u7528\u6cd5\uff1a/theme ', - theme_set: '\u4e3b\u9898\uff1a', - no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd', - - workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。', - workspace_empty_dir: '此工作区为空。', - no_personalities: '\u6ca1\u6709\u627e\u5230\u4eba\u8bbe\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09', - available_personalities: '\u53ef\u7528\u4eba\u8bbe\uff1a', - personality_switch_hint: '\n\n\u4f7f\u7528 `/personality ` \u5207\u6362\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002', - personalities_load_failed: '\u52a0\u8f7d\u4eba\u8bbe\u5931\u8d25', - personality_cleared: '\u4eba\u8bbe\u5df2\u6e05\u7a7a', - personality_set: '\u5f53\u524d\u4eba\u8bbe\uff1a', - failed_colon: '\u5931\u8d25\uff1a', - // ui.js - no_workspace: '\u672a\u9009\u62e9\u5de5\u4f5c\u533a', - dialog_confirm_title: '\u786e\u8ba4\u64cd\u4f5c', - dialog_prompt_title: '\u8f93\u5165\u5185\u5bb9', - dialog_confirm_btn: '\u786e\u8ba4', - // workspace.js - unsaved_confirm: '\u9884\u89c8\u533a\u6709\u672a\u4fdd\u5b58\u4fee\u6539\uff0c\u8981\u653e\u5f03\u66f4\u6539\u5e76\u7ee7\u7eed\u8df3\u8f6c\u5417\uff1f', - discard: '\u653e\u5f03', - save: '\u4fdd\u5b58', - edit: '\u7f16\u8f91', - clear: '\u6e05\u7a7a', - create: '\u521b\u5efa', - remove: '\u79fb\u9664', - save_title: '\u4fdd\u5b58\u4fee\u6539', - edit_title: '\u7f16\u8f91\u6b64\u6587\u4ef6', - saved: '\u5df2\u4fdd\u5b58', - save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a', - image_load_failed: '\u56fe\u7247\u52a0\u8f7d\u5931\u8d25', - file_open_failed: '\u65e0\u6cd5\u6253\u5f00\u6587\u4ef6', - downloading: (name) => `\u6b63\u5728\u4e0b\u8f7d ${name}...`, - double_click_rename: '\u53cc\u51fb\u91cd\u547d\u540d', - renamed_to: '\u5df2\u91cd\u547d\u540d\u4e3a ', - rename_failed: '\u91cd\u547d\u540d\u5931\u8d25\uff1a', - delete_title: '\u5220\u9664', - delete_confirm: (name) => `\u8981\u5220\u9664 ${name} \u5417\uff1f`, - deleted: '\u5df2\u5220\u9664 ', - delete_failed: '\u5220\u9664\u5931\u8d25\uff1a', - new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', - project_name_prompt: '\u9879\u76ee\u540d\u79f0\uff1a', - created: '\u5df2\u521b\u5efa ', - create_failed: '\u521b\u5efa\u5931\u8d25\uff1a', - new_folder_prompt: '\u65b0\u6587\u4ef6\u5939\u540d\u79f0\uff1a', - folder_created: '\u5df2\u521b\u5efa\u6587\u4ef6\u5939 ', - folder_create_failed: '\u521b\u5efa\u6587\u4ef6\u5939\u5931\u8d25\uff1a', - remove_title: '\u79fb\u9664', - empty_dir: '(\u7a7a)', - upload_failed: '\u4e0a\u4f20\u5931\u8d25\uff1a', - all_uploads_failed: (n) => `${n} \u4e2a\u6587\u4ef6\u5168\u90e8\u4e0a\u4f20\u5931\u8d25`, - // settings panel - settings_title: '\u8bbe\u7f6e', - settings_save_btn: '\u4fdd\u5b58\u8bbe\u7f6e', - settings_label_model: '\u9ed8\u8ba4\u6a21\u578b', - settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e', - settings_label_theme: '\u4e3b\u9898', - settings_label_skin: '\u76ae\u80a4', - settings_label_language: '\u8bed\u8a00', - settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', - settings_label_bubble_layout: '聊天气泡布局', - settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd', - settings_label_sync_insights: '\u540c\u6b65\u5230 insights', - settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0', - settings_label_bot_name: '\u52a9\u624b\u540d\u79f0', - settings_label_password: '\u8bbf\u95ee\u5bc6\u7801', - settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58', - settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a', - settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a', - settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5df2\u542f\u7528\u5bc6\u7801\u4fdd\u62a4\uff0c\u5f53\u524d\u6d4f\u89c8\u5668\u4f1a\u4fdd\u6301\u767b\u5f55', - settings_saved_pw_updated: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5bc6\u7801\u5df2\u66f4\u65b0', - // login page - login_title: '\u767b\u5f55', - login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528', - login_placeholder: '\u5bc6\u7801', - login_btn: '\u767b\u5f55', - login_invalid_pw: '\u5bc6\u7801\u9519\u8bef', - login_conn_failed: '\u8fde\u63a5\u5931\u8d25', - // sidebar & navigation - tab_chat: '聊天', - tab_memory: '记忆', - tab_skills: '技能', - tab_tasks: '任务', - - tab_workspaces: '工作区', - tab_profiles: '配置', - new_conversation: '新建对话', - filter_conversations: '筛选对话…', - session_time_unknown: '未知', - session_time_just_now: '刚刚', - session_time_minutes_ago: (n) => `${n} 分钟前`, - session_time_hours_ago: (n) => `${n} 小时前`, - session_time_days_ago: (n) => `${n} 天前`, - session_time_last_week: '上周', - session_time_bucket_today: '今天', - session_time_bucket_yesterday: '昨天', - session_time_bucket_this_week: '本周', - session_time_bucket_last_week: '上周', - session_time_bucket_older: '更早', - scheduled_jobs: '定时任务', - new_job: '新任务', - search_skills: '搜索技能…', - new_skill: '新技能', - save_skill: '保存技能', - personal_memory: '个人记忆', - - workspace_desc: '为你的会话添加并切换工作区。', - new_profile: '新配置', - transcript: '记录', - download_transcript: '下载为 Markdown', - import: '导入', - editing: '编辑中', - empty_title: '有什么可以帮您?', - empty_subtitle: '随时提问、运行命令、浏览文件或管理定时任务。', - cancel: '取消', - loading: '加载中…', - create_job: '创建任务', - suggest_plan: '帮我规划一个小项目。', - suggest_schedule: '今天有什么安排?', - suggest_files: '这个工作区有哪些文件?', - sign_out: '退出登录', - password_placeholder: '输入新密码…', - disable_auth: '停用认证', - settings_label_sound: '通知声音', - settings_label_notifications: '浏览器通知', - settings_desc_sound: '助手完成回复时播放提示音。', - settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。', - settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。', - settings_desc_bubble_layout: '开启后将用户消息右对齐、助手消息左对齐。默认关闭,以保持代码块和工具输出为全宽显示。', - settings_desc_cli_sessions: '将 Hermes CLI(state.db)中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。', - settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', - settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', - settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。', - settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。', - // onboarding - onboarding_badge: '首次运行', - onboarding_title: '欢迎使用 Hermes Web UI', - onboarding_lead: '快速引导将验证 Hermes、保存真实的提供商配置、选择工作区和模型,并可选设置密码保护应用。', - onboarding_back: '返回', - onboarding_continue: '继续', - onboarding_skip: '跳过设置', - onboarding_skipped: '设置已跳过 — 使用现有配置。', - onboarding_open: '打开 Hermes', - onboarding_step_system_title: '系统检查', - onboarding_step_system_desc: '验证 Hermes Agent 与配置可见性。', - onboarding_step_setup_title: '提供商设置', - onboarding_step_setup_desc: '保存最小可用的 Hermes 提供商配置。', - onboarding_step_workspace_title: '工作区 + 模型', - onboarding_step_workspace_desc: '为新会话和聊天选择默认值。', - onboarding_step_password_title: '可选密码', - onboarding_step_password_desc: '在分享前为 Web UI 添加保护。', - onboarding_step_finish_title: '完成', - onboarding_step_finish_desc: '确认信息并进入应用。', - onboarding_notice_system_ready: 'Hermes Agent 看起来可从 Web UI 访问。', - onboarding_notice_system_unavailable: 'Hermes Agent 尚未完全可用。Bootstrap 可以安装它,但提供商设置可能仍需要终端。', - onboarding_check_agent: 'Hermes Agent', - onboarding_check_agent_ready: '已检测且可导入', - onboarding_check_agent_missing: '缺失或仅部分可导入', - onboarding_check_password: '密码', - onboarding_check_password_enabled: '已启用', - onboarding_check_password_disabled: '尚未启用', - onboarding_check_provider: '提供商配置', - onboarding_check_provider_ready: '可开始聊天', - onboarding_check_provider_partial: '已保存但不完整', - onboarding_check_provider_pending: '需要验证', - onboarding_config_file: '配置文件:', - onboarding_env_file: '.env 文件:', - onboarding_unknown: '未知', - onboarding_current_provider: '当前配置:', - onboarding_missing_imports: '缺失导入:', - onboarding_notice_setup_required: '请先在此选择一个简单的提供商路径。高级 OAuth 流程暂时仍建议在 Hermes CLI 中完成。', - onboarding_notice_setup_already_ready: '已检测到可用的 Hermes 提供商配置。你可以保留它,或在这里替换。', - onboarding_oauth_provider_ready_title: '提供商已完成认证', - onboarding_oauth_provider_ready_body: '此实例已配置为使用通过 Hermes CLI 设置的 OAuth 提供商({provider})。这里不需要 API key,点击继续即可完成设置。', - onboarding_oauth_provider_not_ready_title: 'OAuth 提供商尚未认证', - onboarding_oauth_provider_not_ready_body: '此实例已配置为使用 {provider},该提供商使用 OAuth 而非 API key。请在终端运行 hermes authhermes model 完成认证,然后重新加载 Web UI。', - onboarding_oauth_switch_hint: '或者在下方选择其他提供商,切换到 API key 配置:', - onboarding_notice_workspace: '这些值复用与正式应用相同的设置 API。', - onboarding_workspace_label: '工作区', - onboarding_workspace_or_path: '或输入工作区路径', - onboarding_workspace_placeholder: '/home/you/workspace', - onboarding_provider_label: '设置模式', - onboarding_quick_setup_badge: '快速设置', - onboarding_api_key_label: 'API key', - onboarding_api_key_placeholder: '留空可保留已保存的 key', - onboarding_api_key_help_prefix: '会作为密钥保存到 Hermes .env 文件中,变量名为', - onboarding_base_url_label: 'Base URL', - onboarding_base_url_placeholder: 'https://your-endpoint.example/v1', - onboarding_base_url_help: '用于 OpenAI 兼容路由、自托管服务、LiteLLM、Ollama、LM Studio、vLLM 或类似端点。', - onboarding_model_label: '默认模型', - onboarding_workspace_help: '选择设置完成后 Hermes 在新聊天中使用的模型。', - onboarding_custom_model_placeholder: 'your-model-name', - onboarding_custom_model_help: '对于自定义端点,请填写服务端要求的精确模型 ID。', - onboarding_notice_password_enabled: '已配置密码。仅在你想替换时输入新密码。', - onboarding_notice_password_recommended: '可选,但如果你会把 UI 暴露到 localhost 之外,建议设置。', - onboarding_password_label: '密码(可选)', - onboarding_password_placeholder: '留空则跳过', - onboarding_password_help: '密码通过现有设置 API 保存,并在服务端进行哈希处理。', - onboarding_notice_finish: '你之后仍可在设置中修改这些选项。', - onboarding_not_set: '未设置', - onboarding_password_will_enable: '将启用', - onboarding_password_will_replace: '将被替换', - onboarding_password_keep_existing: '保留当前密码', - onboarding_password_remains_disabled: '将保持禁用', - onboarding_password_skipped: '暂时跳过', - onboarding_finish_help: '完成后会在设置中写入 onboarding_completed,并进入常规应用界面。', - onboarding_error_choose_workspace: '继续前请先选择工作区。', - onboarding_error_choose_model: '继续前请先选择模型。', - onboarding_error_provider_required: '继续前请先选择设置模式。', - onboarding_error_base_url_required: '自定义端点必须填写 Base URL。', - onboarding_error_workspace_required: '必须填写工作区。', - onboarding_error_model_required: '必须填写模型。', - onboarding_complete: '引导完成', - // panel/runtime i18n - error_prefix: '错误:', - not_available: '无', - never: '从未', - add: '添加', - add_failed: '添加失败:', - remove_failed: '移除失败:', - switch_failed: '切换失败:', - name_required: '名称不能为空', - content_required: '内容不能为空', - view: '查看', - dismiss: '忽略', - disable: '停用', - cron_no_jobs: '未找到定时任务。', - cron_status_off: '关闭', - cron_status_paused: '暂停', - cron_status_error: '错误', - cron_status_active: '运行中', - cron_next: '下次', - cron_last: '上次', - cron_run_now: '立即运行', - cron_pause: '暂停', - cron_resume: '恢复', - cron_job_name_placeholder: '任务名称', - cron_schedule_placeholder: '调度表达式', - cron_prompt_placeholder: '提示词', - cron_last_output: '最近输出', - cron_all_runs: '全部运行记录', - cron_hide_runs: '隐藏记录', - cron_no_runs_yet: '(暂无运行记录)', - cron_schedule_required_example: '必须填写调度(例如 "0 9 * * *" 或 "every 1h")', - cron_schedule_required: '必须填写调度', - cron_prompt_required: '必须填写提示词', - cron_job_created: '任务已创建', - cron_job_triggered: '任务已触发', - cron_job_paused: '任务已暂停', - cron_job_resumed: '任务已恢复', - cron_job_updated: '任务已更新', - cron_delete_confirm_title: '删除定时任务', - cron_delete_confirm_message: '此操作无法撤销。', - cron_job_deleted: '任务已删除', - cron_completion_status: (name, status) => `定时任务“${name}”${status}`, - status_failed: '失败', - status_completed: '完成', - - clear_conversation_title: '清空对话', - clear_conversation_message: '要清空所有消息吗?此操作无法撤销。', - clear_failed: '清空失败:', - skills_no_match: '没有匹配的技能。', - linked_files: '关联文件', - skill_load_failed: '加载技能失败:', - skill_file_load_failed: '加载文件失败:', - skill_name_required: '技能名称不能为空', - skill_updated: '技能已更新', - skill_created: '技能已创建', - memory_notes_label: '记忆(备注)', - memory_saved: '记忆已保存', - my_notes: '我的备注', - user_profile: '用户画像', - no_notes_yet: '暂无备注。', - no_profile_yet: '暂无用户画像。', - workspace_choose_path: '选择工作区路径', - workspace_choose_path_meta: '添加已校验路径并切换当前会话', - workspace_manage: '管理工作区', - workspace_manage_meta: '打开 Spaces 面板', - workspace_use_title: '用于当前会话', - workspace_use: '使用', - workspace_add_path_placeholder: '添加工作区路径(例如 /home/user/my-project)', - workspace_paths_validated_hint: '保存前会校验路径是否为已存在目录。', - workspace_added: '工作区已添加', - workspace_remove_confirm_title: '移除工作区', - workspace_remove_confirm_message: (path) => `要移除"${path}"吗?`, - workspace_removed: '工作区已移除', - workspace_switch_prompt_title: '切换工作区', - workspace_switch_prompt_message: '输入绝对路径以添加并切换当前会话的工作区。', - workspace_switch_prompt_confirm: '切换', - workspace_switch_prompt_placeholder: '/Users/you/project', - workspace_not_added: '工作区未添加成功', - workspace_already_saved: '工作区已存在,请在列表中选择', - workspace_busy_switch: 'Agent 运行中,无法切换工作区', - discard_file_edits_title: '放弃文件编辑?', - discard_file_edits_message: '切换工作区将丢弃预览区未保存的文件修改。', - workspace_switched_to: (name) => `已切换到 ${name}`, - profiles_no_profiles: '未找到配置档。', - profile_api_keys_configured: '已配置 API 密钥', - profile_gateway_running: '网关运行中', - profile_gateway_stopped: '网关已停止', - profile_active: '当前', - profile_no_configuration: '无配置', - profile_skill_count: (count) => `${count} 个技能`, - profile_use: '使用', - profile_switch_title: '切换到此配置档', - profile_delete_title: '删除此配置档', - profile_default_label: '(默认)', - profile_name_placeholder: '配置档名称(小写字母、a-z、0-9、连字符)', - profile_clone_label: '复制当前配置档的配置', - profile_base_url_placeholder: 'Base URL(可选,例如 http://localhost:11434)', - profile_api_key_placeholder: 'API 密钥(可选)', - manage_profiles: '管理配置档', - profiles_load_failed: '加载配置档失败', - profiles_busy_switch: 'Agent 运行中,无法切换配置档', - profile_switched_new_conversation: (name) => `已切换到配置档:${name},并新建对话`, - profile_switched: (name) => `已切换到配置档:${name}`, - profile_name_rule: '仅允许小写字母、数字、连字符和下划线', - profile_base_url_rule: 'Base URL 必须以 http:// 或 https:// 开头', - profile_created: (name) => `配置档已创建:${name}`, - 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: '你有未保存的更改。', - sign_out_failed: '退出登录失败:', - disable_auth_confirm_title: '停用密码保护', - disable_auth_confirm_message: '任何人都可以访问此实例。', - auth_disabled: '认证已停用,密码保护已移除', - disable_auth_failed: '停用认证失败:', - bg_error_single: (title) => `“${title}”出现错误`, - bg_error_multi: (count) => `${count} 个会话出现错误`, - }, - - // Traditional Chinese (zh-Hant) - 'zh-Hant': { - _lang: 'zh-Hant', - _label: '\u7e41\u9ad4\u4e2d\u6587', - _speech: 'zh-TW', - // boot.js - cancelling: '\u6b63\u5728\u53d6\u6d88...', - cancel_failed: '\u53d6\u6d88\u5931\u6557\uff1a', - mic_denied: '\u9ea6\u514b\u98a8\u8a2a\u554f\u88ab\u62d2\u7d75\uff0c\u8acb\u6aa2\u67e5\u700f\u89bd\u5668\u6b0a\u9650\u3002', - mic_no_speech: '\u6c92\u6709\u6aa2\u6e2c\u5230\u8a71\u97f3\uff0c\u8acb\u518d\u5617\u4e00\u6b21\u3002', - mic_network: '\u8a71\u97f3\u8b58\u5225\u76ee\u524d\u4e0d\u53ef\u7528\u3002', - mic_error: '\u8a71\u97f3\u8f38\u5165\u51fa\u932f\uff1a', - session_imported: '\u6703\u8a71\u5df2\u5c0e\u5165', - import_failed: '\u5c0e\u5165\u5931\u6557\uff1a', - import_invalid_json: 'JSON \u7121\u6548', - image_pasted: '\u5df2\u7c98\u8cbc\u5716\u7247\uff1a', - // messages.js - edit_message: '\u7de8\u8f2f\u8a0a\u606f', - regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986', - copy: '\u8907\u88fd', - copied: '\u5df2\u8907\u88fd', - you: '\u4f60', - thinking: '\u601d\u8003\u904e\u7a0b', - expand_all: '\u5168\u90e8\u5c55\u958b', - collapse_all: '\u5168\u90e8\u6298\u758a', - edit_failed: '\u7de8\u8f2f\u5931\u6557\uff1a', - regen_failed: '\u91cd\u65b0\u751f\u6210\u5931\u6557\uff1a', - reconnect_active: '\u56de\u8986\u4ecd\u5728\u751f\u6210\u4e2d\uff0c\u6e96\u5099\u597d\u5f8c\u8981\u91cd\u65b0\u52a0\u8f09\u55ce\uff1f', - reconnect_finished: '\u4f60\u96e2\u958b\u6642\u6709\u56de\u8986\u6b63\u5728\u751f\u6210\uff0c\u8a0a\u606f\u5167\u5bb9\u53ef\u80fd\u5df2\u7d93\u66f4\u65b0\u3002', - // approval card - approval_heading: '\u9700\u8981\u5ba1\u6838', - approval_desc_prefix: '\u6aa2\u6e2c\u5230\u5371\u96aa\u547d\u4ee4', - approval_btn_once: '\u5141\u8a31\u4e00\u6b21', - approval_btn_once_title: '\u5141\u8a31\u57f7\u884c\u6b64\u547d\u4ee4\u4e00\u6b21\uff08Enter\uff09', - approval_btn_session: '\u672c\u6b21\u5141\u8a31', - approval_btn_session_title: '\u672c\u6b21\u6703\u8a71\u671f\u9593\u5141\u8a31', - approval_btn_always: '始終允許', - approval_btn_always_title: '始終允許此命令模式', - approval_btn_deny: '\u62d2\u7edd', - approval_btn_deny_title: '\u62d2\u7edd — \u4e0d\u57f7\u884c\u6b64\u547d\u4ee4', - approval_responding: '\u8655\u7406\u4e2d\u2026', - clarify_heading: '\u9700\u8981\u91cb\u6e05', - clarify_hint: '\u8acb\u9078\u64c7\u4e00\u500b\u9078\u9805\uff0c\u6216\u5728\u4e0b\u65b9\u8f38\u5165\u4f60\u81ea\u5df1\u7684\u56de\u7b54\u3002', - clarify_other: '\u5176\u4ed6', - clarify_send: '\u9001\u51fa', - clarify_input_placeholder: '\u8f38\u5165\u4f60\u7684\u56de\u7b54\u2026', - clarify_responding: '\u8655\u7406\u4e2d\u2026', - untitled: '\u672a\u547d\u540d', - n_messages: (n) => `${n} \u689d\u8a0a\u606f`, - model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09', - model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d', - provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`, - provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26', - // commands.js - cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4', - cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f', - cmd_compress: '\u624b\u52d5\u58d3\u7e2e\u5c0d\u8a71\u4e0a\u4e0b\u6587\uff08\u7528\u6cd5\uff1a/compress [\u4e3b\u984c]\uff09', - cmd_compact_alias: '\u820a\u5225\u540d\uff1a/compress', - cmd_model: '\u5207\u63db\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09', - cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340', - cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71', - cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a', - cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard\uff09', - cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d', - cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd', - available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a', - type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4', - conversation_cleared: '\u5c0d\u8a71\u5df2\u6e05\u7a7a', - command_label: '\u547d\u4ee4', - context_compaction_label: '\u4e0a\u4e0b\u6587\u58d3\u7e2e', - reference_only_label: '\u50c5\u4f9b\u53c3\u8003', - model_usage: '\u7528\u6cd5\uff1a/model ', - no_model_match: '\u6c92\u6709\u5339\u914d\u201c', - switched_to: '\u5df2\u5207\u63db\u5230 ', - workspace_usage: '\u7528\u6cd5\uff1a/workspace ', - no_workspace_match: '\u6c92\u6709\u5339\u914d\u201c', - switched_workspace: '\u5df2\u5207\u63db\u5de5\u4f5c\u5340\uff1a', - workspace_switch_failed: '\u5de5\u4f5c\u5340\u5207\u63db\u5931\u6557\uff1a', - new_session: '\u5df2\u65b0\u5efa\u6703\u8a71', - compressing: '\u6b63\u5728\u8981\u6c42\u58d3\u7e2e\u4e0a\u4e0b\u6587...', - compress_running_label: '\u58d3\u7e2e\u4e2d', - compress_complete_label: '\u58d3\u7e2e\u5b8c\u6210', - compress_failed_label: '\u58d3\u7e2e\u5931\u6557', - focus_label: '\u4e3b\u984c', - token_usage_on: 'Token \u7528\u91cf\u986f\u793a\u5df2\u958b\u555f', - token_usage_off: 'Token \u7528\u91cf\u986f\u793a\u5df2\u95dc\u9589', - theme_usage: '\u7528\u6cd5\uff1a/theme ', - theme_set: '\u4e3b\u984c\uff1a', - no_active_session: '\u7576\u524d\u6c92\u6709\u6d3b\u52d5\u6703\u8a71', - - workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。', - workspace_empty_dir: '此工作區為空。', - no_personalities: '\u6c92\u6709\u627e\u5230\u4eba\u8a2d\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09', - available_personalities: '\u53ef\u7528\u4eba\u8a2d\uff1a', - personality_switch_hint: '\n\n\u4f7f\u7528 `/personality ` \u5207\u63db\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002', - personalities_load_failed: '\u52a0\u8f7d\u4eba\u8a2d\u5931\u6557', - personality_cleared: '\u4eba\u8a2d\u5df2\u6e05\u7a7a', - personality_set: '\u7576\u524d\u4eba\u8a2d\uff1a', - failed_colon: '\u5931\u6557\uff1a', - // ui.js - no_workspace: '\u672a\u9078\u64c7\u5de5\u4f5c\u5340', - // workspace.js - unsaved_confirm: '\u9810\u89bd\u5340\u6709\u672a\u5132\u5b58\u4fee\u6539\uff0c\u8981\u653e\u68c4\u66f4\u6539\u5e76\u7e7c\u7e8c\u8df3\u8ee2\u55ce\uff1f', - save: '\u5132\u5b58', - edit: '\u7de8\u8f2f', - save_title: '\u5132\u5b58\u4fee\u6539', - edit_title: '\u7de8\u8f2f\u6b64\u6587\u4ef6', - saved: '\u5df2\u5132\u5b58', - save_failed: '\u5132\u5b58\u5931\u6557\uff1a', - image_load_failed: '\u5716\u7247\u52a0\u8f09\u5931\u6557', - file_open_failed: '\u7121\u6cd5\u6253\u958b\u6587\u4ef6', - downloading: (name) => `\u6b63\u5728\u4e0b\u8f09 ${name}...`, - double_click_rename: '\u96d9\u64ca\u91cd\u547d\u540d', - renamed_to: '\u5df2\u91cd\u547d\u540d\u70ba ', - rename_failed: '\u91cd\u547d\u540d\u5931\u6557\uff1a', - delete_title: '\u522a\u9664', - delete_confirm: (name) => `\u8981\u522a\u9664 ${name} \u55ce\uff1f`, - deleted: '\u5df2\u522a\u9664 ', - delete_failed: '\u522a\u9664\u5931\u6557\uff1a', - new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', - created: '\u5df2\u5275\u5efa ', - create_failed: '\u5275\u5efa\u5931\u6557\uff1a', - new_folder_prompt: '\u65b0\u6587\u4ef6\u593e\u540d\u7a31\uff1a', - folder_created: '\u5df2\u5275\u5efa\u6587\u4ef6\u593e ', - folder_create_failed: '\u5275\u5efa\u6587\u4ef6\u593e\u5931\u6557\uff1a', - remove_title: '\u79fb\u9664', - empty_dir: '(\u7a7a)', - upload_failed: '\u4e0a\u50b3\u5931\u6557\uff1a', - all_uploads_failed: (n) => `${n} \u500b\u6587\u4ef6\u5168\u90e8\u4e0a\u50b3\u5931\u6557`, - // settings panel - settings_title: '\u8a2d\u5b9a', - settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a', - settings_label_model: '\u9ed8\u8a8d\u6a21\u578b', - settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375', - settings_label_theme: '\u4e3b\u984c', - settings_label_skin: '\u76ae\u819a', - settings_label_language: '\u8a9d\u8a00', - settings_label_token_usage: '\u986f\u793a token \u7528\u91cf', - settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71', - settings_label_sync_insights: '\u540c\u6b65\u5230 insights', - settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', - settings_label_bot_name: '\u52a9\u624b\u540d\u7a31', - settings_label_password: '\u8a2a\u8aad\u5bc6\u78bc', - settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58', - settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a', - settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a', - settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u4fdd\u8b77\u5df2\u555f\u7528\uff0c\u7576\u524d\u700f\u89bd\u5668\u6703\u4fdd\u6301\u767b\u5165', - settings_saved_pw_updated: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u5df2\u66f4\u65b0', - // login page - login_title: '\u767b\u5f55', - login_subtitle: '\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528', - login_placeholder: '\u5bc6\u78bc', - login_btn: '\u767b\u5f55', - login_invalid_pw: '\u5bc6\u78bc\u932f\u8aa4', - login_conn_failed: '\u9023\u63a5\u5931\u6557', - // missing keys from English - dialog_confirm_title: '確認操作', - dialog_prompt_title: '輸入內容', - dialog_confirm_btn: '確認', - discard: '放棄', - clear: '清空', - create: '建立', - remove: '移除', - project_name_prompt: '專案名稱:', - tab_chat: '\u804a\u5929', - tab_memory: '\u8a18\u61b6', - tab_skills: '\u6280\u80fd', - tab_tasks: '\u4efb\u52d9', - tab_todos: '\u5f85\u8e29', - tab_workspaces: '\u5de5\u4f5c\u5340', - new_conversation: '\u65b0\u5b58\u5c0d\u8a71', - filter_conversations: '\u7b5c\u9078\u5b58\u5c0d\u8a71', - scheduled_jobs: '\u5b58\u5287\u4efb\u52d9', - new_job: '\u65b0\u4efb\u52d9', - search_skills: '\u641c\u5c0b\u6280\u80fd', - new_skill: '\u65b0\u6280\u80fd', - save_skill: '\u5132\u5b58\u6280\u80fd', - personal_memory: '\u500b\u4eba\u8a18\u61b6', - new_profile: '\u65b0\u914d\u7f6e\u6a94', - transcript: '\u8a18\u9304', - download_transcript: '\u4e0b\u8f09\u8a18\u9304', - import: '\u5c0e\u5165', - editing: '\u7de8\u8f2f\u4e2d', - empty_title: '\u7a7a\u767c\u5b58\u7a7a\u9593', - empty_subtitle: '\u9ede\u64ca\u4e0a\u65b9\u6309\u9215\u958b\u59cb\u5c0d\u8a71', - cancel: '\u53d6\u6d88', - loading: '\u52a0\u8f09\u4e2d', - create_job: '\u5efa\u7acb\u4efb\u52d9', - suggest_plan: '\u5efa\u8b70\u8a08\u5287', - suggest_schedule: '\u5efa\u8b70\u6642\u7a0b', - suggest_files: '\u5efa\u8b70\u6a94\u6848', - sign_out: '\u767b\u51fa', - password_placeholder: '\u5bc6\u78bc', - disable_auth: '\u505c\u7528\u9a57\u8b49', - settings_label_sound: '\u901a\u77e5\u8072\u97f3', - settings_label_notifications: '\u700f\u89bd\u901a\u77e5', - settings_desc_sound: '\u52a9\u624b\u5b8c\u6210\u56de\u7b54\u6642\u64a9\u653e\u8072\u97f3\u3002', - settings_desc_notifications: '\u7576\u5206\u9801\u5728\u5f8c\u81ea\u6642\uff0c\u6709\u56de\u7b54\u5b8c\u6210\u6e05\u55ae\u6703\u986f\u793a\u7cfb\u7d71\u901a\u77e5\u3002', - settings_desc_token_usage: '\u5728\u52a9\u624b\u6bcf\u6b21\u56de\u7b54\u4e0b\u65b9\u986f\u793a Input/Output token \u6578\u91cf\u3002\u4e5f\u53ef\u4ee5\u7528 /usage \u5207\u63db\u3002', - settings_desc_cli_sessions: '\u5c07 Hermes CLI (\u7684 state.db) \u4e2d\u7684\u6703\u8a71\u6dfb\u52a0\u5230\u6703\u8a71\u6e05\u55ae\u3002\u9ede\u64ca\u4e00\u500b CLI \u6703\u8a71\u5c07\u5c0e\u5165\u5b83\u7a0b\u5f0f\u4e26\u7e7c\u7e8c\u5b58\u5c0d\u8a71\u3002', - settings_desc_sync_insights: '\u5c07 WebUI token \u4f7f\u7528\u60c5\u6cc1\u540c\u6b65\u5230 state.db\uff0c\u8a93 hermes /insights \u5305\u542b\u700f\u89bd\u5668\u6703\u8a71\u6578\u64da\u3002\u9810\u8a2d\u70b8\u555f\u7528\u3002', - settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002', - settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002', - settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', - onboarding_password_will_enable: '\u5c07\u6703\u555f\u7528', - onboarding_password_will_replace: '\u5c07\u6703\u53d6\u4ee3', - onboarding_password_keep_existing: '\u4fdd\u7559\u76ee\u524d\u5bc6\u78bc', - onboarding_password_remains_disabled: '\u6703\u7e7c\u7e8c\u4fdd\u6301\u95dc\u9589', - settings_label_sound: '\u901a\u77e5\u8072\u97f3', - // boot.js - cancelling: '\u6b63\u5728\u53d6\u6d88...', - cancel_failed: '\u53d6\u6d88\u5931\u6557\uff1a', - mic_denied: '\u9ea6\u514b\u98a8\u8a2a\u554f\u88ab\u62d2\u7d75\uff0c\u8acb\u6aa2\u67e5\u700f\u89bd\u5668\u6b0a\u9650\u3002', - mic_no_speech: '\u6c92\u6709\u6aa2\u6e2c\u5230\u8a71\u97f3\uff0c\u8acb\u518d\u5617\u4e00\u6b21\u3002', - mic_network: '\u8a71\u97f3\u8b58\u5225\u76ee\u524d\u4e0d\u53ef\u7528\u3002', - mic_error: '\u8a71\u97f3\u8f38\u5165\u51fa\u932f\uff1a', - session_imported: '\u6703\u8a71\u5df2\u5c0e\u5165', - import_failed: '\u5c0e\u5165\u5931\u6557\uff1a', - import_invalid_json: 'JSON \u7121\u6548', - image_pasted: '\u5df2\u7c98\u8cbc\u5716\u7247\uff1a', - // messages.js - edit_message: '\u7de8\u8f2f\u8a0a\u606f', - regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986', - copy: '\u8907\u88fd', - copied: '\u5df2\u8907\u88fd', - // ui.js - workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b', - tab_profiles: '\u914d\u7f6e', - }, -}; - -// Active locale — defaults to English; overridden by loadLocale() at boot. -let _locale = LOCALES.en; - -/** - * Resolve an incoming locale tag to a known LOCALES key. - * Supports exact keys, case-insensitive matches, and a few common aliases - * (e.g. zh-CN -> zh, zh-TW -> zh-Hant). Returns null when unresolved. - * @param {string} lang - * @returns {string|null} - */ -function resolveLocale(lang) { - if (typeof lang !== 'string') return null; - const raw = lang.trim(); - if (!raw) return null; - if (LOCALES[raw]) return raw; - - const lower = raw.toLowerCase().replace(/_/g, '-'); - - // Case-insensitive direct match first. - const direct = Object.keys(LOCALES).find((k) => k.toLowerCase() === lower); - if (direct) return direct; - - // Common Chinese variants. - if (lower === 'zh' || lower.startsWith('zh-cn') || lower.startsWith('zh-sg') || lower.startsWith('zh-hans')) { - return LOCALES.zh ? 'zh' : null; +(() => { + const LOCALES = { + en: { + _lang: "en", + _label: "English", + _speech: "en-US", + // boot.js + cancelling: "Cancelling\u2026", + cancel_failed: "Cancel failed: ", + mic_denied: "Microphone access denied. Check browser permissions.", + mic_no_speech: "No speech detected. Try again.", + mic_network: "Speech recognition unavailable.", + mic_error: "Voice input error: ", + session_imported: "Session imported", + import_failed: "Import failed: ", + import_invalid_json: "Invalid JSON", + image_pasted: "Image pasted: ", + // messages.js + edit_message: "Edit message", + regenerate: "Regenerate response", + copy: "Copy", + copied: "Copied!", + you: "You", + thinking: "Thinking", + expand_all: "Expand all", + collapse_all: "Collapse all", + edit_failed: "Edit failed: ", + regen_failed: "Regenerate failed: ", + reconnect_active: "A response is still being generated. Reload when ready?", + reconnect_finished: "A response was in progress when you last left. Messages may have updated.", + // approval card + approval_heading: "Approval required", + approval_desc_prefix: "Dangerous command detected", + approval_btn_once: "Allow once", + approval_btn_once_title: "Allow this one command (Enter)", + approval_btn_session: "Allow session", + approval_btn_session_title: "Allow for this conversation session", + approval_btn_always: "Always allow", + approval_btn_always_title: "Always allow this command pattern", + approval_btn_deny: "Deny", + approval_btn_deny_title: "Deny \u2014 do not run this command", + approval_responding: "Responding\u2026", + clarify_heading: "Clarification needed", + clarify_hint: "Pick a choice, or type your own answer below.", + clarify_other: "Other", + clarify_send: "Send", + clarify_input_placeholder: "Type your response\u2026", + clarify_responding: "Responding\u2026", + untitled: "Untitled", + n_messages: (n) => `${n} messages`, + model_unavailable: " (unavailable)", + model_unavailable_title: "This model is no longer in your current provider list", + provider_mismatch_warning: (m, p) => `"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`, + provider_mismatch_label: "Provider mismatch", + model_custom_label: "Custom model ID", + model_custom_placeholder: "e.g. openai/gpt-5.4", + model_search_placeholder: "Search models\u2026", + model_search_no_results: "No models found", + // commands.js + cmd_clear: "Clear conversation messages", + cmd_compress: "Manually compress conversation context (usage: /compress [focus topic])", + cmd_compact_alias: "Legacy alias for /compress", + cmd_model: "Switch model (e.g. /model gpt-4o)", + cmd_workspace: "Switch workspace by name", + cmd_new: "Start a new chat session", + cmd_usage: "Toggle token usage display on/off", + cmd_theme: "Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)", + cmd_personality: "Switch agent personality", + cmd_skills: "List available Hermes skills", + available_commands: "Available commands:", + type_slash: "Type / to see commands", + conversation_cleared: "Conversation cleared", + command_label: "Command", + context_compaction_label: "Context compaction", + reference_only_label: "Reference only", + model_usage: "Usage: /model ", + no_model_match: 'No model matching "', + switched_to: "Switched to ", + workspace_usage: "Usage: /workspace ", + no_workspace_match: 'No workspace matching "', + switched_workspace: "Switched to workspace: ", + workspace_switch_failed: "Workspace switch failed: ", + new_session: "New session created", + compressing: "Requesting context compression...", + compress_running_label: "Compressing", + compress_complete_label: "Compression complete", + compress_failed_label: "Compression failed", + focus_label: "Focus", + token_usage_on: "Token usage on", + token_usage_off: "Token usage off", + theme_usage: "Usage: /theme ", + theme_set: "Theme: ", + no_active_session: "No active session", + slash_skill_badge: "Skill", + slash_skill_desc: "Invoke this skill", + cmd_stop: "Stop the current response", + cmd_title: "Get or set the session title", + cmd_retry: "Resend the last message", + cmd_undo: "Remove the last exchange", + cmd_status: "Show session info", + cmd_voice: "Toggle microphone input", + stream_stopped: "Response stopped.", + no_active_task: "No active task to stop.", + cancel_unavailable: "Cancel not available.", + retry_failed: "Retry failed: ", + undo_failed: "Undo failed: ", + undid_n_messages: "Removed", + undid_messages_suffix: "message(s).", + status_heading: "Session Status", + status_session_id: "Session ID", + status_title: "Title", + status_model: "Model", + status_workspace: "Workspace", + status_personality: "Personality", + status_messages: "Messages", + status_agent_running: "Agent running", + status_yes: "Yes", + status_no: "No", + status_load_failed: "Failed to load status: ", + title_current: "Current title", + title_change_hint: "Use `/title ` to rename.", + title_set: "Title set to", + cmd_webui_only_session: "This command is not available for CLI-imported sessions.", + cmd_voice_use_mic: "Click the mic button in the composer.", + usage_heading: "Token Usage", + usage_default_model: "default", + usage_unknown: "unknown", + usage_input_tokens: "Input tokens", + usage_output_tokens: "Output tokens", + usage_total: "Total tokens", + usage_estimated_cost: "Estimated cost", + usage_settings_tip: "Note: cost estimates are approximate.", + usage_load_failed: "Failed to load usage: ", + usage_personality_none: "none", + no_personalities: "No personalities found (add them to ~/.hermes/personalities/)", + available_personalities: "Available personalities:", + personality_switch_hint: "\n\nUse `/personality ` to switch, or `/personality none` to clear.", + personalities_load_failed: "Failed to load personalities", + personality_cleared: "Personality cleared", + personality_set: "Personality: ", + failed_colon: "Failed: ", + // ui.js + no_workspace: "No workspace", + workspace_empty_no_path: "No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.", + workspace_empty_dir: "This workspace is empty.", + dialog_confirm_title: "Confirm action", + dialog_prompt_title: "Enter a value", + dialog_confirm_btn: "Confirm", + // workspace.js + unsaved_confirm: "You have unsaved changes in the preview. Discard and navigate?", + discard: "Discard", + save: "Save", + edit: "Edit", + clear: "Clear", + create: "Create", + remove: "Remove", + save_title: "Save changes", + edit_title: "Edit this file", + saved: "Saved", + save_failed: "Save failed: ", + image_load_failed: "Could not load image", + file_open_failed: "Could not open file", + downloading: (name) => `Downloading ${name}\u2026`, + double_click_rename: "Double-click to rename", + renamed_to: "Renamed to ", + rename_failed: "Rename failed: ", + delete_title: "Delete", + delete_confirm: (name) => `Delete ${name}?`, + deleted: "Deleted ", + delete_failed: "Delete failed: ", + new_file_prompt: "New file name (e.g. notes.md):", + project_name_prompt: "Project name:", + created: "Created ", + create_failed: "Create failed: ", + new_folder_prompt: "New folder name:", + folder_created: "Created folder ", + folder_create_failed: "Create folder failed: ", + remove_title: "Remove", + empty_dir: "(empty)", + upload_failed: "Upload failed: ", + all_uploads_failed: (n) => `All ${n} upload(s) failed`, + // settings panel + settings_title: "Settings", + settings_save_btn: "Save Settings", + settings_label_model: "Default Model", + settings_label_send_key: "Send Key", + settings_label_theme: "Theme", + settings_label_skin: "Skin", + settings_label_language: "Language", + settings_label_token_usage: "Show token usage", + settings_label_bubble_layout: "Chat bubble layout", + settings_label_cli_sessions: "Show agent sessions", + settings_label_sync_insights: "Sync to insights", + settings_label_check_updates: "Check for updates", + settings_label_bot_name: "Assistant Name", + settings_label_password: "Access Password", + settings_saved: "Settings saved", + settings_save_failed: "Save failed: ", + settings_load_failed: "Failed to load settings: ", + settings_saved_pw: "Settings saved \u2014 password protection enabled and this browser stays signed in", + settings_saved_pw_updated: "Settings saved \u2014 password updated", + // login page + login_title: "Sign in", + login_subtitle: "Enter your password to continue", + login_placeholder: "Password", + login_btn: "Sign in", + login_invalid_pw: "Invalid password", + login_conn_failed: "Connection failed", + // Sidebar & Tabs + tab_chat: "Chat", + tab_tasks: "Tasks", + tab_skills: "Skills", + tab_memory: "Memory", + tab_workspaces: "Spaces", + tab_profiles: "Profiles", + new_conversation: "New conversation", + filter_conversations: "Filter conversations...", + session_time_unknown: "Unknown", + session_time_just_now: "gerade eben", + session_time_minutes_ago: (n) => `vor ${n} Min.`, + session_time_hours_ago: (n) => `vor ${n} Std.`, + session_time_days_ago: (n) => `vor ${n} Tag${n === 1 ? "" : "en"}`, + session_time_last_week: "letzte Woche", + session_time_bucket_today: "Heute", + session_time_bucket_yesterday: "Gestern", + session_time_bucket_this_week: "Diese Woche", + session_time_bucket_last_week: "Letzte Woche", + session_time_bucket_older: "\xC4lter", + scheduled_jobs: "Geplante Aufgaben", + new_job: "+ Neue Aufgabe", + loading: "Wird geladen...", + search_skills: "Search skills...", + new_skill: "New skill", + personal_memory: "Personal memory", + workspace_desc: "Add and switch workspaces for your sessions.", + new_profile: "New profile", + transcript: "Transcript", + download_transcript: "Download as Markdown", + import: "Import", + // Settings detail + settings_label_sound: "Notification sound", + settings_desc_sound: "Play a sound when the assistant finishes a response.", + settings_label_notifications: "Browser notifications", + settings_desc_notifications: "Show a system notification when a response completes while the app is in the background.", + settings_desc_token_usage: "Displays input/output token count below each assistant reply. Also toggled with /usage.", + settings_desc_bubble_layout: "Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.", + settings_desc_cli_sessions: "Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.", + settings_desc_sync_insights: "Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.", + settings_desc_check_updates: "Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.", + settings_desc_bot_name: "Display name for the assistant throughout the UI. Defaults to Hermes.", + settings_desc_password: "Enter a new password to set or change it. Leave blank to keep current setting.", + password_placeholder: "Enter new password\u2026", + disable_auth: "Disable Auth", + sign_out: "Sign Out", + cancel: "Cancel", + create_job: "Create job", + save_skill: "Save skill", + editing: "Editing", + // Empty state + empty_title: "What can I help with?", + empty_subtitle: "Ask anything, run commands, explore files, or manage your scheduled tasks.", + suggest_files: "What files are in this workspace?", + suggest_schedule: "What's on my schedule today?", + suggest_plan: "Help me plan a small project.", + // onboarding + onboarding_badge: "FIRST RUN", + onboarding_title: "Welcome to Hermes Web UI", + onboarding_lead: "A quick guided setup will verify Hermes, save a real provider configuration, choose a workspace and model, and optionally protect the app with a password.", + onboarding_back: "Back", + onboarding_continue: "Continue", + onboarding_skip: "Skip setup", + onboarding_skipped: "Setup skipped \u2014 using existing config.", + onboarding_open: "Open Hermes", + onboarding_step_system_title: "System check", + onboarding_step_system_desc: "Verify Hermes Agent and config visibility.", + onboarding_step_setup_title: "Provider setup", + onboarding_step_setup_desc: "Save the minimum Hermes provider config.", + onboarding_step_workspace_title: "Workspace + model", + onboarding_step_workspace_desc: "Pick defaults for new sessions and chat.", + onboarding_step_password_title: "Optional password", + onboarding_step_password_desc: "Protect the Web UI before sharing it.", + onboarding_step_finish_title: "Finish", + onboarding_step_finish_desc: "Review and enter the app.", + onboarding_notice_system_ready: "Hermes Agent looks reachable from the Web UI.", + onboarding_notice_system_unavailable: "Hermes Agent is not fully available yet. Bootstrap can install it, but provider setup may still require a terminal.", + onboarding_check_agent: "Hermes Agent", + onboarding_check_agent_ready: "Detected and importable", + onboarding_check_agent_missing: "Missing or partially importable", + onboarding_check_password: "Password", + onboarding_check_password_enabled: "Already enabled", + onboarding_check_password_disabled: "Not enabled yet", + onboarding_check_provider: "Provider config", + onboarding_check_provider_ready: "Ready to chat", + onboarding_check_provider_partial: "Saved but incomplete", + onboarding_check_provider_pending: "Needs verification", + onboarding_config_file: "Config file:", + onboarding_env_file: ".env file:", + onboarding_unknown: "Unknown", + onboarding_current_provider: "Current setup:", + onboarding_missing_imports: "Missing imports:", + onboarding_notice_setup_required: "Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.", + onboarding_notice_setup_already_ready: "A working Hermes provider setup is already detected. You can keep it or replace it here.", + onboarding_oauth_provider_ready_title: "Provider already authenticated", + onboarding_oauth_provider_ready_body: "This instance is configured to use an OAuth provider ({provider}) that was set up via the Hermes CLI. No API key is needed here \u2014 click Continue to finish setup.", + onboarding_oauth_provider_not_ready_title: "OAuth provider not yet authenticated", + onboarding_oauth_provider_not_ready_body: "This instance is configured to use {provider}, which uses OAuth rather than an API key. Run hermes auth or hermes model in a terminal to authenticate, then reload the Web UI.", + onboarding_oauth_switch_hint: "Or choose a different provider below to switch to an API-key setup:", + onboarding_notice_workspace: "These values reuse the same settings APIs as the normal app.", + onboarding_workspace_label: "Workspace", + onboarding_workspace_or_path: "Or enter a workspace path", + onboarding_workspace_placeholder: "/home/you/workspace", + onboarding_provider_label: "Setup mode", + onboarding_quick_setup_badge: "quick setup", + onboarding_api_key_label: "API key", + onboarding_api_key_placeholder: "Leave blank to keep an existing saved key", + onboarding_api_key_help_prefix: "Saved as a secret in your Hermes .env file using", + onboarding_base_url_label: "Base URL", + onboarding_base_url_placeholder: "https://your-endpoint.example/v1", + onboarding_base_url_help: "Use this for OpenAI-compatible routers, self-hosted servers, LiteLLM, Ollama, LM Studio, vLLM, or similar endpoints.", + onboarding_model_label: "Default model", + onboarding_workspace_help: "Pick the model Hermes should use for new chats after setup completes.", + onboarding_custom_model_placeholder: "your-model-name", + onboarding_custom_model_help: "For custom endpoints, enter the exact model ID your server expects.", + onboarding_notice_password_enabled: "A password is already configured. Enter a new one only if you want to replace it.", + onboarding_notice_password_recommended: "Optional but recommended if you will expose the UI beyond localhost.", + onboarding_password_label: "Password (optional)", + onboarding_password_placeholder: "Leave blank to skip", + onboarding_password_help: "Passwords are stored through the existing settings API and hashed server-side.", + onboarding_notice_finish: "You can reopen Settings later to change any of this.", + onboarding_not_set: "Not set", + onboarding_password_will_enable: "Will be enabled", + onboarding_password_will_replace: "Will be replaced", + onboarding_password_keep_existing: "Keep current password", + onboarding_password_remains_disabled: "Will remain disabled", + onboarding_password_skipped: "Skipped for now", + onboarding_finish_help: "Finishing stores onboarding_completed in settings and drops you into the normal app.", + onboarding_error_choose_workspace: "Choose a workspace before continuing.", + onboarding_error_choose_model: "Choose a model before continuing.", + onboarding_error_provider_required: "Choose a setup mode before continuing.", + onboarding_error_base_url_required: "Base URL is required for custom endpoints.", + onboarding_error_workspace_required: "Workspace is required.", + onboarding_error_model_required: "Model is required.", + onboarding_complete: "Onboarding complete", + // panel/runtime i18n + error_prefix: "Error: ", + not_available: "N/A", + never: "never", + add: "Add", + add_failed: "Add failed: ", + remove_failed: "Remove failed: ", + switch_failed: "Switch failed: ", + name_required: "Name is required", + content_required: "Content is required", + view: "View", + dismiss: "Dismiss", + disable: "Disable", + cron_no_jobs: "Keine geplanten Aufgaben gefunden.", + cron_status_off: "AUS", + cron_status_paused: "PAUSIERT", + cron_status_error: "FEHLER", + cron_status_active: "AKTIV", + cron_next: "N\xE4chste", + cron_last: "Letzte", + cron_run_now: "Jetzt starten", + cron_pause: "Pausieren", + cron_resume: "Fortsetzen", + cron_job_name_placeholder: "Aufgabenname", + cron_schedule_placeholder: "Zeitplan", + cron_prompt_placeholder: "Prompt", + cron_last_output: "Letzte Ausgabe", + cron_all_runs: "Alle Ausf\xFChrungen", + cron_hide_runs: "Ausf\xFChrungen verbergen", + cron_no_runs_yet: "(noch keine Ausf\xFChrung)", + cron_schedule_required_example: 'Zeitplan erforderlich (z.B. "0 9 * * *" oder "every 1h")', + cron_schedule_required: "Zeitplan erforderlich", + cron_prompt_required: "Prompt erforderlich", + cron_loading: "Laden...", + cron_add_skills_placeholder: "Skills hinzuf\xFCgen (optional)...", + cron_deliver_local: "Lokal (nur speichern)", + cron_deliver_discord: "Discord", + cron_deliver_telegram: "Telegram", + cron_job_created: "Aufgabe erstellt", + cron_job_triggered: "Aufgabe gestartet", + cron_job_paused: "Aufgabe pausiert", + cron_job_resumed: "Aufgabe fortgesetzt", + cron_job_updated: "Aufgabe aktualisiert", + cron_delete_confirm_title: "Aufgabe l\xF6schen", + cron_delete_confirm_message: "Dies kann nicht r\xFCckg\xE4ngig gemacht werden.", + cron_job_deleted: "Aufgabe gel\xF6scht", + cron_completion_status: (name, status) => `Aufgabe "${name}" ${status}`, + status_failed: "fehlgeschlagen", + status_completed: "abgeschlossen", + clear_conversation_title: "Chat leeren", + clear_conversation_message: "Alle Nachrichten l\xF6schen? Dies kann nicht r\xFCckg\xE4ngig gemacht werden.", + clear_failed: "Leeren fehlgeschlagen: ", + cron_running: "L\xE4uft...", + // inline cron labels (used directly in HTML templates) + cron_label_schedule: "Zeitplan", + cron_label_next_run: "N\xE4chste Ausf\xFChrung", + cron_label_last_ran: "Zuletzt gelaufen", + cron_label_prompt: "Prompt", + cron_label_edit: "Bearbeiten", + cron_label_delete: "L\xF6schen", + cron_label_never: "nie", + skills_no_match: "Keine Skills gefunden.", + linked_files: "Linked Files", + skill_load_failed: "Could not load skill: ", + skill_file_load_failed: "Could not load file: ", + skill_name_required: "Skill name is required", + skill_updated: "Skill updated", + skill_created: "Skill created", + memory_notes_label: "memory (notes)", + memory_saved: "Memory saved", + my_notes: "My Notes", + user_profile: "User Profile", + no_notes_yet: "No notes yet.", + no_profile_yet: "No profile yet.", + workspace_choose_path: "Choose workspace path", + workspace_choose_path_meta: "Add a validated path and switch this conversation", + workspace_manage: "Manage workspaces", + workspace_manage_meta: "Open the Spaces panel", + workspace_use_title: "Use in current session", + workspace_use: "Use", + workspace_add_path_placeholder: "Add workspace path (e.g. /home/user/my-project)", + workspace_paths_validated_hint: "Paths are validated as existing directories before saving.", + workspace_added: "Workspace added", + workspace_remove_confirm_title: "Remove workspace", + workspace_remove_confirm_message: (path) => `Remove "${path}"?`, + workspace_removed: "Workspace removed", + workspace_switch_prompt_title: "Switch workspace", + workspace_switch_prompt_message: "Enter an absolute workspace path to add and switch this conversation to.", + workspace_switch_prompt_confirm: "Switch", + workspace_switch_prompt_placeholder: "/Users/you/project", + workspace_not_added: "Workspace was not added", + workspace_already_saved: "Workspace already saved \u2014 choose it from the list", + workspace_busy_switch: "Cannot switch workspace while agent is running", + discard_file_edits_title: "Discard file edits?", + discard_file_edits_message: "Switching workspaces will discard unsaved file edits in the preview.", + workspace_switched_to: (name) => `Switched to ${name}`, + profiles_no_profiles: "No profiles found.", + profile_api_keys_configured: "API keys configured", + profile_gateway_running: "Gateway running", + profile_gateway_stopped: "Gateway stopped", + profile_active: "ACTIVE", + profile_no_configuration: "No configuration", + profile_skill_count: (count) => `${count} skill${count === 1 ? "" : "s"}`, + profile_use: "Use", + profile_switch_title: "Switch to this profile", + profile_delete_title: "Delete this profile", + profile_default_label: "(default)", + profile_name_placeholder: "Profile name (lowercase, a-z 0-9 hyphens)", + profile_clone_label: "Clone config from active profile", + profile_base_url_placeholder: "Base URL (optional, e.g. http://localhost:11434)", + profile_api_key_placeholder: "API key (optional)", + manage_profiles: "Manage profiles", + profiles_load_failed: "Failed to load profiles", + profiles_busy_switch: "Cannot switch profiles while agent is running", + profile_switched_new_conversation: (name) => `Switched to profile: ${name} \u2014 new conversation started`, + profile_switched: (name) => `Switched to profile: ${name}`, + profile_name_rule: "Lowercase letters, numbers, hyphens, underscores only", + profile_base_url_rule: "Base URL must start with http:// or https://", + profile_created: (name) => `Profile created: ${name}`, + 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} \xB7 ${count} message${count === 1 ? "" : "s"}`, + settings_unsaved_changes: "You have unsaved changes.", + sign_out_failed: "Sign out failed: ", + disable_auth_confirm_title: "Disable password protection", + disable_auth_confirm_message: "Anyone will be able to access this instance.", + auth_disabled: "Auth disabled \u2014 password protection removed", + disable_auth_failed: "Failed to disable auth: ", + bg_error_single: (title) => `"${title}" has encountered an error`, + bg_error_multi: (count) => `${count} sessions have encountered an error` + } + }; + function t(key, ...args) { + const lang = typeof _locale !== "undefined" && _locale && _locale._lang ? _locale._lang : "en"; + const locale = LOCALES[lang] || LOCALES["en"] || LOCALES.en; + const en = LOCALES["en"]; + let val = locale[key]; + if (val === void 0) val = en[key]; + if (val === void 0) return key; + if (typeof val === "function") { + try { + return val(...args); + } catch { + return key; + } + } + if (typeof val === "string" && args.length > 0) { + return args.reduce((result, arg, i) => { + return result.replace(new RegExp(`\\{${i}\\}`, "g"), String(arg)); + }, val); + } + return val; } - if (lower.startsWith('zh-tw') || lower.startsWith('zh-hk') || lower.startsWith('zh-mo') || lower.startsWith('zh-hant')) { - return LOCALES['zh-Hant'] ? 'zh-Hant' : null; - } - - // Fallback to base language subtag (e.g. en-US -> en). - const base = lower.split('-')[0]; - const baseMatch = Object.keys(LOCALES).find((k) => k.toLowerCase() === base); - return baseMatch || null; -} - -/** - * Resolve locale with precedence: - * 1) primary (typically server setting) - * 2) fallback (typically localStorage) - * 3) English - * @param {string} primary - * @param {string} fallback - * @returns {string} - */ -function resolvePreferredLocale(primary, fallback) { - return resolveLocale(primary) || resolveLocale(fallback) || 'en'; -} - -/** - * Translate a key. Falls back to English if the key is missing in the active locale. - * Supports function values (for interpolated strings): call t('key', arg). - * @param {string} key - * @param {...*} args - forwarded to function-valued translations - * @returns {string} - */ -function t(key, ...args) { - const val = _locale[key] ?? LOCALES.en[key]; - if (val === undefined) return key; // final fallback: return key itself - return typeof val === 'function' ? val(...args) : val; -} - -/** - * Switch locale by language code (e.g. 'en', 'zh'). - * Persists to localStorage and updates the attribute. - * @param {string} lang - */ -function setLocale(lang) { - const resolved = resolveLocale(lang) || 'en'; - _locale = LOCALES[resolved]; - localStorage.setItem('hermes-lang', resolved); - document.documentElement.lang = _locale._speech || resolved; -} - -/** - * Load locale from localStorage (called once at boot, before DOMContentLoaded). - * Server-persisted preference is applied later in loadSettingsPanel(). - */ -function loadLocale() { - setLocale(resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))); -} - -/** - * Re-stamp all [data-i18n] elements in the DOM with the current locale. - * Safe to call at any time — missing keys fall back to English. - * Call after setLocale() to make static HTML text update without a reload. - */ -function applyLocaleToDOM() { - document.querySelectorAll('[data-i18n]').forEach(el => { - const key = el.getAttribute('data-i18n'); - const val = t(key); - if (val && val !== key) el.textContent = val; - }); - document.querySelectorAll('[data-i18n-title]').forEach(el => { - const key = el.getAttribute('data-i18n-title'); - const val = t(key); - if (val && val !== key) el.title = val; - }); - document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - const key = el.getAttribute('data-i18n-placeholder'); - const val = t(key); - if (val && val !== key) el.placeholder = val; - }); -} - -// Apply saved locale immediately so there's no flash of English on reload. -loadLocale(); + window.t = t; +})(); +//# sourceMappingURL=i18n.js.map diff --git a/static/i18n.ts b/static/i18n.ts new file mode 100644 index 0000000..8ab9719 --- /dev/null +++ b/static/i18n.ts @@ -0,0 +1,1046 @@ +// ── i18n: locale bundles and t() helper ────────────────────────────────────── +// To add a new language: add an entry to LOCALES below with all keys translated. +// The language code must match a valid BCP 47 tag (used for speech recognition). +// Keys missing in a non-English locale fall back to English automatically. + +// Type definitions for locale entries +type LocaleString = string; +type LocaleStringFn = (n: number) => string; +type LocaleStringFn2 = (m: string, p: string) => string; +type LocaleStringFnPath = (path: string) => string; +type LocaleStringFnName = (name: string) => string; +type LocaleStringFnStatus = (name: string, status: string) => string; +type LocaleStringFnTitle = (title: string) => string; +type LocaleStringFnCount = (count: number) => string; +type LocaleStringFnTitleCount = (title: string, count: number) => string; + +interface LocaleEntry { + _lang: string; + _label: string; + _speech: string; + // boot.js + cancelling?: LocaleString; + cancel_failed?: LocaleString; + mic_denied?: LocaleString; + mic_no_speech?: LocaleString; + mic_network?: LocaleString; + mic_error?: LocaleString; + session_imported?: LocaleString; + import_failed?: LocaleString; + import_invalid_json?: LocaleString; + image_pasted?: LocaleString; + // messages.js + edit_message?: LocaleString; + regenerate?: LocaleString; + copy?: LocaleString; + copied?: LocaleString; + you?: LocaleString; + thinking?: LocaleString; + expand_all?: LocaleString; + collapse_all?: LocaleString; + + edit_failed?: LocaleString; + regen_failed?: LocaleString; + reconnect_active?: LocaleString; + reconnect_finished?: LocaleString; + // approval card + approval_heading?: LocaleString; + approval_desc_prefix?: LocaleString; + approval_btn_once?: LocaleString; + approval_btn_once_title?: LocaleString; + approval_btn_session?: LocaleString; + approval_btn_session_title?: LocaleString; + approval_btn_always?: LocaleString; + approval_btn_always_title?: LocaleString; + approval_btn_deny?: LocaleString; + approval_btn_deny_title?: LocaleString; + approval_responding?: LocaleString; + clarify_heading?: LocaleString; + clarify_hint?: LocaleString; + clarify_other?: LocaleString; + clarify_send?: LocaleString; + clarify_input_placeholder?: LocaleString; + clarify_responding?: LocaleString; + untitled?: LocaleString; + n_messages?: LocaleStringFn; + model_unavailable?: LocaleString; + model_unavailable_title?: LocaleString; + provider_mismatch_warning?: LocaleStringFn2; + provider_mismatch_label?: LocaleString; + model_custom_label?: LocaleString; + model_custom_placeholder?: LocaleString; + model_search_placeholder?: LocaleString; + model_search_no_results?: LocaleString; + // commands.js + cmd_clear?: LocaleString; + cmd_compress?: LocaleString; + cmd_compact_alias?: LocaleString; + cmd_model?: LocaleString; + cmd_workspace?: LocaleString; + cmd_new?: LocaleString; + cmd_usage?: LocaleString; + cmd_theme?: LocaleString; + cmd_personality?: LocaleString; + cmd_skills?: LocaleString; + available_commands?: LocaleString; + type_slash?: LocaleString; + conversation_cleared?: LocaleString; + command_label?: LocaleString; + context_compaction_label?: LocaleString; + reference_only_label?: LocaleString; + model_usage?: LocaleString; + no_model_match?: LocaleString; + switched_to?: LocaleString; + workspace_usage?: LocaleString; + no_workspace_match?: LocaleString; + switched_workspace?: LocaleString; + workspace_switch_failed?: LocaleString; + new_session?: LocaleString; + compressing?: LocaleString; + compress_running_label?: LocaleString; + compress_complete_label?: LocaleString; + compress_failed_label?: LocaleString; + focus_label?: LocaleString; + token_usage_on?: LocaleString; + token_usage_off?: LocaleString; + theme_usage?: LocaleString; + theme_set?: LocaleString; + no_active_session?: LocaleString; + slash_skill_badge?: LocaleString; + slash_skill_desc?: LocaleString; + cmd_stop?: LocaleString; + cmd_title?: LocaleString; + cmd_retry?: LocaleString; + cmd_undo?: LocaleString; + cmd_status?: LocaleString; + cmd_voice?: LocaleString; + stream_stopped?: LocaleString; + no_active_task?: LocaleString; + cancel_unavailable?: LocaleString; + retry_failed?: LocaleString; + undo_failed?: LocaleString; + undid_n_messages?: LocaleString; + undid_messages_suffix?: LocaleString; + status_heading?: LocaleString; + status_session_id?: LocaleString; + status_title?: LocaleString; + status_model?: LocaleString; + status_workspace?: LocaleString; + status_personality?: LocaleString; + status_messages?: LocaleString; + status_agent_running?: LocaleString; + status_yes?: LocaleString; + status_no?: LocaleString; + status_load_failed?: LocaleString; + title_current?: LocaleString; + title_change_hint?: LocaleString; + title_set?: LocaleString; + cmd_webui_only_session?: LocaleString; + cmd_voice_use_mic?: LocaleString; + usage_heading?: LocaleString; + usage_default_model?: LocaleString; + usage_unknown?: LocaleString; + usage_input_tokens?: LocaleString; + usage_output_tokens?: LocaleString; + usage_total?: LocaleString; + usage_estimated_cost?: LocaleString; + usage_settings_tip?: LocaleString; + usage_load_failed?: LocaleString; + usage_personality_none?: LocaleString; + no_personalities?: LocaleString; + available_personalities?: LocaleString; + personality_switch_hint?: LocaleString; + personalities_load_failed?: LocaleString; + personality_cleared?: LocaleString; + personality_set?: LocaleString; + failed_colon?: LocaleString; + // ui.js + no_workspace?: LocaleString; + workspace_empty_no_path?: LocaleString; + workspace_empty_dir?: LocaleString; + dialog_confirm_title?: LocaleString; + dialog_prompt_title?: LocaleString; + dialog_confirm_btn?: LocaleString; + // workspace.js + unsaved_confirm?: LocaleString; + discard?: LocaleString; + save?: LocaleString; + edit?: LocaleString; + clear?: LocaleString; + create?: LocaleString; + remove?: LocaleString; + save_title?: LocaleString; + edit_title?: LocaleString; + saved?: LocaleString; + save_failed?: LocaleString; + image_load_failed?: LocaleString; + file_open_failed?: LocaleString; + downloading?: LocaleStringFnPath; + double_click_rename?: LocaleString; + renamed_to?: LocaleString; + rename_failed?: LocaleString; + delete_title?: LocaleString; + delete_confirm?: LocaleStringFnPath; + deleted?: LocaleString; + delete_failed?: LocaleString; + new_file_prompt?: LocaleString; + project_name_prompt?: LocaleString; + created?: LocaleString; + create_failed?: LocaleString; + new_folder_prompt?: LocaleString; + folder_created?: LocaleString; + folder_create_failed?: LocaleString; + remove_title?: LocaleString; + empty_dir?: LocaleString; + upload_failed?: LocaleString; + all_uploads_failed?: LocaleStringFn; + // settings panel + settings_title?: LocaleString; + settings_save_btn?: LocaleString; + settings_label_model?: LocaleString; + settings_label_send_key?: LocaleString; + settings_label_theme?: LocaleString; + settings_label_skin?: LocaleString; + settings_label_language?: LocaleString; + settings_label_token_usage?: LocaleString; + settings_label_bubble_layout?: LocaleString; + settings_label_cli_sessions?: LocaleString; + settings_label_sync_insights?: LocaleString; + settings_label_check_updates?: LocaleString; + settings_label_bot_name?: LocaleString; + settings_label_password?: LocaleString; + settings_saved?: LocaleString; + settings_save_failed?: LocaleString; + settings_load_failed?: LocaleString; + settings_saved_pw?: LocaleString; + settings_saved_pw_updated?: LocaleString; + // login page + login_title?: LocaleString; + login_subtitle?: LocaleString; + login_placeholder?: LocaleString; + login_btn?: LocaleString; + login_invalid_pw?: LocaleString; + login_conn_failed?: LocaleString; + // Sidebar & Tabs + tab_chat?: LocaleString; + tab_tasks?: LocaleString; + tab_skills?: LocaleString; + tab_memory?: LocaleString; + tab_workspaces?: LocaleString; + tab_profiles?: LocaleString; + new_conversation?: LocaleString; + filter_conversations?: LocaleString; + session_time_unknown?: LocaleString; + session_time_just_now?: LocaleString; + session_time_minutes_ago?: LocaleStringFn; + session_time_hours_ago?: LocaleStringFn; + session_time_days_ago?: LocaleStringFn; + session_time_last_week?: LocaleString; + session_time_bucket_today?: LocaleString; + session_time_bucket_yesterday?: LocaleString; + session_time_bucket_this_week?: LocaleString; + session_time_bucket_last_week?: LocaleString; + session_time_bucket_older?: LocaleString; + scheduled_jobs?: LocaleString; + new_job?: LocaleString; + loading?: LocaleString; + search_skills?: LocaleString; + new_skill?: LocaleString; + personal_memory?: LocaleString; + workspace_desc?: LocaleString; + new_profile?: LocaleString; + transcript?: LocaleString; + download_transcript?: LocaleString; + import?: LocaleString; + // Settings detail + settings_label_sound?: LocaleString; + settings_desc_sound?: LocaleString; + settings_label_notifications?: LocaleString; + settings_desc_notifications?: LocaleString; + settings_desc_token_usage?: LocaleString; + settings_desc_bubble_layout?: LocaleString; + settings_desc_cli_sessions?: LocaleString; + settings_desc_sync_insights?: LocaleString; + settings_desc_check_updates?: LocaleString; + settings_desc_bot_name?: LocaleString; + settings_desc_password?: LocaleString; + password_placeholder?: LocaleString; + disable_auth?: LocaleString; + sign_out?: LocaleString; + cancel?: LocaleString; + create_job?: LocaleString; + save_skill?: LocaleString; + editing?: LocaleString; + // Empty state + empty_title?: LocaleString; + empty_subtitle?: LocaleString; + suggest_files?: LocaleString; + suggest_schedule?: LocaleString; + suggest_plan?: LocaleString; + // onboarding + onboarding_badge?: LocaleString; + onboarding_title?: LocaleString; + onboarding_lead?: LocaleString; + onboarding_back?: LocaleString; + onboarding_continue?: LocaleString; + onboarding_skip?: LocaleString; + onboarding_skipped?: LocaleString; + onboarding_open?: LocaleString; + onboarding_step_system_title?: LocaleString; + onboarding_step_system_desc?: LocaleString; + onboarding_step_setup_title?: LocaleString; + onboarding_step_setup_desc?: LocaleString; + onboarding_step_workspace_title?: LocaleString; + onboarding_step_workspace_desc?: LocaleString; + onboarding_step_password_title?: LocaleString; + onboarding_step_password_desc?: LocaleString; + onboarding_step_finish_title?: LocaleString; + onboarding_step_finish_desc?: LocaleString; + onboarding_notice_system_ready?: LocaleString; + onboarding_notice_system_unavailable?: LocaleString; + onboarding_check_agent?: LocaleString; + onboarding_check_agent_ready?: LocaleString; + onboarding_check_agent_missing?: LocaleString; + onboarding_check_password?: LocaleString; + onboarding_check_password_enabled?: LocaleString; + onboarding_check_password_disabled?: LocaleString; + onboarding_check_provider?: LocaleString; + onboarding_check_provider_ready?: LocaleString; + onboarding_check_provider_partial?: LocaleString; + onboarding_check_provider_pending?: LocaleString; + onboarding_config_file?: LocaleString; + onboarding_env_file?: LocaleString; + onboarding_unknown?: LocaleString; + onboarding_current_provider?: LocaleString; + onboarding_missing_imports?: LocaleString; + onboarding_notice_setup_required?: LocaleString; + onboarding_notice_setup_already_ready?: LocaleString; + onboarding_oauth_provider_ready_title?: LocaleString; + onboarding_oauth_provider_ready_body?: LocaleString; + onboarding_oauth_provider_not_ready_title?: LocaleString; + onboarding_oauth_provider_not_ready_body?: LocaleString; + onboarding_oauth_switch_hint?: LocaleString; + onboarding_notice_workspace?: LocaleString; + onboarding_workspace_label?: LocaleString; + onboarding_workspace_or_path?: LocaleString; + onboarding_workspace_placeholder?: LocaleString; + onboarding_provider_label?: LocaleString; + onboarding_quick_setup_badge?: LocaleString; + onboarding_api_key_label?: LocaleString; + onboarding_api_key_placeholder?: LocaleString; + onboarding_api_key_help_prefix?: LocaleString; + onboarding_base_url_label?: LocaleString; + onboarding_base_url_placeholder?: LocaleString; + onboarding_base_url_help?: LocaleString; + onboarding_model_label?: LocaleString; + onboarding_workspace_help?: LocaleString; + onboarding_custom_model_placeholder?: LocaleString; + onboarding_custom_model_help?: LocaleString; + onboarding_notice_password_enabled?: LocaleString; + onboarding_notice_password_recommended?: LocaleString; + onboarding_password_label?: LocaleString; + onboarding_password_placeholder?: LocaleString; + onboarding_password_help?: LocaleString; + onboarding_notice_finish?: LocaleString; + onboarding_not_set?: LocaleString; + onboarding_password_will_enable?: LocaleString; + onboarding_password_will_replace?: LocaleString; + onboarding_password_keep_existing?: LocaleString; + onboarding_password_remains_disabled?: LocaleString; + onboarding_password_skipped?: LocaleString; + onboarding_finish_help?: LocaleString; + onboarding_error_choose_workspace?: LocaleString; + onboarding_error_choose_model?: LocaleString; + onboarding_error_provider_required?: LocaleString; + onboarding_error_base_url_required?: LocaleString; + onboarding_error_workspace_required?: LocaleString; + onboarding_error_model_required?: LocaleString; + onboarding_complete?: LocaleString; + // panel/runtime i18n + error_prefix?: LocaleString; + not_available?: LocaleString; + never?: LocaleString; + add?: LocaleString; + add_failed?: LocaleString; + remove_failed?: LocaleString; + switch_failed?: LocaleString; + name_required?: LocaleString; + content_required?: LocaleString; + view?: LocaleString; + dismiss?: LocaleString; + disable?: LocaleString; + cron_no_jobs?: LocaleString; + cron_status_off?: LocaleString; + cron_status_paused?: LocaleString; + cron_status_error?: LocaleString; + cron_status_active?: LocaleString; + cron_next?: LocaleString; + cron_last?: LocaleString; + cron_run_now?: LocaleString; + cron_pause?: LocaleString; + cron_resume?: LocaleString; + cron_job_name_placeholder?: LocaleString; + cron_schedule_placeholder?: LocaleString; + cron_prompt_placeholder?: LocaleString; + cron_last_output?: LocaleString; + cron_all_runs?: LocaleString; + cron_hide_runs?: LocaleString; + cron_no_runs_yet?: LocaleString; + cron_schedule_required_example?: LocaleString; + cron_schedule_required?: LocaleString; + cron_prompt_required?: LocaleString; + cron_job_created?: LocaleString; + cron_job_triggered?: LocaleString; + cron_job_paused?: LocaleString; + cron_job_resumed?: LocaleString; + cron_job_updated?: LocaleString; + cron_delete_confirm_title?: LocaleString; + cron_delete_confirm_message?: LocaleString; + cron_job_deleted?: LocaleString; + cron_completion_status?: LocaleStringFnStatus; + status_failed?: LocaleString; + status_completed?: LocaleString; + clear_conversation_title?: LocaleString; + clear_conversation_message?: LocaleString; + clear_failed?: LocaleString; + // inline cron labels + cron_label_schedule?: LocaleString; + cron_label_next_run?: LocaleString; + cron_label_last_ran?: LocaleString; + cron_label_prompt?: LocaleString; + cron_label_edit?: LocaleString; + cron_label_delete?: LocaleString; + cron_label_never?: LocaleString; + cron_running?: LocaleString; + skills_no_match?: LocaleString; + linked_files?: LocaleString; + skill_load_failed?: LocaleString; + skill_file_load_failed?: LocaleString; + skill_name_required?: LocaleString; + skill_updated?: LocaleString; + skill_created?: LocaleString; + memory_notes_label?: LocaleString; + memory_saved?: LocaleString; + my_notes?: LocaleString; + user_profile?: LocaleString; + no_notes_yet?: LocaleString; + no_profile_yet?: LocaleString; + workspace_choose_path?: LocaleString; + workspace_choose_path_meta?: LocaleString; + workspace_manage?: LocaleString; + workspace_manage_meta?: LocaleString; + workspace_use_title?: LocaleString; + workspace_use?: LocaleString; + workspace_add_path_placeholder?: LocaleString; + workspace_paths_validated_hint?: LocaleString; + workspace_added?: LocaleString; + workspace_remove_confirm_title?: LocaleString; + workspace_remove_confirm_message?: LocaleStringFnPath; + workspace_removed?: LocaleString; + workspace_switch_prompt_title?: LocaleString; + workspace_switch_prompt_message?: LocaleString; + workspace_switch_prompt_confirm?: LocaleString; + workspace_switch_prompt_placeholder?: LocaleString; + workspace_not_added?: LocaleString; + workspace_already_saved?: LocaleString; + workspace_busy_switch?: LocaleString; + discard_file_edits_title?: LocaleString; + discard_file_edits_message?: LocaleString; + workspace_switched_to?: LocaleStringFnPath; + profiles_no_profiles?: LocaleString; + profile_api_keys_configured?: LocaleString; + profile_gateway_running?: LocaleString; + profile_gateway_stopped?: LocaleString; + profile_active?: LocaleString; + profile_no_configuration?: LocaleString; + profile_skill_count?: LocaleStringFnCount; + profile_use?: LocaleString; + profile_switch_title?: LocaleString; + profile_delete_title?: LocaleString; + profile_default_label?: LocaleString; + profile_name_placeholder?: LocaleString; + profile_clone_label?: LocaleString; + profile_base_url_placeholder?: LocaleString; + profile_api_key_placeholder?: LocaleString; + manage_profiles?: LocaleString; + profiles_load_failed?: LocaleString; + profiles_busy_switch?: LocaleString; + profile_switched_new_conversation?: LocaleStringFnPath; + profile_switched?: LocaleStringFnPath; + profile_name_rule?: LocaleString; + profile_base_url_rule?: LocaleString; + profile_created?: LocaleStringFnPath; + profile_delete_confirm_title?: LocaleStringFnPath; + profile_delete_confirm_message?: LocaleString; + profile_deleted?: LocaleStringFnPath; + gateways_no_gateways?: LocaleString; + gateway_running?: LocaleString; + gateway_stopped?: LocaleString; + gateway_stop?: LocaleString; + gateway_start?: LocaleString; + gateway_restart?: LocaleString; + gateway_stop_title?: LocaleString; + gateway_start_title?: LocaleString; + gateway_restart_title?: LocaleString; + gateway_started?: LocaleStringFnPath; + gateway_stopped_msg?: LocaleStringFnPath; + gateway_restarted?: LocaleStringFnPath; + gateway_start_failed?: LocaleString; + gateway_stop_failed?: LocaleString; + gateway_restart_failed?: LocaleString; + gateway_add?: LocaleString; + gateway_add_title?: LocaleString; + gateway_add_message?: LocaleString; + gateway_added?: LocaleStringFnPath; + gateway_add_failed?: LocaleString; + active_conversation_none?: LocaleString; + active_conversation_meta?: LocaleStringFnTitleCount; + settings_unsaved_changes?: LocaleString; + sign_out_failed?: LocaleString; + disable_auth_confirm_title?: LocaleString; + disable_auth_confirm_message?: LocaleString; + auth_disabled?: LocaleString; + disable_auth_failed?: LocaleString; + bg_error_single?: LocaleStringFnTitle; + bg_error_multi?: LocaleStringFnCount; + // Fallback keys + [key: string]: LocaleString | LocaleStringFn | LocaleStringFn2 | LocaleStringFnPath | LocaleStringFnName | LocaleStringFnStatus | LocaleStringFnTitle | LocaleStringFnCount | LocaleStringFnTitleCount | undefined; +} + +const LOCALES: Record = { + en: { + _lang: 'en', + _label: 'English', + _speech: 'en-US', + // boot.js + cancelling: 'Cancelling\u2026', + cancel_failed: 'Cancel failed: ', + mic_denied: 'Microphone access denied. Check browser permissions.', + mic_no_speech: 'No speech detected. Try again.', + mic_network: 'Speech recognition unavailable.', + mic_error: 'Voice input error: ', + session_imported: 'Session imported', + import_failed: 'Import failed: ', + import_invalid_json: 'Invalid JSON', + image_pasted: 'Image pasted: ', + // messages.js + edit_message: 'Edit message', + regenerate: 'Regenerate response', + copy: 'Copy', + copied: 'Copied!', + you: 'You', + thinking: 'Thinking', + expand_all: 'Expand all', + collapse_all: 'Collapse all', + + edit_failed: 'Edit failed: ', + regen_failed: 'Regenerate failed: ', + reconnect_active: 'A response is still being generated. Reload when ready?', + reconnect_finished: 'A response was in progress when you last left. Messages may have updated.', + // approval card + approval_heading: 'Approval required', + approval_desc_prefix: 'Dangerous command detected', + approval_btn_once: 'Allow once', + approval_btn_once_title: 'Allow this one command (Enter)', + approval_btn_session: 'Allow session', + approval_btn_session_title: 'Allow for this conversation session', + approval_btn_always: 'Always allow', + approval_btn_always_title: 'Always allow this command pattern', + approval_btn_deny: 'Deny', + approval_btn_deny_title: 'Deny — do not run this command', + approval_responding: 'Responding\u2026', + clarify_heading: 'Clarification needed', + clarify_hint: 'Pick a choice, or type your own answer below.', + clarify_other: 'Other', + clarify_send: 'Send', + clarify_input_placeholder: 'Type your response\u2026', + clarify_responding: 'Responding\u2026', + untitled: 'Untitled', + n_messages: (n) => `${n} messages`, + model_unavailable: ' (unavailable)', + model_unavailable_title: 'This model is no longer in your current provider list', + provider_mismatch_warning: (m, p) => `"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`, + provider_mismatch_label: 'Provider mismatch', + model_custom_label: 'Custom model ID', + model_custom_placeholder: 'e.g. openai/gpt-5.4', + model_search_placeholder: 'Search models\u2026', + model_search_no_results: 'No models found', + // commands.js + cmd_clear: 'Clear conversation messages', + cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])', + cmd_compact_alias: 'Legacy alias for /compress', + cmd_model: 'Switch model (e.g. /model gpt-4o)', + cmd_workspace: 'Switch workspace by name', + cmd_new: 'Start a new chat session', + cmd_usage: 'Toggle token usage display on/off', + cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)', + cmd_personality: 'Switch agent personality', + cmd_skills: 'List available Hermes skills', + available_commands: 'Available commands:', + type_slash: 'Type / to see commands', + conversation_cleared: 'Conversation cleared', + command_label: 'Command', + context_compaction_label: 'Context compaction', + reference_only_label: 'Reference only', + model_usage: 'Usage: /model ', + no_model_match: 'No model matching "', + switched_to: 'Switched to ', + workspace_usage: 'Usage: /workspace ', + no_workspace_match: 'No workspace matching "', + switched_workspace: 'Switched to workspace: ', + workspace_switch_failed: 'Workspace switch failed: ', + new_session: 'New session created', + compressing: 'Requesting context compression...', + compress_running_label: 'Compressing', + compress_complete_label: 'Compression complete', + compress_failed_label: 'Compression failed', + focus_label: 'Focus', + token_usage_on: 'Token usage on', + token_usage_off: 'Token usage off', + theme_usage: 'Usage: /theme ', + theme_set: 'Theme: ', + no_active_session: 'No active session', + slash_skill_badge: 'Skill', + slash_skill_desc: 'Invoke this skill', + cmd_stop: 'Stop the current response', + cmd_title: 'Get or set the session title', + cmd_retry: 'Resend the last message', + cmd_undo: 'Remove the last exchange', + cmd_status: 'Show session info', + cmd_voice: 'Toggle microphone input', + stream_stopped: 'Response stopped.', + no_active_task: 'No active task to stop.', + cancel_unavailable: 'Cancel not available.', + retry_failed: 'Retry failed: ', + undo_failed: 'Undo failed: ', + undid_n_messages: 'Removed', + undid_messages_suffix: 'message(s).', + status_heading: 'Session Status', + status_session_id: 'Session ID', + status_title: 'Title', + status_model: 'Model', + status_workspace: 'Workspace', + status_personality: 'Personality', + status_messages: 'Messages', + status_agent_running: 'Agent running', + status_yes: 'Yes', + status_no: 'No', + status_load_failed: 'Failed to load status: ', + title_current: 'Current title', + title_change_hint: 'Use `/title ` to rename.', + title_set: 'Title set to', + cmd_webui_only_session: 'This command is not available for CLI-imported sessions.', + cmd_voice_use_mic: 'Click the mic button in the composer.', + usage_heading: 'Token Usage', + usage_default_model: 'default', + usage_unknown: 'unknown', + usage_input_tokens: 'Input tokens', + usage_output_tokens: 'Output tokens', + usage_total: 'Total tokens', + usage_estimated_cost: 'Estimated cost', + usage_settings_tip: 'Note: cost estimates are approximate.', + usage_load_failed: 'Failed to load usage: ', + usage_personality_none: 'none', + no_personalities: 'No personalities found (add them to ~/.hermes/personalities/)', + available_personalities: 'Available personalities:', + personality_switch_hint: '\n\nUse `/personality ` to switch, or `/personality none` to clear.', + personalities_load_failed: 'Failed to load personalities', + personality_cleared: 'Personality cleared', + personality_set: 'Personality: ', + failed_colon: 'Failed: ', + // ui.js + no_workspace: 'No workspace', + workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.', + workspace_empty_dir: 'This workspace is empty.', + dialog_confirm_title: 'Confirm action', + dialog_prompt_title: 'Enter a value', + dialog_confirm_btn: 'Confirm', + // workspace.js + unsaved_confirm: 'You have unsaved changes in the preview. Discard and navigate?', + discard: 'Discard', + save: 'Save', + edit: 'Edit', + clear: 'Clear', + create: 'Create', + remove: 'Remove', + save_title: 'Save changes', + edit_title: 'Edit this file', + saved: 'Saved', + save_failed: 'Save failed: ', + image_load_failed: 'Could not load image', + file_open_failed: 'Could not open file', + downloading: (name) => `Downloading ${name}\u2026`, + double_click_rename: 'Double-click to rename', + renamed_to: 'Renamed to ', + rename_failed: 'Rename failed: ', + delete_title: 'Delete', + delete_confirm: (name) => `Delete ${name}?`, + deleted: 'Deleted ', + delete_failed: 'Delete failed: ', + new_file_prompt: 'New file name (e.g. notes.md):', + project_name_prompt: 'Project name:', + created: 'Created ', + create_failed: 'Create failed: ', + new_folder_prompt: 'New folder name:', + folder_created: 'Created folder ', + folder_create_failed: 'Create folder failed: ', + remove_title: 'Remove', + empty_dir: '(empty)', + upload_failed: 'Upload failed: ', + all_uploads_failed: (n) => `All ${n} upload(s) failed`, + // settings panel + settings_title: 'Settings', + settings_save_btn: 'Save Settings', + settings_label_model: 'Default Model', + settings_label_send_key: 'Send Key', + settings_label_theme: 'Theme', + settings_label_skin: 'Skin', + settings_label_language: 'Language', + settings_label_token_usage: 'Show token usage', + settings_label_bubble_layout: 'Chat bubble layout', + settings_label_cli_sessions: 'Show agent sessions', + settings_label_sync_insights: 'Sync to insights', + settings_label_check_updates: 'Check for updates', + settings_label_bot_name: 'Assistant Name', + settings_label_password: 'Access Password', + settings_saved: 'Settings saved', + settings_save_failed: 'Save failed: ', + settings_load_failed: 'Failed to load settings: ', + settings_saved_pw: 'Settings saved — password protection enabled and this browser stays signed in', + settings_saved_pw_updated: 'Settings saved — password updated', + // login page + login_title: 'Sign in', + login_subtitle: 'Enter your password to continue', + login_placeholder: 'Password', + login_btn: 'Sign in', + login_invalid_pw: 'Invalid password', + login_conn_failed: 'Connection failed', + // Sidebar & Tabs + tab_chat: 'Chat', + tab_tasks: 'Tasks', + tab_skills: 'Skills', + tab_memory: 'Memory', + tab_workspaces: 'Spaces', + tab_profiles: 'Profiles', + new_conversation: 'New conversation', + filter_conversations: 'Filter conversations...', + session_time_unknown: 'Unknown', + session_time_just_now: 'gerade eben', + session_time_minutes_ago: (n) => `vor ${n} Min.`, + session_time_hours_ago: (n) => `vor ${n} Std.`, + session_time_days_ago: (n) => `vor ${n} Tag${n === 1 ? '' : 'en'}`, + session_time_last_week: 'letzte Woche', + session_time_bucket_today: 'Heute', + session_time_bucket_yesterday: 'Gestern', + session_time_bucket_this_week: 'Diese Woche', + session_time_bucket_last_week: 'Letzte Woche', + session_time_bucket_older: 'Älter', + scheduled_jobs: 'Geplante Aufgaben', + new_job: '+ Neue Aufgabe', + loading: 'Wird geladen...', + search_skills: 'Search skills...', + new_skill: 'New skill', + personal_memory: 'Personal memory', + workspace_desc: 'Add and switch workspaces for your sessions.', + new_profile: 'New profile', + transcript: 'Transcript', + download_transcript: 'Download as Markdown', + import: 'Import', + // Settings detail + settings_label_sound: 'Notification sound', + settings_desc_sound: 'Play a sound when the assistant finishes a response.', + settings_label_notifications: 'Browser notifications', + settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.', + settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.', + settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.', + settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.', + settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', + settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', + settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.', + settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.', + password_placeholder: 'Enter new password\u2026', + disable_auth: 'Disable Auth', + sign_out: 'Sign Out', + cancel: 'Cancel', + create_job: 'Create job', + save_skill: 'Save skill', + editing: 'Editing', + // Empty state + empty_title: 'What can I help with?', + empty_subtitle: 'Ask anything, run commands, explore files, or manage your scheduled tasks.', + suggest_files: 'What files are in this workspace?', + suggest_schedule: "What's on my schedule today?", + suggest_plan: 'Help me plan a small project.', + // onboarding + onboarding_badge: 'FIRST RUN', + onboarding_title: 'Welcome to Hermes Web UI', + onboarding_lead: 'A quick guided setup will verify Hermes, save a real provider configuration, choose a workspace and model, and optionally protect the app with a password.', + onboarding_back: 'Back', + onboarding_continue: 'Continue', + onboarding_skip: 'Skip setup', + onboarding_skipped: 'Setup skipped — using existing config.', + onboarding_open: 'Open Hermes', + onboarding_step_system_title: 'System check', + onboarding_step_system_desc: 'Verify Hermes Agent and config visibility.', + onboarding_step_setup_title: 'Provider setup', + onboarding_step_setup_desc: 'Save the minimum Hermes provider config.', + onboarding_step_workspace_title: 'Workspace + model', + onboarding_step_workspace_desc: 'Pick defaults for new sessions and chat.', + onboarding_step_password_title: 'Optional password', + onboarding_step_password_desc: 'Protect the Web UI before sharing it.', + onboarding_step_finish_title: 'Finish', + onboarding_step_finish_desc: 'Review and enter the app.', + onboarding_notice_system_ready: 'Hermes Agent looks reachable from the Web UI.', + onboarding_notice_system_unavailable: 'Hermes Agent is not fully available yet. Bootstrap can install it, but provider setup may still require a terminal.', + onboarding_check_agent: 'Hermes Agent', + onboarding_check_agent_ready: 'Detected and importable', + onboarding_check_agent_missing: 'Missing or partially importable', + onboarding_check_password: 'Password', + onboarding_check_password_enabled: 'Already enabled', + onboarding_check_password_disabled: 'Not enabled yet', + onboarding_check_provider: 'Provider config', + onboarding_check_provider_ready: 'Ready to chat', + onboarding_check_provider_partial: 'Saved but incomplete', + onboarding_check_provider_pending: 'Needs verification', + onboarding_config_file: 'Config file:', + onboarding_env_file: '.env file:', + onboarding_unknown: 'Unknown', + onboarding_current_provider: 'Current setup:', + onboarding_missing_imports: 'Missing imports:', + onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.', + onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.', + onboarding_oauth_provider_ready_title: 'Provider already authenticated', + onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider ({provider}) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.', + onboarding_oauth_provider_not_ready_title: 'OAuth provider not yet authenticated', + onboarding_oauth_provider_not_ready_body: 'This instance is configured to use {provider}, which uses OAuth rather than an API key. Run hermes auth or hermes model in a terminal to authenticate, then reload the Web UI.', + onboarding_oauth_switch_hint: 'Or choose a different provider below to switch to an API-key setup:', + onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.', + onboarding_workspace_label: 'Workspace', + onboarding_workspace_or_path: 'Or enter a workspace path', + onboarding_workspace_placeholder: '/home/you/workspace', + onboarding_provider_label: 'Setup mode', + onboarding_quick_setup_badge: 'quick setup', + onboarding_api_key_label: 'API key', + onboarding_api_key_placeholder: 'Leave blank to keep an existing saved key', + onboarding_api_key_help_prefix: 'Saved as a secret in your Hermes .env file using', + onboarding_base_url_label: 'Base URL', + onboarding_base_url_placeholder: 'https://your-endpoint.example/v1', + onboarding_base_url_help: 'Use this for OpenAI-compatible routers, self-hosted servers, LiteLLM, Ollama, LM Studio, vLLM, or similar endpoints.', + onboarding_model_label: 'Default model', + onboarding_workspace_help: 'Pick the model Hermes should use for new chats after setup completes.', + onboarding_custom_model_placeholder: 'your-model-name', + onboarding_custom_model_help: 'For custom endpoints, enter the exact model ID your server expects.', + onboarding_notice_password_enabled: 'A password is already configured. Enter a new one only if you want to replace it.', + onboarding_notice_password_recommended: 'Optional but recommended if you will expose the UI beyond localhost.', + onboarding_password_label: 'Password (optional)', + onboarding_password_placeholder: 'Leave blank to skip', + onboarding_password_help: 'Passwords are stored through the existing settings API and hashed server-side.', + onboarding_notice_finish: 'You can reopen Settings later to change any of this.', + onboarding_not_set: 'Not set', + onboarding_password_will_enable: 'Will be enabled', + onboarding_password_will_replace: 'Will be replaced', + onboarding_password_keep_existing: 'Keep current password', + onboarding_password_remains_disabled: 'Will remain disabled', + onboarding_password_skipped: 'Skipped for now', + onboarding_finish_help: 'Finishing stores onboarding_completed in settings and drops you into the normal app.', + onboarding_error_choose_workspace: 'Choose a workspace before continuing.', + onboarding_error_choose_model: 'Choose a model before continuing.', + onboarding_error_provider_required: 'Choose a setup mode before continuing.', + onboarding_error_base_url_required: 'Base URL is required for custom endpoints.', + onboarding_error_workspace_required: 'Workspace is required.', + onboarding_error_model_required: 'Model is required.', + onboarding_complete: 'Onboarding complete', + // panel/runtime i18n + error_prefix: 'Error: ', + not_available: 'N/A', + never: 'never', + add: 'Add', + add_failed: 'Add failed: ', + remove_failed: 'Remove failed: ', + switch_failed: 'Switch failed: ', + name_required: 'Name is required', + content_required: 'Content is required', + view: 'View', + dismiss: 'Dismiss', + disable: 'Disable', + cron_no_jobs: 'Keine geplanten Aufgaben gefunden.', + cron_status_off: 'AUS', + cron_status_paused: 'PAUSIERT', + cron_status_error: 'FEHLER', + cron_status_active: 'AKTIV', + cron_next: 'N\u00e4chste', + cron_last: 'Letzte', + cron_run_now: 'Jetzt starten', + cron_pause: 'Pausieren', + cron_resume: 'Fortsetzen', + cron_job_name_placeholder: 'Aufgabenname', + cron_schedule_placeholder: 'Zeitplan', + cron_prompt_placeholder: 'Prompt', + cron_last_output: 'Letzte Ausgabe', + cron_all_runs: 'Alle Ausf\u00fchrungen', + cron_hide_runs: 'Ausf\u00fchrungen verbergen', + cron_no_runs_yet: '(noch keine Ausf\u00fchrung)', + cron_schedule_required_example: 'Zeitplan erforderlich (z.B. "0 9 * * *" oder "every 1h")', + cron_schedule_required: 'Zeitplan erforderlich', + cron_prompt_required: 'Prompt erforderlich', + cron_loading: 'Laden...', + cron_add_skills_placeholder: 'Skills hinzufügen (optional)...', + cron_deliver_local: 'Lokal (nur speichern)', + cron_deliver_discord: 'Discord', + cron_deliver_telegram: 'Telegram', + cron_job_created: 'Aufgabe erstellt', + cron_job_triggered: 'Aufgabe gestartet', + cron_job_paused: 'Aufgabe pausiert', + cron_job_resumed: 'Aufgabe fortgesetzt', + cron_job_updated: 'Aufgabe aktualisiert', + cron_delete_confirm_title: 'Aufgabe l\u00f6schen', + cron_delete_confirm_message: 'Dies kann nicht r\u00fcckg\u00e4ngig gemacht werden.', + cron_job_deleted: 'Aufgabe gel\u00f6scht', + cron_completion_status: (name, status) => `Aufgabe "${name}" ${status}`, + status_failed: 'fehlgeschlagen', + status_completed: 'abgeschlossen', + clear_conversation_title: 'Chat leeren', + clear_conversation_message: 'Alle Nachrichten löschen? Dies kann nicht rückgängig gemacht werden.', + clear_failed: 'Leeren fehlgeschlagen: ', + cron_running: 'L\u00e4uft...', + // inline cron labels (used directly in HTML templates) + cron_label_schedule: 'Zeitplan', + cron_label_next_run: 'N\u00e4chste Ausf\u00fchrung', + cron_label_last_ran: 'Zuletzt gelaufen', + cron_label_prompt: 'Prompt', + cron_label_edit: 'Bearbeiten', + cron_label_delete: 'L\u00f6schen', + cron_label_never: 'nie', + skills_no_match: 'Keine Skills gefunden.', + linked_files: 'Linked Files', + skill_load_failed: 'Could not load skill: ', + skill_file_load_failed: 'Could not load file: ', + skill_name_required: 'Skill name is required', + skill_updated: 'Skill updated', + skill_created: 'Skill created', + memory_notes_label: 'memory (notes)', + memory_saved: 'Memory saved', + my_notes: 'My Notes', + user_profile: 'User Profile', + no_notes_yet: 'No notes yet.', + no_profile_yet: 'No profile yet.', + workspace_choose_path: 'Choose workspace path', + workspace_choose_path_meta: 'Add a validated path and switch this conversation', + workspace_manage: 'Manage workspaces', + workspace_manage_meta: 'Open the Spaces panel', + workspace_use_title: 'Use in current session', + workspace_use: 'Use', + workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)', + workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.', + workspace_added: 'Workspace added', + workspace_remove_confirm_title: 'Remove workspace', + workspace_remove_confirm_message: (path) => `Remove "${path}"?`, + workspace_removed: 'Workspace removed', + workspace_switch_prompt_title: 'Switch workspace', + workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.', + workspace_switch_prompt_confirm: 'Switch', + workspace_switch_prompt_placeholder: '/Users/you/project', + workspace_not_added: 'Workspace was not added', + workspace_already_saved: 'Workspace already saved — choose it from the list', + workspace_busy_switch: 'Cannot switch workspace while agent is running', + discard_file_edits_title: 'Discard file edits?', + discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.', + workspace_switched_to: (name) => `Switched to ${name}`, + profiles_no_profiles: 'No profiles found.', + profile_api_keys_configured: 'API keys configured', + profile_gateway_running: 'Gateway running', + profile_gateway_stopped: 'Gateway stopped', + profile_active: 'ACTIVE', + profile_no_configuration: 'No configuration', + profile_skill_count: (count) => `${count} skill${count === 1 ? '' : 's'}`, + profile_use: 'Use', + profile_switch_title: 'Switch to this profile', + profile_delete_title: 'Delete this profile', + profile_default_label: '(default)', + profile_name_placeholder: 'Profile name (lowercase, a-z 0-9 hyphens)', + profile_clone_label: 'Clone config from active profile', + profile_base_url_placeholder: 'Base URL (optional, e.g. http://localhost:11434)', + profile_api_key_placeholder: 'API key (optional)', + manage_profiles: 'Manage profiles', + profiles_load_failed: 'Failed to load profiles', + profiles_busy_switch: 'Cannot switch profiles while agent is running', + profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, + profile_switched: (name) => `Switched to profile: ${name}`, + profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', + profile_base_url_rule: 'Base URL must start with http:// or https://', + profile_created: (name) => `Profile created: ${name}`, + 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.', + sign_out_failed: 'Sign out failed: ', + disable_auth_confirm_title: 'Disable password protection', + disable_auth_confirm_message: 'Anyone will be able to access this instance.', + auth_disabled: 'Auth disabled — password protection removed', + disable_auth_failed: 'Failed to disable auth: ', + bg_error_single: (title) => `"${title}" has encountered an error`, + bg_error_multi: (count) => `${count} sessions have encountered an error`, + }, +}; + +// Helper type for t() function arguments +type TranslationValue = LocaleString | LocaleStringFn | LocaleStringFn2 | LocaleStringFnPath | LocaleStringFnName | LocaleStringFnStatus | LocaleStringFnTitle | LocaleStringFnCount | LocaleStringFnTitleCount | undefined; + +function t(key: string, ...args: unknown[]): string { + const lang = (typeof _locale !== 'undefined' && _locale && _locale._lang) ? _locale._lang : 'en'; + const locale = LOCALES[lang] || LOCALES['en'] || LOCALES.en; + const en = LOCALES['en']; + + // Try current locale first, fall back to English + let val: TranslationValue = locale[key]; + if (val === undefined) val = en[key]; + if (val === undefined) return key; + + // Handle function types (parameterized translations) + if (typeof val === 'function') { + try { + return (val as (...args: unknown[]) => string)(...args); + } catch { + return key; + } + } + + // Handle string replacements for simple string values with {0}, {1} style placeholders + if (typeof val === 'string' && args.length > 0) { + return args.reduce((result, arg, i) => { + return result.replace(new RegExp(`\\{${i}\\}`, 'g'), String(arg)); + }, val); + } + + return val as string; +} + +// Declare the global _locale variable +declare global { + var _locale: LocaleEntry | undefined; +} + +export { LOCALES, t }; +export type { LocaleEntry }; diff --git a/static/icons.js b/static/icons.js index d706ec6..ae563a0 100644 --- a/static/icons.js +++ b/static/icons.js @@ -1,77 +1,68 @@ -// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ────────── -// All icons are 24×24 viewBox, stroke-based, currentColor. -// Usage: li('folder') → returns a ready-to-embed SVG string -// The returned SVG uses display:inline-block + vertical-align so it sits -// neatly beside text in both HTML templates and innerHTML assignments. - -const LI_PATHS = { - // Navigation tabs - 'message-square': '', - 'calendar': '', - 'layers': '', - 'lightbulb': '', - 'folder': '', - 'list-todo': '', - // Editing / actions - 'pencil': '', - 'save': '', - 'chevron-down': '', - 'chevron-right': '', - 'download': '', - 'upload': '', - 'braces': '', - 'trash-2': '', - 'settings': '', - 'alert-triangle': '', - 'refresh-cw': '', - 'check': '', - 'lock': '', - 'star': '', - 'x': '', - 'square': '', - 'plus': '', - 'arrow-up': '', - 'arrow-right': '', - 'loader': '', - 'pause': '', - // Tool icons - 'terminal': '', - 'file-text': '', - 'file-pen': '', - 'search': '', - 'globe': '', - 'play': '', - 'wrench': '', - 'brain': '', - 'book-open': '', - 'clock': '', - 'bot': '', - 'eye': '', - 'shuffle': '', - 'paperclip': '', - 'copy': '', - 'rotate-ccw': '', - 'user': '', - // File-type icons - 'image': '', - 'file-code': '', - 'zap': '', - // Suggestion buttons - 'clipboard-list': '', - 'map': '', -}; - -/** - * Returns a Lucide SVG string for the given icon name. - * @param {string} name – key in LI_PATHS (e.g. 'folder', 'trash-2') - * @param {number} size – width/height in px (default 16) - * @returns {string} SVG element string ready for innerHTML - */ -function li(name, size = 16) { - const p = LI_PATHS[name]; - if (!p) { console.warn('li(): unknown icon', name); return ''; } - return ``; -} +(() => { + const LI_PATHS = { + // Navigation tabs + "message-square": '', + "calendar": '', + "layers": '', + "lightbulb": '', + "folder": '', + "list-todo": '', + // Editing / actions + "pencil": '', + "save": '', + "chevron-down": '', + "chevron-right": '', + "download": '', + "upload": '', + "braces": '', + "trash-2": '', + "settings": '', + "alert-triangle": '', + "refresh-cw": '', + "check": '', + "lock": '', + "star": '', + "x": '', + "square": '', + "plus": '', + "arrow-up": '', + "arrow-right": '', + "loader": '', + "pause": '', + // Tool icons + "terminal": '', + "file-text": '', + "file-pen": '', + "search": '', + "globe": '', + "play": '', + "wrench": '', + "brain": '', + "book-open": '', + "clock": '', + "bot": '', + "eye": '', + "shuffle": '', + "paperclip": '', + "copy": '', + "rotate-ccw": '', + "user": '', + // File-type icons + "image": '', + "file-code": '', + "zap": '', + // Suggestion buttons + "clipboard-list": '', + "map": '' + }; + function li(name, size = 16) { + const p = LI_PATHS[name]; + if (!p) { + console.warn("li(): unknown icon", name); + return ""; + } + return ``; + } + window.li = li; +})(); +//# sourceMappingURL=icons.js.map diff --git a/static/icons.ts b/static/icons.ts new file mode 100644 index 0000000..989ac35 --- /dev/null +++ b/static/icons.ts @@ -0,0 +1,79 @@ +// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ────────── +// All icons are 24×24 viewBox, stroke-based, currentColor. +// Usage: li('folder') → returns a ready-to-embed SVG string +// The returned SVG uses display:inline-block + vertical-align so it sits +// neatly beside text in both HTML templates and innerHTML assignments. + +const LI_PATHS: Record = { + // Navigation tabs + 'message-square': '', + 'calendar': '', + 'layers': '', + 'lightbulb': '', + 'folder': '', + 'list-todo': '', + // Editing / actions + 'pencil': '', + 'save': '', + 'chevron-down': '', + 'chevron-right': '', + 'download': '', + 'upload': '', + 'braces': '', + 'trash-2': '', + 'settings': '', + 'alert-triangle': '', + 'refresh-cw': '', + 'check': '', + 'lock': '', + 'star': '', + 'x': '', + 'square': '', + 'plus': '', + 'arrow-up': '', + 'arrow-right': '', + 'loader': '', + 'pause': '', + // Tool icons + 'terminal': '', + 'file-text': '', + 'file-pen': '', + 'search': '', + 'globe': '', + 'play': '', + 'wrench': '', + 'brain': '', + 'book-open': '', + 'clock': '', + 'bot': '', + 'eye': '', + 'shuffle': '', + 'paperclip': '', + 'copy': '', + 'rotate-ccw': '', + 'user': '', + // File-type icons + 'image': '', + 'file-code': '', + 'zap': '', + // Suggestion buttons + 'clipboard-list': '', + 'map': '', +}; + +/** + * Returns a Lucide SVG string for the given icon name. + * @param name – key in LI_PATHS (e.g. 'folder', 'trash-2') + * @param size – width/height in px (default 16) + * @returns SVG element string ready for innerHTML + */ +function li(name: string, size: number = 16): string { + const p = LI_PATHS[name]; + if (!p) { console.warn('li(): unknown icon', name); return ''; } + return ``; +} + +export { li, LI_PATHS }; diff --git a/static/index.html b/static/index.html index f7d54a7..fa22529 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,7 @@ Hermes + @@ -26,8 +27,7 @@ - - +
@@ -43,46 +43,60 @@
-
Loading...
+
Laden...
- - -
- -
-
📋 Projects
-
- -
- - -
- + - - - - -
- - -
-
- - - - -
-
-
- - - -
-
-
- - -
-
- - -
- -
- -
- -
- -
-
- - -
-
-
- 📋 TODO - -
-
+ +
+
+
+

Agent Activity

+
+ + +
-
-
- ⚡ IN PROGRESS - -
-
-
-
-
- 👀 REVIEW - -
-
-
-
-
- ✅ DONE - -
-
+
+
+ +
+ + + +
+
+
-