Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
21
.gitignore
vendored
21
.gitignore
vendored
@@ -36,3 +36,24 @@ Thumbs.db
|
|||||||
docs/*
|
docs/*
|
||||||
!docs/ui-ux/
|
!docs/ui-ux/
|
||||||
!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
|
||||||
|
|||||||
1035
api/agents.py
1035
api/agents.py
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,8 @@ def _get_agent_soul(agent_id: str) -> str | None:
|
|||||||
|
|
||||||
Returns None if not found.
|
Returns None if not found.
|
||||||
"""
|
"""
|
||||||
if not agent_id or agent_id == "rose":
|
if not agent_id:
|
||||||
return None # Rose uses the global HERMES_HOME/SOUL.md
|
return None
|
||||||
|
|
||||||
for fname in ("soul.md", "SOUL.md"):
|
for fname in ("soul.md", "SOUL.md"):
|
||||||
path = HERMES_HOME / "agents" / agent_id / fname
|
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}/".
|
Searches rose_memory collection filtered by topic matching "{agent_id}/".
|
||||||
Returns formatted text block or None if nothing found.
|
Returns formatted text block or None if nothing found.
|
||||||
"""
|
"""
|
||||||
if not agent_id or agent_id == "rose":
|
if not agent_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
matches = _search_agent_memory(agent_id, query, limit=limit)
|
matches = _search_agent_memory(agent_id, query, limit=limit)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import threading
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.RLock() # Reentrant for consistency; no nested calls currently but safer
|
||||||
_pending: dict[str, dict] = {}
|
_pending: dict[str, dict] = {}
|
||||||
_gateway_queues: dict[str, list] = {}
|
_gateway_queues: dict[str, list] = {}
|
||||||
_gateway_notify_cbs: dict[str, object] = {}
|
_gateway_notify_cbs: dict[str, object] = {}
|
||||||
|
|||||||
@@ -1033,6 +1033,18 @@ def get_available_models() -> dict:
|
|||||||
logger.debug("Live models fetched for %s: %s", pid, _live_ids)
|
logger.debug("Live models fetched for %s: %s", pid, _live_ids)
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.debug("Could not fetch live models for %s: %s", pid, _e)
|
logger.debug("Could not fetch live models for %s: %s", pid, _e)
|
||||||
|
# Fallback: read models from config.yaml providers.<pid>.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()
|
_active = (active_provider or "").lower()
|
||||||
if _active and pid != _active:
|
if _active and pid != _active:
|
||||||
models = []
|
models = []
|
||||||
@@ -1136,7 +1148,7 @@ def get_available_models() -> dict:
|
|||||||
_INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
|
_INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
|
||||||
|
|
||||||
# ── Thread synchronisation ───────────────────────────────────────────────────
|
# ── Thread synchronisation ───────────────────────────────────────────────────
|
||||||
LOCK = threading.Lock()
|
LOCK = threading.RLock() # Reentrant — allows nested acquisition in save() → _write_session_index()
|
||||||
SESSIONS_MAX = 100
|
SESSIONS_MAX = 100
|
||||||
CHAT_LOCK = threading.Lock()
|
CHAT_LOCK = threading.Lock()
|
||||||
STREAMS: dict = {}
|
STREAMS: dict = {}
|
||||||
@@ -1188,6 +1200,8 @@ _SETTINGS_DEFAULTS = {
|
|||||||
"sound_enabled": False, # play notification sound when assistant finishes
|
"sound_enabled": False, # play notification sound when assistant finishes
|
||||||
"notifications_enabled": False, # browser notification when tab is in background
|
"notifications_enabled": False, # browser notification when tab is in background
|
||||||
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
|
"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
|
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
|
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
|
||||||
@@ -1198,7 +1212,7 @@ def load_settings() -> dict:
|
|||||||
settings = dict(_SETTINGS_DEFAULTS)
|
settings = dict(_SETTINGS_DEFAULTS)
|
||||||
if SETTINGS_FILE.exists():
|
if SETTINGS_FILE.exists():
|
||||||
try:
|
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):
|
if isinstance(stored, dict):
|
||||||
settings.update(
|
settings.update(
|
||||||
{
|
{
|
||||||
|
|||||||
289
api/heartbeats.py
Normal file
289
api/heartbeats.py
Normal file
@@ -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
|
||||||
@@ -35,17 +35,26 @@ def safe_resolve(root: Path, requested: str) -> Path:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def _security_headers(handler):
|
def _security_headers(handler, origin=None):
|
||||||
"""Add security headers to every response."""
|
"""Add security headers to every response."""
|
||||||
handler.send_header('X-Content-Type-Options', 'nosniff')
|
handler.send_header('X-Content-Type-Options', 'nosniff')
|
||||||
handler.send_header('X-Frame-Options', 'DENY')
|
handler.send_header('X-Frame-Options', 'DENY')
|
||||||
handler.send_header('Referrer-Policy', 'same-origin')
|
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(
|
handler.send_header(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
"style-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'"
|
"base-uri 'self'; form-action 'self'"
|
||||||
)
|
)
|
||||||
handler.send_header(
|
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-Type', 'application/json; charset=utf-8')
|
||||||
handler.send_header('Content-Length', str(len(body)))
|
handler.send_header('Content-Length', str(len(body)))
|
||||||
handler.send_header('Cache-Control', 'no-store')
|
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.end_headers()
|
||||||
handler.wfile.write(body)
|
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-Type', content_type)
|
||||||
handler.send_header('Content-Length', str(len(body)))
|
handler.send_header('Content-Length', str(len(body)))
|
||||||
handler.send_header('Cache-Control', 'no-store')
|
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.end_headers()
|
||||||
handler.wfile.write(body)
|
handler.wfile.write(body)
|
||||||
|
|
||||||
|
|||||||
827
api/mc.py
827
api/mc.py
@@ -1,218 +1,695 @@
|
|||||||
"""
|
# api/mc.py
|
||||||
Mission Control API — Data layer for Hermes WebUI Mission Control extension.
|
# Mission Control — Projects & Tasks API
|
||||||
Provides priorities, tasks, feed, and dashboard status management.
|
# Rose's persönliches PM-System
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import uuid
|
||||||
import time
|
|
||||||
from pathlib import Path
|
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 ────────────────────────────────────────────────────────────────
|
TASKS_FILE = DATA_DIR / "tasks.json"
|
||||||
_MC_DATA_FILE = Path.home() / ".hermes" / "data" / "mc-data.json"
|
PROJECTS_FILE = DATA_DIR / "projects.json"
|
||||||
_MC_LOCK = threading.RLock()
|
|
||||||
|
|
||||||
# ── Default structure ─────────────────────────────────────────────────────────
|
TASKS_FILE.write_text(json.dumps({"version": "3.0.0", "tasks": []}, indent=2))
|
||||||
DEFAULT_MC_DATA = {
|
PROJECTS_FILE.write_text(json.dumps({"version": "3.0.0", "projects": []}, indent=2))
|
||||||
"priorities": [],
|
|
||||||
"tasks": [],
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
"feed": [],
|
# 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:
|
def _load_tasks():
|
||||||
"""Load Mission Control data from disk."""
|
if TASKS_FILE.exists():
|
||||||
with _MC_LOCK:
|
with TASKS_FILE.open(encoding="utf-8") as f:
|
||||||
if not _MC_DATA_FILE.exists():
|
return json.loads(f.read())
|
||||||
return DEFAULT_MC_DATA.copy()
|
return {"version": "3.0.0", "tasks": []}
|
||||||
try:
|
|
||||||
with open(_MC_DATA_FILE, "r") as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (json.JSONDecodeError, IOError):
|
|
||||||
return DEFAULT_MC_DATA.copy()
|
|
||||||
|
|
||||||
|
def _save_tasks(data):
|
||||||
|
TASKS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
def _save_mc_data(data: dict) -> None:
|
def _load_projects():
|
||||||
"""Save Mission Control data to disk."""
|
if PROJECTS_FILE.exists():
|
||||||
with _MC_LOCK:
|
with PROJECTS_FILE.open(encoding="utf-8") as f:
|
||||||
_MC_DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
return json.loads(f.read())
|
||||||
with open(_MC_DATA_FILE, "w") as f:
|
return {"version": "3.0.0", "projects": []}
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
|
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]:
|
def _now():
|
||||||
"""Return all priorities sorted by id."""
|
return datetime.now().isoformat()
|
||||||
data = _load_mc_data()
|
|
||||||
return sorted(data.get("priorities", []), key=lambda p: p.get("id", 0))
|
|
||||||
|
|
||||||
|
def _today():
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
def create_priority(name: str, color: str = "#808080") -> dict:
|
def _auto_done_subtasks(item):
|
||||||
"""Add a new priority. Returns the created priority."""
|
"""Check if all subtasks are done (for auto-done logic)."""
|
||||||
data = _load_mc_data()
|
subtasks = item.get("subtasks", [])
|
||||||
priorities = data.get("priorities", [])
|
if not subtasks:
|
||||||
new_id = max([p.get("id", 0) for p in priorities], default=0) + 1
|
return None
|
||||||
priority = {"id": new_id, "name": name, "color": color}
|
all_done = all(s.get("done", False) for s in subtasks)
|
||||||
priorities.append(priority)
|
return all_done
|
||||||
data["priorities"] = priorities
|
|
||||||
_save_mc_data(data)
|
|
||||||
_add_feed_event(f"Priority created: {name}")
|
|
||||||
return priority
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# TASKS — CRUD
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def update_priority(priority_id: int, name: str = None, color: str = None, done: bool = None) -> dict | None:
|
def list_tasks(filters=None):
|
||||||
"""Update an existing priority. Returns updated priority or None if not found."""
|
"""GET /api/mc/tasks — alle Tasks mit optionalen Filtern."""
|
||||||
data = _load_mc_data()
|
data = _load_tasks()
|
||||||
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()
|
|
||||||
tasks = data.get("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}
|
if not filters:
|
||||||
tasks.append(task)
|
return tasks
|
||||||
data["tasks"] = tasks
|
|
||||||
_save_mc_data(data)
|
# Filter: project_id
|
||||||
_add_feed_event(f"Task created: {title}")
|
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
|
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:
|
for t in data["tasks"]:
|
||||||
"""Update a task by id. kwargs: title, priority, status. Returns updated task or None."""
|
if t["id"] == task_id:
|
||||||
data = _load_mc_data()
|
# Erlaubte Felder
|
||||||
tasks = data.get("tasks", [])
|
for key in ["title", "task_type", "type", "project_id", "phase_id",
|
||||||
for t in tasks:
|
"status", "priority", "due", "due_time", "tags",
|
||||||
if t.get("id") == task_id:
|
"daily_schedule", "assigned_agent", "agent_status",
|
||||||
old_status = t.get("status")
|
"agent_note", "cron_schedule", "cron_last_run",
|
||||||
for key in ("title", "priority", "status"):
|
"cron_next_run", "daily_completed_today", "daily_last_done"]:
|
||||||
if key in kwargs:
|
if key in body:
|
||||||
t[key] = kwargs[key]
|
t[key] = body[key]
|
||||||
new_status = t.get("status")
|
|
||||||
# Feed events for status transitions
|
# Status → completed_at
|
||||||
if old_status != new_status:
|
if body.get("status") == "done" and t["completed_at"] is None:
|
||||||
if new_status == "done":
|
t["completed_at"] = _now()
|
||||||
_add_feed_event(f"Task completed: {t['title']}")
|
elif body.get("status") and body.get("status") != "done":
|
||||||
elif new_status == "progress":
|
t["completed_at"] = None
|
||||||
_add_feed_event(f"Task started: {t['title']}")
|
|
||||||
data["tasks"] = tasks
|
t["updated_at"] = _now()
|
||||||
_save_mc_data(data)
|
|
||||||
|
# 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 t
|
||||||
|
|
||||||
return None
|
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."""
|
# DAILY TASKS
|
||||||
data = _load_mc_data()
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
tasks = data.get("tasks", [])
|
|
||||||
original_len = len(tasks)
|
def list_daily():
|
||||||
tasks = [t for t in tasks if t.get("id") != task_id]
|
"""GET /api/mc/daily — alle Daily Tasks."""
|
||||||
if len(tasks) < original_len:
|
return list_tasks({"task_type": "daily"})
|
||||||
data["tasks"] = tasks
|
|
||||||
_save_mc_data(data)
|
def toggle_daily_done(task_id):
|
||||||
return True
|
"""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
|
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]:
|
def get_project(project_id):
|
||||||
"""Return recent feed events, newest first."""
|
"""GET /api/mc/projects/:id — einzelnes Projekt."""
|
||||||
data = _load_mc_data()
|
data = _load_projects()
|
||||||
feed = data.get("feed", [])
|
return next((p for p in data["projects"] if p["id"] == project_id), None)
|
||||||
return sorted(feed, key=lambda f: f.get("timestamp", ""), reverse=True)[:limit]
|
|
||||||
|
|
||||||
|
def create_project(body):
|
||||||
|
"""POST /api/mc/projects — Projekt erstellen."""
|
||||||
|
data = _load_projects()
|
||||||
|
|
||||||
def _add_feed_event(event: str) -> None:
|
project = {
|
||||||
"""Add a timestamped feed event."""
|
"id": body.get("id") or _new_id("proj").replace("proj-", ""),
|
||||||
data = _load_mc_data()
|
"name": body.get("name", "Neues Projekt"),
|
||||||
feed = data.get("feed", [])
|
"color": body.get("color", "#6366f1"),
|
||||||
feed.append({
|
"description": body.get("description", ""),
|
||||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"status": body.get("status", "active"),
|
||||||
"event": event,
|
"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."""
|
# STATS
|
||||||
data = _load_mc_data()
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
priorities = data.get("priorities", [])
|
|
||||||
tasks = data.get("tasks", [])
|
|
||||||
|
|
||||||
priorities_total = len(priorities)
|
def get_stats():
|
||||||
priorities_done = sum(1 for p in priorities if p.get("done"))
|
"""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")
|
total = len(tasks)
|
||||||
tasks_progress = sum(1 for t in tasks if t.get("status") == "progress")
|
done = len([t for t in tasks if t.get("status") == "done"])
|
||||||
tasks_done = sum(1 for t in tasks if t.get("status") == "done")
|
today = _today()
|
||||||
|
|
||||||
feed = get_feed(limit=5)
|
# Overdue
|
||||||
latest_event = feed[0]["event"] if feed else "No recent activity"
|
overdue = len([t for t in tasks if t.get("due") and t["due"] < today and t.get("status") != "done"])
|
||||||
|
|
||||||
# Health assessment
|
# By priority
|
||||||
if tasks_done == 0 and tasks_backlog == 0 and tasks_progress == 0:
|
by_priority = {"p1": 0, "p2": 0, "p3": 0}
|
||||||
health = "empty"
|
for t in tasks:
|
||||||
elif tasks_progress > 0 and tasks_done > 0:
|
p = t.get("priority", "p2")
|
||||||
health = "healthy"
|
if p in by_priority:
|
||||||
elif tasks_progress > 0:
|
by_priority[p] += 1
|
||||||
health = "active"
|
|
||||||
elif tasks_backlog > 0:
|
# By type
|
||||||
health = "warning"
|
by_type = {"one-time": 0, "daily": 0}
|
||||||
else:
|
for t in tasks:
|
||||||
health = "ok"
|
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 {
|
return {
|
||||||
"priorities_total": priorities_total,
|
"total": total,
|
||||||
"priorities_done": priorities_done,
|
"done": done,
|
||||||
"tasks_backlog": tasks_backlog,
|
"overdue": overdue,
|
||||||
"tasks_progress": tasks_progress,
|
"streak": streak,
|
||||||
"tasks_done": tasks_done,
|
"daily_done_today": daily_done_today,
|
||||||
"latest_feed_event": latest_event,
|
"by_priority": by_priority,
|
||||||
"dashboard_health": health,
|
"by_type": by_type,
|
||||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"by_status": by_status,
|
||||||
|
"project_progress": project_progress,
|
||||||
|
"agent_activity": agent_activity,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ def _write_session_index():
|
|||||||
for s in SESSIONS.values():
|
for s in SESSIONS.values():
|
||||||
if not any(e['session_id'] == s.session_id for e in entries):
|
if not any(e['session_id'] == s.session_id for e in entries):
|
||||||
entries.append(s.compact())
|
entries.append(s.compact())
|
||||||
entries.sort(key=lambda s: s['updated_at'], reverse=True)
|
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')
|
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
@@ -97,7 +97,8 @@ class Session:
|
|||||||
p = SESSION_DIR / f'{sid}.json'
|
p = SESSION_DIR / f'{sid}.json'
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
return None
|
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:
|
def compact(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -156,7 +157,8 @@ def all_sessions():
|
|||||||
# Phase C: try index first for O(1) read; fall back to full scan
|
# Phase C: try index first for O(1) read; fall back to full scan
|
||||||
if SESSION_INDEX_FILE.exists():
|
if SESSION_INDEX_FILE.exists():
|
||||||
try:
|
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
|
# Overlay any in-memory sessions that may be newer than the index
|
||||||
index_map = {s['session_id']: s for s in index}
|
index_map = {s['session_id']: s for s in index}
|
||||||
with LOCK:
|
with LOCK:
|
||||||
@@ -212,7 +214,7 @@ def load_projects() -> list:
|
|||||||
if not PROJECTS_FILE.exists():
|
if not PROJECTS_FILE.exists():
|
||||||
return []
|
return []
|
||||||
try:
|
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:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
280
api/projects.py
Normal file
280
api/projects.py
Normal file
@@ -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),
|
||||||
|
}
|
||||||
109
api/routes.py
109
api/routes.py
@@ -61,6 +61,20 @@ from api import heartbeats as _heartbeats
|
|||||||
import re as _re
|
import re as _re
|
||||||
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
|
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
|
||||||
|
|
||||||
|
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]:
|
def _normalize_host_port(value: str) -> tuple[str, str | None]:
|
||||||
"""Split a host or host:port string into (hostname, port|None).
|
"""Split a host or host:port string into (hostname, port|None).
|
||||||
Handles IPv6 bracket notation, e.g. [::1]:8080."""
|
Handles IPv6 bracket notation, e.g. [::1]:8080."""
|
||||||
@@ -128,12 +142,15 @@ def _check_csrf(handler) -> bool:
|
|||||||
origin = handler.headers.get("Origin", "")
|
origin = handler.headers.get("Origin", "")
|
||||||
referer = handler.headers.get("Referer", "")
|
referer = handler.headers.get("Referer", "")
|
||||||
host = handler.headers.get("Host", "")
|
host = handler.headers.get("Host", "")
|
||||||
|
x_fwd_host = handler.headers.get("X-Forwarded-Host", "")
|
||||||
if not origin and not referer:
|
if not origin and not referer:
|
||||||
return True # non-browser clients (curl, agent) have no Origin
|
return True # non-browser clients (curl, agent) have no Origin
|
||||||
target = origin or referer
|
target = origin or referer
|
||||||
# Extract host:port from origin/referer
|
# Extract host:port from origin/referer
|
||||||
m = _re.match(r"^https?://([^/]+)", target)
|
m = _re.match(r"^https?://([^/]+)", target)
|
||||||
if not m:
|
if not m:
|
||||||
|
import sys
|
||||||
|
print(f"[CSRF DEBUG] no host match in target={target!r}", flush=True, file=sys.stderr)
|
||||||
return False
|
return False
|
||||||
origin_host = m.group(1)
|
origin_host = m.group(1)
|
||||||
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
|
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()
|
origin_value = m.group(0).rstrip('/').lower()
|
||||||
if origin_value in _allowed_public_origins():
|
if origin_value in _allowed_public_origins():
|
||||||
return True
|
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
|
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
|
||||||
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
|
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
|
||||||
# X-Forwarded-Host to the client's original Host header.
|
# 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)
|
allowed_name, allowed_port = _normalize_host_port(allowed)
|
||||||
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
|
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -569,7 +592,13 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
|
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
|
||||||
|
|
||||||
if parsed.path == "/api/projects":
|
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) ──────────────────────────────────────────────
|
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
|
||||||
from api import projects as _projects
|
from api import projects as _projects
|
||||||
@@ -961,6 +990,10 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
return j(handler, {"error": str(e)}, status=500)
|
return j(handler, {"error": str(e)}, status=500)
|
||||||
|
|
||||||
# GET /api/heartbeats — list all + status
|
# 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/"):
|
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
|
||||||
result = _heartbeats.handle_get(parsed.path)
|
result = _heartbeats.handle_get(parsed.path)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -1031,6 +1064,10 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return bad(handler, str(e))
|
return bad(handler, str(e))
|
||||||
s = new_session(workspace=workspace, model=body.get("model"))
|
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}})
|
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||||
|
|
||||||
if parsed.path == "/api/sessions/cleanup":
|
if parsed.path == "/api/sessions/cleanup":
|
||||||
@@ -1052,6 +1089,21 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
s.save()
|
s.save()
|
||||||
return j(handler, {"session": s.compact()})
|
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":
|
if parsed.path == "/api/personality/set":
|
||||||
try:
|
try:
|
||||||
require(body, "session_id")
|
require(body, "session_id")
|
||||||
@@ -1478,6 +1530,10 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
|
|
||||||
if "bot_name" in body:
|
if "bot_name" in body:
|
||||||
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
|
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()
|
auth_enabled_before = is_auth_enabled()
|
||||||
current_cookie = parse_cookie(handler)
|
current_cookie = parse_cookie(handler)
|
||||||
@@ -1658,7 +1714,8 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
# Unassign all sessions that belonged to this project
|
# Unassign all sessions that belonged to this project
|
||||||
if SESSION_INDEX_FILE.exists():
|
if SESSION_INDEX_FILE.exists():
|
||||||
try:
|
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:
|
for entry in index:
|
||||||
if entry.get("project_id") == body["project_id"]:
|
if entry.get("project_id") == body["project_id"]:
|
||||||
try:
|
try:
|
||||||
@@ -1737,6 +1794,12 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# POST /api/heartbeats — create heartbeat
|
# 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/"):
|
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
|
||||||
result = _heartbeats.handle_post(parsed.path, body)
|
result = _heartbeats.handle_post(parsed.path, body)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -1780,6 +1843,10 @@ def handle_put(handler, parsed) -> bool:
|
|||||||
agent_id = parsed.path.split("/")[-2]
|
agent_id = parsed.path.split("/")[-2]
|
||||||
return j(handler, _agents.update_agent_memory(agent_id, body.get("content", "")))
|
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
|
return False # 404
|
||||||
|
|
||||||
|
|
||||||
@@ -1953,6 +2020,8 @@ def _handle_sse_stream(handler, parsed):
|
|||||||
return j(handler, {"error": "stream not found"}, status=404)
|
return j(handler, {"error": "stream not found"}, status=404)
|
||||||
handler.send_response(200)
|
handler.send_response(200)
|
||||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
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("Cache-Control", "no-cache")
|
||||||
handler.send_header("X-Accel-Buffering", "no")
|
handler.send_header("X-Accel-Buffering", "no")
|
||||||
handler.send_header("Connection", "keep-alive")
|
handler.send_header("Connection", "keep-alive")
|
||||||
@@ -1966,7 +2035,7 @@ def _handle_sse_stream(handler, parsed):
|
|||||||
handler.wfile.flush()
|
handler.wfile.flush()
|
||||||
continue
|
continue
|
||||||
_sse(handler, event, data)
|
_sse(handler, event, data)
|
||||||
if event in ("stream_end", "error", "cancel"):
|
if event in ("stream_end", "error", "cancel", "apperror"):
|
||||||
break
|
break
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
pass
|
pass
|
||||||
@@ -3232,6 +3301,12 @@ def _handle_skill_save(handler, body):
|
|||||||
if category and ("/" in category or ".." in category):
|
if category and ("/" in category or ".." in category):
|
||||||
return bad(handler, "Invalid category")
|
return bad(handler, "Invalid category")
|
||||||
from tools.skills_tool import SKILLS_DIR
|
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:
|
if category:
|
||||||
skill_dir = SKILLS_DIR / category / skill_name
|
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"]})
|
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):
|
def _handle_memory_write(handler, body):
|
||||||
try:
|
try:
|
||||||
require(body, "section", "content")
|
require(body, "section", "content")
|
||||||
|
|||||||
@@ -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):
|
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]."""
|
"""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)
|
q = STREAMS.get(stream_id)
|
||||||
|
print(f'[DEBUG streaming] STREAMS keys={list(STREAMS.keys())}', flush=True)
|
||||||
if q is None:
|
if q is None:
|
||||||
|
print(f'[DEBUG streaming] queue is None for stream_id={stream_id}', flush=True)
|
||||||
return
|
return
|
||||||
|
print(f'[DEBUG streaming] queue found, agent={agent}', flush=True)
|
||||||
s = None
|
s = None
|
||||||
_rt = {}
|
_rt = {}
|
||||||
old_cwd = None
|
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
|
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
|
||||||
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
|
_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):
|
def on_token(text):
|
||||||
nonlocal _token_sent
|
nonlocal _token_sent, _token_buf_timer, _token_buf_closed
|
||||||
if text is None:
|
if text is None:
|
||||||
|
# Flush any remaining buffered tokens, then mark closed
|
||||||
|
_flush_token_buf()
|
||||||
|
_token_buf_closed = True
|
||||||
return # end-of-stream sentinel
|
return # end-of-stream sentinel
|
||||||
_token_sent = True
|
_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):
|
def on_reasoning(text):
|
||||||
nonlocal _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':
|
if isinstance(_rm, dict) and _rm.get('role') == 'assistant':
|
||||||
_rm['reasoning'] = _reasoning_text
|
_rm['reasoning'] = _reasoning_text
|
||||||
break
|
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()
|
s.save()
|
||||||
# Sync to state.db for /insights (opt-in setting)
|
# Sync to state.db for /insights (opt-in setting)
|
||||||
try:
|
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['context_length'] = getattr(_cc, 'context_length', 0) or 0
|
||||||
usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 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
|
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())
|
# (reasoning trace already attached + saved above, before s.save())
|
||||||
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
|
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
|
||||||
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
|
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ def _migrate_global_workspaces() -> list:
|
|||||||
if not _GLOBAL_WS_FILE.exists():
|
if not _GLOBAL_WS_FILE.exists():
|
||||||
return []
|
return []
|
||||||
try:
|
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)
|
cleaned = _clean_workspace_list(raw)
|
||||||
if len(cleaned) != len(raw):
|
if len(cleaned) != len(raw):
|
||||||
# Rewrite the cleaned version so future reads are already clean
|
# Rewrite the cleaned version so future reads are already clean
|
||||||
@@ -155,7 +156,8 @@ def load_workspaces() -> list:
|
|||||||
ws_file = _workspaces_file()
|
ws_file = _workspaces_file()
|
||||||
if ws_file.exists():
|
if ws_file.exists():
|
||||||
try:
|
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)
|
cleaned = _clean_workspace_list(raw)
|
||||||
if len(cleaned) != len(raw):
|
if len(cleaned) != len(raw):
|
||||||
# Persist the cleaned version so stale entries don't keep reappearing
|
# Persist the cleaned version so stale entries don't keep reappearing
|
||||||
|
|||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
server.py
21
server.py
@@ -60,6 +60,27 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
})
|
})
|
||||||
print(f'[webui] {record}', flush=True)
|
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:
|
def do_GET(self) -> None:
|
||||||
self._req_t0 = time.time()
|
self._req_t0 = time.time()
|
||||||
try:
|
try:
|
||||||
|
|||||||
380
static/activity-tree.js
Normal file
380
static/activity-tree.js
Normal file
File diff suppressed because one or more lines are too long
528
static/activity-tree.ts
Normal file
528
static/activity-tree.ts
Normal file
@@ -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<string, { emoji: string; name: string; tier: 1|2|3 }> = {
|
||||||
|
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<ActivityNode>): 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<string, any>;
|
||||||
|
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<ActivityToolCall>): 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
196
static/awesome-design-md_README.md
Normal file
196
static/awesome-design-md_README.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<a href="https://github.com/VoltAgent/voltagent">
|
||||||
|
<img width="1500" alt="claude-skills" src="https://github.com/user-attachments/assets/d012a0d2-cec3-4630-ba5e-acc339dbe6cf" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>Curated collection of DESIGN.md files inspired by developer focused websites.</strong>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://awesome.re)
|
||||||
|

|
||||||
|
[](https://github.com/VoltAgent/awesome-design-md)
|
||||||
|
[](https://s.voltagent.dev/discord)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# 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.
|
||||||
@@ -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})});
|
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;
|
S.session.model=selectedModel;
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
|
||||||
syncTopbar();
|
syncTopbar();
|
||||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||||
if(typeof _checkProviderMismatch==='function'){
|
if(typeof _checkProviderMismatch==='function'){
|
||||||
|
|||||||
876
static/boot.ts
Normal file
876
static/boot.ts
Normal file
@@ -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<any>;
|
||||||
|
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<void>;
|
||||||
|
declare function loadCommands(): Promise<void>;
|
||||||
|
declare function loadWorkspaceList(): Promise<void>;
|
||||||
|
declare function loadOnboardingWizard(): Promise<boolean>;
|
||||||
|
declare function syncTopbar(): void;
|
||||||
|
declare function checkInflightOnBoot(sessionId: string): Promise<void>;
|
||||||
|
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<event.results.length;i++){
|
||||||
|
const t=event.results[i][0].transcript;
|
||||||
|
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
||||||
|
else{ interim+=t; }
|
||||||
|
}
|
||||||
|
ta.value=_prefix+(final||interim);
|
||||||
|
autoResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend=()=>{
|
||||||
|
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=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
|
||||||
|
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -1,379 +1,400 @@
|
|||||||
// ── Slash commands ──────────────────────────────────────────────────────────
|
(() => {
|
||||||
// Commands are loaded dynamically from GET /api/commands (Hermes COMMAND_REGISTRY).
|
const PASSTHROUGH = [
|
||||||
// Tier-2 Agent commands and passthrough handlers are added client-side.
|
"retry",
|
||||||
// Each command either runs locally or is forwarded as a message to the agent.
|
"undo",
|
||||||
|
"title",
|
||||||
let COMMANDS=[]; // Loaded async via loadCommands()
|
"branch",
|
||||||
|
"stop",
|
||||||
// Map Hermes passthrough command names to their fn.
|
"background",
|
||||||
// These commands are forwarded to the agent as-is.
|
"btw",
|
||||||
const _PASSTHROUGH=['retry','undo','title','branch','stop','background','btw',
|
"queue",
|
||||||
'queue','status','profile','resume','snapshot','rollback','provider',
|
"status",
|
||||||
'yolo','reasoning','fast','voice','reload','reload-mcp','cron','browser',
|
"profile",
|
||||||
'plugins','insights','platforms','debug','update','image','inbox'];
|
"resume",
|
||||||
|
"snapshot",
|
||||||
function _fnFor(name){
|
"rollback",
|
||||||
if(name==='help'||name==='commands') return cmdHelp;
|
"provider",
|
||||||
if(name==='clear') return cmdClear;
|
"yolo",
|
||||||
if(name==='compact'||name==='compress') return cmdCompact;
|
"reasoning",
|
||||||
if(name==='model') return cmdModel;
|
"fast",
|
||||||
if(name==='workspace') return cmdWorkspace;
|
"voice",
|
||||||
if(name==='new') return cmdNew;
|
"reload",
|
||||||
if(name==='usage') return cmdUsage;
|
"reload-mcp",
|
||||||
if(name==='theme') return cmdTheme;
|
"cron",
|
||||||
if(name==='skills') return cmdSkills;
|
"browser",
|
||||||
if(name==='personality') return cmdPersonality;
|
"plugins",
|
||||||
if(_PASSTHROUGH.includes(name)) return cmdPassthrough;
|
"insights",
|
||||||
// Fallback: passthrough unknown commands so new Hermes commands work without JS changes
|
"platforms",
|
||||||
return cmdPassthrough;
|
"debug",
|
||||||
}
|
"update",
|
||||||
|
"image",
|
||||||
/**
|
"inbox"
|
||||||
* Fetch commands from Hermes COMMAND_REGISTRY and merge with WebUI-specific commands.
|
];
|
||||||
* Called once at boot time.
|
let COMMANDS = [];
|
||||||
*/
|
const AGENT_INFO = {
|
||||||
async function loadCommands(){
|
"sunflower": { emoji: "\u{1F33B}", name: "Sunflower", file: "sunflower/soul.md", domain: "Finance, Wealth & Subscriptions" },
|
||||||
try{
|
"lotus": { emoji: "\u{1F9D7}", name: "Lotus", file: "lotus/soul.md", domain: "Health, Fitness & Recovery" },
|
||||||
const data=await api('/api/commands');
|
"forget-me-not": { emoji: "\u{1F33C}", name: "Forget-me-not", file: "forget-me-not/soul.md", domain: "Calendar, Time & Social" },
|
||||||
if(data.error) throw new Error(data.error);
|
"iris": { emoji: "\u2695\uFE0F", name: "Iris", file: "iris/soul.md", domain: "Career, Learning & Focus" },
|
||||||
const cats=data.categories||{};
|
"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" },
|
||||||
// Flatten all categories into COMMANDS
|
"root": { emoji: "\u{1F333}", name: "Root", file: "root/soul.md", domain: "DevOps, Logs & System Health" },
|
||||||
const merged=[];
|
"back": { emoji: "\u{1F339}", name: "Rose", file: "rose/soul.md", domain: "Orchestrator (return from agent)" }
|
||||||
for(const [catName,cmds] of Object.entries(cats)){
|
};
|
||||||
for(const c of cmds){
|
function _fnFor(name) {
|
||||||
merged.push({name:c.name, desc:c.desc, arg:c.arg||'(none)',
|
if (name === "help" || name === "commands") return cmdHelp;
|
||||||
aliases:c.aliases||[], fn:_fnFor(c.name)});
|
if (name === "clear") return cmdClear;
|
||||||
}
|
if (name === "compact" || name === "compress") return cmdCompact;
|
||||||
}
|
if (name === "model") return cmdModel;
|
||||||
|
if (name === "workspace") return cmdWorkspace;
|
||||||
// ── Tier-2 Domain Agents (WebUI-specific, override API entries) ──
|
if (name === "new") return cmdNew;
|
||||||
// Dedup: remove any API entries that would clash with Tier-2 agents
|
if (name === "usage") return cmdUsage;
|
||||||
const _agentNames=['sunflower','lotus','forget-me-not','iris','ivy',
|
if (name === "theme") return cmdTheme;
|
||||||
'dandelion','root','back','inbox'];
|
if (name === "skills") return cmdSkills;
|
||||||
// Remove API entries for agent names (they may already be in the registry
|
if (name === "personality") return cmdPersonality;
|
||||||
// from the API if agents registered themselves as commands there)
|
if (PASSTHROUGH.includes(name)) return cmdPassthrough;
|
||||||
const filtered=merged.filter(c=>!_agentNames.includes(c.name));
|
return cmdPassthrough;
|
||||||
// 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=[];
|
|
||||||
}
|
}
|
||||||
}
|
async function loadCommands() {
|
||||||
|
try {
|
||||||
function parseCommand(text){
|
const data = await api("/api/commands");
|
||||||
if(!text.startsWith('/'))return null;
|
if (data.error) throw new Error(data.error);
|
||||||
const parts=text.slice(1).split(/\s+/);
|
const cats = data.categories || {};
|
||||||
const name=parts[0].toLowerCase();
|
const merged = [];
|
||||||
const args=parts.slice(1).join(' ').trim();
|
for (const [, cmds] of Object.entries(cats)) {
|
||||||
return {name,args};
|
for (const c of cmds) {
|
||||||
}
|
merged.push({ name: c.name, desc: c.desc, arg: c.arg || "(none)", aliases: c.aliases || [], fn: _fnFor(c.name) });
|
||||||
|
}
|
||||||
function executeCommand(text){
|
}
|
||||||
const parsed=parseCommand(text);
|
const agentNames = ["sunflower", "lotus", "forget-me-not", "iris", "ivy", "dandelion", "root", "back", "inbox"];
|
||||||
if(!parsed)return false;
|
const filtered = merged.filter((c) => !agentNames.includes(c.name));
|
||||||
const cmd=COMMANDS.find(c=>c.name===parsed.name);
|
filtered.push(
|
||||||
if(!cmd)return false;
|
{ name: "sunflower", desc: "\u{1F33B} Finance, Wealth & Subscriptions", fn: cmdAgent, arg: "message", aliases: [] },
|
||||||
cmd.fn(parsed.args);
|
{ name: "lotus", desc: "\u{1F9D7} Health, Fitness & Recovery", fn: cmdAgent, arg: "message", aliases: [] },
|
||||||
return true;
|
{ 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: [] },
|
||||||
function getMatchingCommands(prefix){
|
{ name: "dandelion", desc: "\u{1F6E1} Communication Triage & Gatekeeping", fn: cmdAgent, arg: "message", aliases: [] },
|
||||||
const q=prefix.toLowerCase();
|
{ name: "root", desc: "\u{1F333} DevOps, Logs & System Health", fn: cmdAgent, arg: "message", aliases: [] },
|
||||||
return COMMANDS.filter(c=>{
|
{ name: "back", desc: "\u{1F339} Return to Rose (orchestrator)", fn: cmdAgent, arg: "message", aliases: [] }
|
||||||
if(c.name.startsWith(q)) return true;
|
);
|
||||||
// Also match aliases
|
COMMANDS = filtered;
|
||||||
if(c.aliases&&c.aliases.some(a=>a.startsWith(q))) return true;
|
} catch (e) {
|
||||||
return false;
|
console.warn("[commands] Failed to load from API, using fallback:", e instanceof Error ? e.message : String(e));
|
||||||
});
|
COMMANDS = [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// ── Generic passthrough: send command text directly to agent ────────────
|
function parseCommand(text) {
|
||||||
|
if (!text.startsWith("/")) return null;
|
||||||
function cmdPassthrough(args){
|
const parts = text.slice(1).split(/\s+/);
|
||||||
const parsed=parseCommand($('msg').value);
|
const name = parts[0].toLowerCase();
|
||||||
if(!parsed)return;
|
const args = parts.slice(1).join(" ").trim();
|
||||||
// Forward the raw command to the agent as a regular message
|
return { name, args };
|
||||||
$('msg').value=$('msg').value; // keep as-is
|
}
|
||||||
send();
|
function executeCommand(text) {
|
||||||
}
|
const parsed = parseCommand(text);
|
||||||
|
if (!parsed) return false;
|
||||||
// ── Command handlers ────────────────────────────────────────────────────
|
const cmd = COMMANDS.find((c) => c.name === parsed.name);
|
||||||
|
if (!cmd) return false;
|
||||||
function cmdHelp(){
|
cmd.fn(parsed.args);
|
||||||
// Infer categories from command names (backwards-compatible with hardcoded categories)
|
return true;
|
||||||
const categories={'Session':[],'Configuration':[],'Tools & Skills':[],'Info':[],'Agents':[]};
|
}
|
||||||
COMMANDS.forEach(c=>{
|
function getMatchingCommands(prefix) {
|
||||||
let cat='Info';
|
const q = prefix.toLowerCase();
|
||||||
if(['new','clear','compact','compress','retry','undo','title','branch',
|
return COMMANDS.filter((c) => {
|
||||||
'stop','background','btw','queue','status','profile','resume',
|
if (c.name.startsWith(q)) return true;
|
||||||
'snapshot','rollback'].includes(c.name)) cat='Session';
|
if (c.aliases && c.aliases.some((a) => a.startsWith(q))) return true;
|
||||||
else if(['model','provider','personality','workspace','theme','yolo',
|
return false;
|
||||||
'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}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const msg={role:'assistant',content:'Available commands:\n'+lines.join('\n')};
|
function cmdPassthrough(_args) {
|
||||||
S.messages.push(msg);
|
const msgEl = $("msg");
|
||||||
renderMessages();
|
if (!msgEl) return;
|
||||||
showToast('Type / to see commands');
|
const parsed = parseCommand(msgEl.value);
|
||||||
}
|
if (!parsed) return;
|
||||||
|
send();
|
||||||
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 <model_name>');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;}
|
function cmdHelp() {
|
||||||
sel.value=match;
|
const categories = { "Session": [], "Configuration": [], "Tools & Skills": [], "Info": [], "Agents": [] };
|
||||||
await sel.onchange();
|
COMMANDS.forEach((c) => {
|
||||||
showToast(t('switched_to')+match);
|
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";
|
||||||
async function cmdWorkspace(args){
|
else if (["skills", "cron", "browser", "plugins"].includes(c.name)) cat = "Tools & Skills";
|
||||||
if(!args){showToast('Usage: /workspace <name>');return;}
|
else if (["sunflower", "lotus", "forget-me-not", "iris", "ivy", "dandelion", "root", "back", "inbox"].includes(c.name)) cat = "Agents";
|
||||||
try{
|
if (!categories[cat]) categories[cat] = [];
|
||||||
const data=await api('/api/workspaces');
|
categories[cat].push(c);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for(const [cat, items] of Object.entries(byCategory).sort()){
|
for (const [cat, cmds] of Object.entries(categories)) {
|
||||||
lines.push(`**${cat}**`);
|
if (!cmds.length) continue;
|
||||||
items.forEach(s => {
|
lines.push(`
|
||||||
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
|
**${cat}**`);
|
||||||
lines.push(` \`${s.name}\`${desc}`);
|
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
|
const msg = { role: "assistant", content: "Available commands:\n" + lines.join("\n") };
|
||||||
? `Skills matching "${args}" (${skills.length}):\n\n`
|
S.messages.push(msg);
|
||||||
: `Available skills (${skills.length}):\n\n`;
|
|
||||||
S.messages.push({role:'assistant', content: header + lines.join('\n')});
|
|
||||||
renderMessages();
|
renderMessages();
|
||||||
}catch(e){
|
showToast(t("type_slash"));
|
||||||
showToast('Failed to load skills: '+e.message);
|
|
||||||
}
|
}
|
||||||
}
|
function cmdClear() {
|
||||||
|
if (!S.session) return;
|
||||||
async function cmdPersonality(args){
|
S.messages = [];
|
||||||
if(!S.session){showToast(t('no_active_session'));return;}
|
S.toolCalls = [];
|
||||||
if(!args){
|
clearLiveToolCards();
|
||||||
try{
|
renderMessages();
|
||||||
const data=await api('/api/personalities');
|
const emptyState = $("emptyState");
|
||||||
if(!data.personalities||!data.personalities.length){
|
if (emptyState) emptyState.style.display = "";
|
||||||
showToast(t('no_personalities'));
|
showToast(t("conversation_cleared"));
|
||||||
|
}
|
||||||
|
async function cmdModel(args) {
|
||||||
|
if (!args) {
|
||||||
|
showToast("Usage: /model <model_name>");
|
||||||
|
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 <name>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api("/api/workspaces");
|
||||||
|
const q = args.toLowerCase();
|
||||||
|
const ws = (data.workspaces || []).find(
|
||||||
|
(w) => (w.name || "").toLowerCase().includes(q) || w.path.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
if (!ws) {
|
||||||
|
showToast('No workspace matching "' + args + '"');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
|
if (typeof switchToWorkspace === "function") await switchToWorkspace(ws.path, ws.name || ws.path);
|
||||||
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+'\n\nSwitch with: /personality <name>'});
|
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();
|
renderMessages();
|
||||||
}catch(e){showToast(t('personalities_load_failed'));}
|
} catch (e) {
|
||||||
return;
|
showToast("Failed to load skills: " + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const name=args.trim();
|
async function cmdPersonality(args) {
|
||||||
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
|
if (!S.session) {
|
||||||
try{
|
showToast(t("no_active_session"));
|
||||||
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
|
return;
|
||||||
showToast(t('personality_cleared'));
|
}
|
||||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
if (!args) {
|
||||||
return;
|
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 <name>" });
|
||||||
|
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{
|
function cmdAgent(_args) {
|
||||||
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
|
const msgEl = $("msg");
|
||||||
showToast(t('personality_set')+name);
|
if (!msgEl) return;
|
||||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
const parsed = parseCommand(msgEl.value);
|
||||||
}
|
if (!parsed) return;
|
||||||
|
const agentKey = parsed.name;
|
||||||
// ── Tier-2 Agent Command Handler ────────────────────────────────────────
|
const info = AGENT_INFO[agentKey];
|
||||||
|
if (!info) {
|
||||||
const AGENT_INFO={
|
showToast("Unknown agent: " + agentKey);
|
||||||
'sunflower': {emoji:'🌻', name:'Sunflower', file:'sunflower/soul.md', domain:'Finance, Wealth & Subscriptions'},
|
return;
|
||||||
'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'},
|
const userMsg = _args || "";
|
||||||
'iris': {emoji:'⚜️', name:'Iris', file:'iris/soul.md', domain:'Career, Learning & Focus'},
|
const contextMsg = `[Agent Switch: ${info.emoji} ${info.name}]
|
||||||
'ivy': {emoji:'🌿', name:'Ivy', file:'ivy/soul.md', domain:'Smart Home & Environment'},
|
Load ~/.hermes/agents/${info.file} and handle this request as ${info.name} (${info.domain}).${userMsg ? "\n\nUser message: " + userMsg : ""}`;
|
||||||
'dandelion': {emoji:'🛡', name:'Dandelion', file:'dandelion/soul.md', domain:'Communication Triage & Gatekeeping'},
|
msgEl.value = contextMsg;
|
||||||
'root': {emoji:'🌳', name:'Root', file:'root/soul.md', domain:'DevOps, Logs & System Health'},
|
send();
|
||||||
'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<matches.length;i++){
|
|
||||||
const c=matches[i];
|
|
||||||
const el=document.createElement('div');
|
|
||||||
el.className='cmd-item';
|
|
||||||
el.dataset.idx=i;
|
|
||||||
const usage=c.arg&&c.arg!=='(none)'?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
|
|
||||||
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
|
||||||
el.onmousedown=(e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
$('msg').value='/'+c.name+(c.arg&&c.arg!=='(none)'?' ':'');
|
|
||||||
hideCmdDropdown();
|
|
||||||
$('msg').focus();
|
|
||||||
};
|
|
||||||
dd.appendChild(el);
|
|
||||||
}
|
}
|
||||||
dd.classList.add('open');
|
let _cmdSelectedIdx = -1;
|
||||||
}
|
function showCmdDropdown(matches) {
|
||||||
|
const dd = $("cmdDropdown");
|
||||||
function hideCmdDropdown(){
|
if (!dd) return;
|
||||||
const dd=$('cmdDropdown');
|
dd.innerHTML = "";
|
||||||
if(dd)dd.classList.remove('open');
|
_cmdSelectedIdx = -1;
|
||||||
_cmdSelectedIdx=-1;
|
for (let i = 0; i < matches.length; i++) {
|
||||||
}
|
const c = matches[i];
|
||||||
|
const el = document.createElement("div");
|
||||||
function navigateCmdDropdown(dir){
|
el.className = "cmd-item";
|
||||||
const dd=$('cmdDropdown');
|
el.dataset.idx = String(i);
|
||||||
if(!dd)return;
|
const usage = c.arg && c.arg !== "(none)" ? ` <span class="cmd-item-arg">${esc(c.arg)}</span>` : "";
|
||||||
const items=dd.querySelectorAll('.cmd-item');
|
el.innerHTML = `<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||||
if(!items.length)return;
|
el.addEventListener("mousedown", (e) => {
|
||||||
items.forEach(el=>el.classList.remove('selected'));
|
e.preventDefault();
|
||||||
_cmdSelectedIdx+=dir;
|
const msgEl2 = $("msg");
|
||||||
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
|
if (msgEl2) {
|
||||||
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
|
msgEl2.value = "/" + c.name + (c.arg && c.arg !== "(none)" ? " " : "");
|
||||||
items[_cmdSelectedIdx].classList.add('selected');
|
msgEl2.focus();
|
||||||
}
|
}
|
||||||
|
hideCmdDropdown();
|
||||||
function selectCmdDropdownItem(){
|
});
|
||||||
const dd=$('cmdDropdown');
|
dd.appendChild(el);
|
||||||
if(!dd)return;
|
}
|
||||||
const items=dd.querySelectorAll('.cmd-item');
|
dd.classList.add("open");
|
||||||
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
|
|
||||||
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
|
|
||||||
} else if(items.length===1){
|
|
||||||
items[0].onmousedown({preventDefault:()=>{}});
|
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
358
static/commands.ts
Normal file
358
static/commands.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/* commands.ts — Slash commands (/command) for the WebUI */
|
||||||
|
/// <reference path="./global.d.ts" />
|
||||||
|
|
||||||
|
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<string, { emoji: string; name: string; file: string; domain: string }> = {
|
||||||
|
'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<void> {
|
||||||
|
try {
|
||||||
|
const data = await api('/api/commands') as { error?: string; categories?: Record<string, Array<{ name: string; desc: string; arg?: string; aliases?: string[] }>> };
|
||||||
|
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<string, CmdDef[]> = { '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<void> {
|
||||||
|
if (!args) { showToast('Usage: /model <model_name>'); 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<void> {
|
||||||
|
if (!args) { showToast('Usage: /workspace <name>'); 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, typeof skills> = {};
|
||||||
|
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<void> {
|
||||||
|
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 <name>' });
|
||||||
|
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)' ? ` <span class="cmd-item-arg">${esc(c.arg)}</span>` : '';
|
||||||
|
el.innerHTML = `<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||||
|
el.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 };
|
||||||
389
static/global.d.ts
vendored
Normal file
389
static/global.d.ts
vendored
Normal file
@@ -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<string>;
|
||||||
|
_dirCache?: Record<string, WsEntry[]>;
|
||||||
|
_profileDefaultWorkspace?: string;
|
||||||
|
lastUsage?: Record<string, unknown>;
|
||||||
|
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<HTMLOptGroupElement | HTMLOptionElement>;
|
||||||
|
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<HTMLOptionElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||||
|
removeEventListener<K extends keyof HTMLElementEventMap>(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<void>;
|
||||||
|
matches?(selector: string): boolean;
|
||||||
|
replaceWith(...nodes: any[]): void;
|
||||||
|
_t?: ReturnType<typeof setTimeout>;
|
||||||
|
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<any>;
|
||||||
|
showOpenFilePicker?: (options?: any) => Promise<any>;
|
||||||
|
_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<string, unknown>;
|
||||||
|
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<string>;
|
||||||
|
declare const MD_EXTS: Set<string>;
|
||||||
|
declare const DOWNLOAD_EXTS: Set<string>;
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
exitFullscreen?: () => Promise<void>;
|
||||||
|
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<T extends Node>(node: T): T;
|
||||||
|
removeChild<T extends Node>(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<K extends keyof HTMLElementEventMap>(type: K, listener: (this: Element, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||||
|
removeEventListener<K extends keyof HTMLElementEventMap>(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<any>;
|
||||||
|
declare function syncOnboardingProvider(value: string): void;
|
||||||
|
declare function syncOnboardingWorkspaceSelect(value: string): void;
|
||||||
|
declare function fileIcon(name: string, type: string): string;
|
||||||
|
declare function loadOnboardingWizard(): Promise<boolean>;
|
||||||
|
declare function nextOnboardingStep(): Promise<void>;
|
||||||
|
declare function prevOnboardingStep(): void;
|
||||||
|
declare function skipOnboarding(): Promise<void>;
|
||||||
|
declare function renderSessionList(): Promise<void>;
|
||||||
|
declare function newSession(flash?: boolean, agentOverride?: string): Promise<void>;
|
||||||
|
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<void>;
|
||||||
|
declare function loadSession(sid: string): Promise<void>;
|
||||||
|
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<boolean>;
|
||||||
|
declare function showPromptDialog(opts?: DialogOpts): Promise<string | null>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, ActivityNode>;
|
||||||
|
stats: MCStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCFilter {
|
||||||
|
agent?: string;
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare function initActivityTree(): ActivityTree;
|
||||||
|
declare function createMockActivityTree(): ActivityTree;
|
||||||
|
declare function formatElapsed(ms: number): string;
|
||||||
2927
static/i18n.js
2927
static/i18n.js
File diff suppressed because it is too large
Load Diff
1046
static/i18n.ts
Normal file
1046
static/i18n.ts
Normal file
File diff suppressed because it is too large
Load Diff
145
static/icons.js
145
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.
|
const LI_PATHS = {
|
||||||
// Usage: li('folder') → returns a ready-to-embed SVG string
|
// Navigation tabs
|
||||||
// The returned SVG uses display:inline-block + vertical-align so it sits
|
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||||
// neatly beside text in both HTML templates and innerHTML assignments.
|
"calendar": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||||
|
"layers": '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
|
||||||
const LI_PATHS = {
|
"lightbulb": '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
|
||||||
// Navigation tabs
|
"folder": '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
"list-todo": '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
||||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
// Editing / actions
|
||||||
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
|
"pencil": '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||||
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
|
"save": '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
|
||||||
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
"chevron-right": '<polyline points="9 18 15 12 9 6"/>',
|
||||||
// Editing / actions
|
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||||
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
"upload": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
"braces": '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
|
||||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
"trash-2": '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
"settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
"alert-triangle": '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
"refresh-cw": '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||||
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
|
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||||||
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
"lock": '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
"star": '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
"x": '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||||
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
"square": '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
|
||||||
'check': '<polyline points="20 6 9 17 4 12"/>',
|
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
"arrow-up": '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
|
||||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
"arrow-right": '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
|
||||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
"loader": '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
|
||||||
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
|
"pause": '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
||||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
// Tool icons
|
||||||
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
|
"terminal": '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||||
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
|
"file-text": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||||
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
|
"file-pen": '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
|
||||||
'pause': '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
"search": '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||||
// Tool icons
|
"globe": '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||||
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
"play": '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
"wrench": '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||||
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
|
"brain": '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
|
||||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
"book-open": '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
"clock": '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||||
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
"bot": '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
|
||||||
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
"eye": '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
|
"shuffle": '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
|
||||||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
"paperclip": '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
|
||||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
"copy": '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||||
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
|
"rotate-ccw": '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
|
||||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
"user": '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
|
||||||
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
|
// File-type icons
|
||||||
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
|
"image": '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
"file-code": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
|
||||||
'rotate-ccw': '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
|
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||||
'user': '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
|
// Suggestion buttons
|
||||||
// File-type icons
|
"clipboard-list": '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
|
||||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
"map": '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>'
|
||||||
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
|
};
|
||||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
function li(name, size = 16) {
|
||||||
// Suggestion buttons
|
const p = LI_PATHS[name];
|
||||||
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
|
if (!p) {
|
||||||
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
|
console.warn("li(): unknown icon", name);
|
||||||
};
|
return "";
|
||||||
|
}
|
||||||
/**
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
|
||||||
* Returns a Lucide SVG string for the given icon name.
|
}
|
||||||
* @param {string} name – key in LI_PATHS (e.g. 'folder', 'trash-2')
|
window.li = li;
|
||||||
* @param {number} size – width/height in px (default 16)
|
})();
|
||||||
* @returns {string} SVG element string ready for innerHTML
|
//# sourceMappingURL=icons.js.map
|
||||||
*/
|
|
||||||
function li(name, size = 16) {
|
|
||||||
const p = LI_PATHS[name];
|
|
||||||
if (!p) { console.warn('li(): unknown icon', name); return ''; }
|
|
||||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
|
|
||||||
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
|
|
||||||
+ `stroke-linejoin="round" aria-hidden="true" `
|
|
||||||
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
|
|
||||||
}
|
|
||||||
|
|||||||
79
static/icons.ts
Normal file
79
static/icons.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
// Navigation tabs
|
||||||
|
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||||
|
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||||
|
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
|
||||||
|
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
|
||||||
|
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||||
|
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
||||||
|
// Editing / actions
|
||||||
|
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||||
|
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||||
|
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||||
|
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||||
|
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||||
|
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||||
|
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
|
||||||
|
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||||
|
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||||
|
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||||
|
'check': '<polyline points="20 6 9 17 4 12"/>',
|
||||||
|
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
|
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||||
|
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||||
|
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
|
||||||
|
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||||
|
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
|
||||||
|
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
|
||||||
|
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
|
||||||
|
'pause': '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
||||||
|
// Tool icons
|
||||||
|
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||||
|
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||||
|
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
|
||||||
|
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||||
|
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||||
|
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||||
|
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||||
|
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
|
||||||
|
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||||
|
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||||
|
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
|
||||||
|
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
|
||||||
|
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
|
||||||
|
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||||
|
'rotate-ccw': '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
|
||||||
|
'user': '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
|
||||||
|
// File-type icons
|
||||||
|
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||||
|
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
|
||||||
|
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||||
|
// Suggestion buttons
|
||||||
|
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
|
||||||
|
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
|
||||||
|
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
|
||||||
|
+ `stroke-linejoin="round" aria-hidden="true" `
|
||||||
|
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { li, LI_PATHS };
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Hermes</title>
|
<title>Hermes</title>
|
||||||
|
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
||||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||||
@@ -26,8 +27,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<button class="nav-tab" data-panel="agents" data-label="Agents" onclick="switchPanel('agents')" title="Rose + Tier-2 Agents" data-i18n-title="tab_agents"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></button>
|
<button class="nav-tab" data-panel="agents" data-label="Agents" onclick="switchPanel('agents')" title="Rose + Tier-2 Agents" data-i18n-title="tab_agents"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></button>
|
||||||
|
<button class="nav-tab" data-panel="activity" data-label="Activity" onclick="switchPanel('activity')" title="Agent Activity Tree" id="navActivity"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></button>
|
||||||
<button class="nav-tab" data-panel="projects" data-label="Projects" onclick="switchPanel('projects')" title="Projects & Tasks" data-i18n-title="tab_projects"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Chat panel -->
|
<!-- Chat panel -->
|
||||||
<div class="panel-view active" id="panelChat">
|
<div class="panel-view active" id="panelChat">
|
||||||
@@ -43,46 +43,60 @@
|
|||||||
<!-- Tasks (cron) panel -->
|
<!-- Tasks (cron) panel -->
|
||||||
<div class="panel-view" id="panelTasks">
|
<div class="panel-view" id="panelTasks">
|
||||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||||
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
|
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Geplante Aufgaben</div>
|
||||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
|
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">Neue Aufgabe</span></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Create job form (hidden by default) -->
|
<!-- Create job form (hidden by default) -->
|
||||||
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||||
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
<input id="cronFormName" placeholder="Aufgabenname (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||||
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
<input id="cronFormSchedule" placeholder="Zeitplan (z.B. '0 9 * * *' oder 'every 1h')" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||||
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
|
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (muss eigenständig funktionieren)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
|
||||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||||
<option value="local">Local (save output only)</option>
|
<option value="local">Lokal (nur speichern)</option>
|
||||||
<option value="discord">Discord</option>
|
<option value="discord">Discord</option>
|
||||||
<option value="telegram">Telegram</option>
|
<option value="telegram">Telegram</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="skill-picker-wrap" style="margin-bottom:8px">
|
<div class="skill-picker-wrap" style="margin-bottom:8px">
|
||||||
<input id="cronFormSkillSearch" placeholder="Add skills (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
|
<input id="cronFormSkillSearch" placeholder="Skills hinzufügen (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
|
||||||
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
|
<div id="cronFormSkillDropdown" class="skill-picker-dropdown"></div>
|
||||||
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
|
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Aufgabe erstellen</button>
|
||||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
|
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Laden...</div></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Skills panel -->
|
<!-- Skills panel -->
|
||||||
<div class="panel-view" id="panelSkills">
|
<div class="panel-view" id="panelSkills">
|
||||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:4px">
|
||||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
<div style="display:flex;align-items:center;gap:6px;flex:1;min-width:0">
|
||||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
|
<div class="skills-search" style="flex:1;min-width:0;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||||
|
<select id="skillsSort" onchange="setSkillsSort(this.value)" style="background:var(--hover-bg);border:1px solid var(--border2);border-radius:6px;color:var(--muted);font-size:10px;padding:4px 6px;outline:none;cursor:pointer;flex-shrink:0">
|
||||||
|
<option value="az" ${_skillsSort==='az'?'selected':''}>A-Z</option>
|
||||||
|
<option value="za" ${_skillsSort==='za'?'selected':''}>Z-A</option>
|
||||||
|
<option value="uncat" ${_skillsSort==='uncat'?'selected':''}>Uncat.▲</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="cron-btn run" style="padding:4px 10px;font-size:11px;flex-shrink:0" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Skill create/edit form (hidden by default) -->
|
<!-- Skill create/edit form (hidden by default) -->
|
||||||
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||||
|
<div id="skillFormHeader" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||||||
|
<span id="skillFormTitle" style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">Neuer Skill</span>
|
||||||
|
<button onclick="toggleSkillForm()" style="background:none;border:none;color:var(--muted);cursor:pointer;padding:2px;line-height:1;font-size:14px" title="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;margin-bottom:6px">
|
||||||
|
<input id="skillFormCategory" placeholder="Category (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;box-sizing:border-box">
|
||||||
|
<div id="skillCatDropdown" style="display:none;position:absolute;left:0;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:6px;z-index:100;max-height:160px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.4)"></div>
|
||||||
|
</div>
|
||||||
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||||
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
|
||||||
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
|
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
|
|
||||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
|
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
|
||||||
|
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,95 +142,29 @@
|
|||||||
<div id="agentInbox" style="display:none;position:absolute;top:0;right:0;bottom:0;width:320px;background:var(--surface);border-left:1px solid var(--border);z-index:100;overflow-y:auto;padding:16px;box-shadow:-4px 0 20px rgba(0,0,0,.3)"></div>
|
<div id="agentInbox" style="display:none;position:absolute;top:0;right:0;bottom:0;width:320px;background:var(--surface);border-left:1px solid var(--border);z-index:100;overflow-y:auto;padding:16px;box-shadow:-4px 0 20px rgba(0,0,0,.3)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Projects panel -->
|
<!-- Agent Activity Tree panel -->
|
||||||
<div class="panel-view" id="panelProjects">
|
<div id="panelActivity" class="panel-view">
|
||||||
<!-- Header: Title + Expand Button -->
|
<div class="mc-header">
|
||||||
<div class="projects-header">
|
<div class="mc-header-top">
|
||||||
<div class="projects-title">📋 Projects</div>
|
<h3>Agent Activity</h3>
|
||||||
<div class="projects-header-stats" id="projectsHeaderStats"></div>
|
<div class="mc-header-actions">
|
||||||
<button class="panel-icon-btn" id="btnExpandProjects" title="Expand" onclick="expandPanel('projects')">
|
<button id="mcExpandAll" class="mc-btn-sm" title="Expand All">▼</button>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
<button id="mcCollapseAll" class="mc-btn-sm" title="Collapse All">▶</button>
|
||||||
</button>
|
<button id="mcMockData" class="mc-btn-sm" title="Load Mock Data">🧪</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Add Bar -->
|
|
||||||
<div class="projects-quick-add">
|
|
||||||
<span class="quick-add-icon">+</span>
|
|
||||||
<input id="quickAddInput" placeholder="Add a task..." onkeydown="if(event.key==='Enter')quickAddTask()">
|
|
||||||
<input id="quickAddDue" type="date" title="Due date" id="quickAddDue">
|
|
||||||
<select id="quickAddType">
|
|
||||||
<option value="project">📁 Project</option>
|
|
||||||
<option value="daily">📅 Daily</option>
|
|
||||||
<option value="recurring">🔄 Recurring</option>
|
|
||||||
</select>
|
|
||||||
<button class="quick-add-btn" onclick="quickAddTask()">Add</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter Bar -->
|
|
||||||
<div class="projects-filter-bar" id="projectsFilterBar">
|
|
||||||
<div class="filter-group">
|
|
||||||
<button class="filter-btn active" data-filter="all" onclick="filterTasks('all')">All</button>
|
|
||||||
<button class="filter-btn" data-filter="project" onclick="filterTasks('project')">📁 Projects</button>
|
|
||||||
<button class="filter-btn" data-filter="daily" onclick="filterTasks('daily')">📅 Daily</button>
|
|
||||||
<button class="filter-btn" data-filter="recurring" onclick="filterTasks('recurring')">🔄 Recurring</button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-sep"></div>
|
|
||||||
<div class="filter-group">
|
|
||||||
<button class="filter-btn p1" onclick="filterTasks('p1')">🔴 P1</button>
|
|
||||||
<button class="filter-btn p2" onclick="filterTasks('p2')">🟡 P2</button>
|
|
||||||
<button class="filter-btn p3" onclick="filterTasks('p3')">🟢 P3</button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-sep"></div>
|
|
||||||
<div class="filter-group filter-group-right">
|
|
||||||
<span class="filter-streak" id="filterStreak"></span>
|
|
||||||
<span class="filter-overdue" id="filterOverdue"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content: Split View -->
|
|
||||||
<div class="projects-main" id="projectsMain">
|
|
||||||
<!-- Linke Spalte: Projektliste -->
|
|
||||||
<div class="projects-sidebar" id="projectsSidebar">
|
|
||||||
<div class="sidebar-section-header">📁 Projects</div>
|
|
||||||
<div id="projectsList"></div>
|
|
||||||
<div class="sidebar-section-header" style="margin-top:16px">📅 Daily Tasks</div>
|
|
||||||
<div id="dailyTasksList"></div>
|
|
||||||
<div class="sidebar-section-header" style="margin-top:16px">🔄 Recurring</div>
|
|
||||||
<div id="recurringTasksList"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rechte Spalte: Globales Kanban -->
|
|
||||||
<div class="projects-kanban" id="projectsKanban">
|
|
||||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'todo')">
|
|
||||||
<div class="kanban-col-header">
|
|
||||||
<span class="kanban-col-title">📋 TODO</span>
|
|
||||||
<span class="kanban-col-count" id="kanbanTodoCount"></span>
|
|
||||||
</div>
|
|
||||||
<div class="kanban-col-content" id="kanbanTodoContent"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'in_progress')">
|
</div>
|
||||||
<div class="kanban-col-header">
|
<div class="mc-controls">
|
||||||
<span class="kanban-col-title">⚡ IN PROGRESS</span>
|
<input type="text" id="mcSearch" class="mc-search" placeholder="Search agents or tools..." />
|
||||||
<span class="kanban-col-count" id="kanbanInProgressCount"></span>
|
<div class="mc-filter-group">
|
||||||
</div>
|
<button class="mc-filter-btn active" data-filter="all">All</button>
|
||||||
<div class="kanban-col-content" id="kanbanInProgressContent"></div>
|
<button class="mc-filter-btn" data-filter="running">Running</button>
|
||||||
</div>
|
<button class="mc-filter-btn" data-filter="pending">Pending</button>
|
||||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'review')">
|
<button class="mc-filter-btn" data-filter="done">Done</button>
|
||||||
<div class="kanban-col-header">
|
|
||||||
<span class="kanban-col-title">👀 REVIEW</span>
|
|
||||||
<span class="kanban-col-count" id="kanbanReviewCount"></span>
|
|
||||||
</div>
|
|
||||||
<div class="kanban-col-content" id="kanbanReviewContent"></div>
|
|
||||||
</div>
|
|
||||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'done')">
|
|
||||||
<div class="kanban-col-header">
|
|
||||||
<span class="kanban-col-title">✅ DONE</span>
|
|
||||||
<span class="kanban-col-count" id="kanbanDoneCount"></span>
|
|
||||||
</div>
|
|
||||||
<div class="kanban-col-content" id="kanbanDoneContent"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mcBody" class="mc-body"></div>
|
||||||
|
<div id="mcFooter" class="mc-footer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
@@ -254,6 +202,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
|
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
|
||||||
<div class="topbar-chips">
|
<div class="topbar-chips">
|
||||||
|
<div class="agent-selector-wrap" id="agentSelectorWrap" style="position:relative">
|
||||||
|
<button class="chip agent-selector-chip" id="agentSelectorChip" type="button" onclick="toggleAgentSelectorDropdown()" title="Chat with agent">
|
||||||
|
<span id="agentSelectorIcon">🌹</span>
|
||||||
|
<span id="agentSelectorLabel">Rose</span>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="agent-selector-dropdown" id="agentSelectorDropdown" style="display:none;position:absolute;top:100%;right:0;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:10px;min-width:200px;max-height:320px;overflow-y:auto;z-index:200;box-shadow:0 8px 24px rgba(0,0,0,.3)"></div>
|
||||||
|
</div>
|
||||||
<div class="ws-selector-wrap" id="wsSelectorWrap" style="position:relative">
|
<div class="ws-selector-wrap" id="wsSelectorWrap" style="position:relative">
|
||||||
<button class="chip ws-selector-chip" id="wsSelectorChip" type="button" onclick="toggleWsSelectorDropdown()" title="Switch workspace">
|
<button class="chip ws-selector-chip" id="wsSelectorChip" type="button" onclick="toggleWsSelectorDropdown()" title="Switch workspace">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
@@ -294,9 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="messages-inner" id="msgInner"></div>
|
<div class="messages-inner" id="msgInner"></div>
|
||||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||||
<div class="tool-cards-toggle" id="toolCardToggleBtn" style="margin:4px 0 2px 40px;display:flex;gap:8px">
|
|
||||||
<button onclick="toggleShowAllTools()" id="btnShowAllTools" data-i18n="show_all_tools">Show all tools</button>
|
|
||||||
</div>
|
|
||||||
<button class="scroll-bottom-fab" id="scrollBottomFab" onclick="scrollToBottom()" title="Scroll to bottom"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
<button class="scroll-bottom-fab" id="scrollBottomFab" onclick="scrollToBottom()" title="Scroll to bottom"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-banner" id="updateBanner">
|
<div class="update-banner" id="updateBanner">
|
||||||
@@ -365,15 +319,15 @@
|
|||||||
Drop files to upload to workspace
|
Drop files to upload to workspace
|
||||||
</div>
|
</div>
|
||||||
<div class="attach-tray" id="attachTray"></div>
|
<div class="attach-tray" id="attachTray"></div>
|
||||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
<div class="mic-status" id="micStatus"><span class="mic-dot"></span> Listening…</div>
|
||||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||||
<div class="composer-footer">
|
<div class="composer-footer">
|
||||||
<div class="composer-left">
|
<div class="composer-left">
|
||||||
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
|
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env">
|
||||||
<button class="icon-btn" id="btnAttach" title="Attach files">
|
<button class="icon-btn" id="btnAttach" title="Attach files">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
|
<button class="icon-btn mic-btn voice-chip" id="btnMic" title="Voice input">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
||||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||||
@@ -425,8 +379,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-right">
|
<div class="composer-right">
|
||||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
<span class="composer-status" id="composerStatus"></span>
|
||||||
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap" style="display:none">
|
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap">
|
||||||
<button class="ctx-indicator" id="ctxIndicator" type="button" aria-label="Context window usage" aria-describedby="ctxTooltip">
|
<button class="ctx-indicator" id="ctxIndicator" type="button" aria-label="Context window usage" aria-describedby="ctxTooltip">
|
||||||
<span class="ctx-ring">
|
<span class="ctx-ring">
|
||||||
<svg class="ctx-ring-svg" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="ctx-ring-svg" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -441,10 +395,10 @@
|
|||||||
<div class="ctx-tooltip-line" id="ctxTooltipUsage"></div>
|
<div class="ctx-tooltip-line" id="ctxTooltipUsage"></div>
|
||||||
<div class="ctx-tooltip-line" id="ctxTooltipTokens"></div>
|
<div class="ctx-tooltip-line" id="ctxTooltipTokens"></div>
|
||||||
<div class="ctx-tooltip-line" id="ctxTooltipThreshold"></div>
|
<div class="ctx-tooltip-line" id="ctxTooltipThreshold"></div>
|
||||||
<div class="ctx-tooltip-line" id="ctxTooltipCost" style="display:none"></div>
|
<div class="ctx-tooltip-line" id="ctxTooltipCost"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
|
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" title="Stop generation" aria-label="Stop generation">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="send-btn" id="btnSend" title="Send message" disabled>
|
<button class="send-btn" id="btnSend" title="Send message" disabled>
|
||||||
@@ -464,10 +418,10 @@
|
|||||||
<div class="resize-handle" id="rightpanelResize"></div>
|
<div class="resize-handle" id="rightpanelResize"></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>Workspace</span>
|
<span>Workspace</span>
|
||||||
<span class="git-badge" id="gitBadge" style="display:none"></span>
|
<span class="git-badge" id="gitBadge"></span>
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
|
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||||
@@ -480,7 +434,7 @@
|
|||||||
<input type="text" id="wsSearchInput" placeholder="Files suchen..." oninput="filterWsFiles()">
|
<input type="text" id="wsSearchInput" placeholder="Files suchen..." oninput="filterWsFiles()">
|
||||||
<button class="ws-search-clear" id="wsSearchClear" onclick="clearWsSearch()">×</button>
|
<button class="ws-search-clear" id="wsSearchClear" onclick="clearWsSearch()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
<div class="breadcrumb-bar" id="breadcrumbBar"></div>
|
||||||
<div class="file-tree" id="fileTree"></div>
|
<div class="file-tree" id="fileTree"></div>
|
||||||
<div class="preview-area" id="previewArea">
|
<div class="preview-area" id="previewArea">
|
||||||
<div class="preview-path" id="previewPath">
|
<div class="preview-path" id="previewPath">
|
||||||
@@ -490,8 +444,8 @@
|
|||||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="preview-code" id="previewCode"></pre>
|
<pre class="preview-code" id="previewCode"></pre>
|
||||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
<div class="preview-img-wrap" id="previewImgWrap"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
<div class="preview-md" id="previewMd"></div>
|
||||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -562,7 +516,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
<div class="onboarding-overlay" id="onboardingOverlay" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||||
<div class="onboarding-card">
|
<div class="onboarding-card">
|
||||||
<div class="onboarding-shell">
|
<div class="onboarding-shell">
|
||||||
<div class="onboarding-sidebar">
|
<div class="onboarding-sidebar">
|
||||||
@@ -575,7 +529,7 @@
|
|||||||
<div class="onboarding-status" id="onboardingNotice"></div>
|
<div class="onboarding-status" id="onboardingNotice"></div>
|
||||||
<div class="onboarding-body" id="onboardingBody"></div>
|
<div class="onboarding-body" id="onboardingBody"></div>
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
|
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" data-i18n="onboarding_back">Back</button>
|
||||||
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
|
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
|
||||||
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
|
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -635,7 +589,7 @@
|
|||||||
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
||||||
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> Clear</button>
|
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
<input type="file" id="importFileInput" accept=".json">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
|
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
|
||||||
<div class="settings-section-head">
|
<div class="settings-section-head">
|
||||||
@@ -722,6 +676,15 @@
|
|||||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
|
<label data-i18n="settings_label_user_profile">Your Profile</label>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_user_profile">This is how you appear in the chat.</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<div id="userAvatarPreview" style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0">Y</div>
|
||||||
|
<input type="text" id="settingsUserEmoji" placeholder="🙂" maxlength="8" style="width:50px;padding:6px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:16px;text-align:center" oninput="updateUserAvatarPreview(this.value)">
|
||||||
|
<input type="text" id="settingsUserName" placeholder="You" maxlength="32" style="flex:1;padding:6px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="margin-top:12px">
|
||||||
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
|
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||||
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||||
@@ -739,7 +702,9 @@
|
|||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||||
|
<form id="settingsPasswordForm" onsubmit="return false">
|
||||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||||
@@ -772,7 +737,7 @@
|
|||||||
<span class="logs-filename" id="logsFileName">Select a log file</span>
|
<span class="logs-filename" id="logsFileName">Select a log file</span>
|
||||||
<div class="logs-toolbar-right">
|
<div class="logs-toolbar-right">
|
||||||
<input type="text" id="logsSearchInput" placeholder="Search logs..." style="display:none;width:160px;padding:3px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:5px;font-size:11px" onkeyup="filterLogContent()">
|
<input type="text" id="logsSearchInput" placeholder="Search logs..." style="display:none;width:160px;padding:3px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:5px;font-size:11px" onkeyup="filterLogContent()">
|
||||||
<div class="logs-level-btns" id="logsLevelBtns" style="display:none">
|
<div class="logs-level-btns" id="logsLevelBtns">
|
||||||
<button class="log-level-btn active" data-level="all" onclick="setLogLevel('all')">All</button>
|
<button class="log-level-btn active" data-level="all" onclick="setLogLevel('all')">All</button>
|
||||||
<button class="log-level-btn" data-level="ERROR" onclick="setLogLevel('ERROR')" style="color:#e85353">ERROR</button>
|
<button class="log-level-btn" data-level="ERROR" onclick="setLogLevel('ERROR')" style="color:#e85353">ERROR</button>
|
||||||
<button class="log-level-btn" data-level="WARN" onclick="setLogLevel('WARN')" style="color:#e8a030">WARN</button>
|
<button class="log-level-btn" data-level="WARN" onclick="setLogLevel('WARN')" style="color:#e8a030">WARN</button>
|
||||||
@@ -782,14 +747,14 @@
|
|||||||
<input type="checkbox" id="logsAutoRefresh" onchange="toggleLogAutoRefresh()" style="width:13px;height:13px;accent-color:var(--accent)">
|
<input type="checkbox" id="logsAutoRefresh" onchange="toggleLogAutoRefresh()" style="width:13px;height:13px;accent-color:var(--accent)">
|
||||||
Live
|
Live
|
||||||
</label>
|
</label>
|
||||||
<button class="icon-btn" id="logsRefreshBtn" style="display:none" onclick="loadLogsPanel()" title="Refresh logs">
|
<button class="icon-btn" id="logsRefreshBtn" onclick="loadLogsPanel()" title="Refresh logs">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="logs-body" id="logsBody">
|
<div class="logs-body" id="logsBody">
|
||||||
<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center" id="logsEmptyState">Select a log file from the sidebar to view its contents.</div>
|
<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center" id="logsEmptyState">Select a log file from the sidebar to view its contents.</div>
|
||||||
<div id="logsContent" style="display:none"></div>
|
<div id="logsContent"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -810,7 +775,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
|
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
|
||||||
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
|
<div class="app-dialog-overlay" id="appDialogOverlay" aria-hidden="true">
|
||||||
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
|
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
|
||||||
<div class="app-dialog-header">
|
<div class="app-dialog-header">
|
||||||
<div class="app-dialog-title" id="appDialogTitle">Confirm action</div>
|
<div class="app-dialog-title" id="appDialogTitle">Confirm action</div>
|
||||||
@@ -819,7 +784,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-dialog-desc" id="appDialogDesc"></div>
|
<div class="app-dialog-desc" id="appDialogDesc"></div>
|
||||||
<input class="app-dialog-input" id="appDialogInput" type="text" style="display:none">
|
<input class="app-dialog-input" id="appDialogInput" type="text">
|
||||||
<div class="app-dialog-actions">
|
<div class="app-dialog-actions">
|
||||||
<button class="app-dialog-btn" id="appDialogCancel" type="button" data-i18n="cancel">Cancel</button>
|
<button class="app-dialog-btn" id="appDialogCancel" type="button" data-i18n="cancel">Cancel</button>
|
||||||
<button class="app-dialog-btn confirm" id="appDialogConfirm" type="button">Confirm</button>
|
<button class="app-dialog-btn confirm" id="appDialogConfirm" type="button">Confirm</button>
|
||||||
@@ -833,10 +798,11 @@
|
|||||||
<script src="/static/workspace.js"></script>
|
<script src="/static/workspace.js"></script>
|
||||||
<script src="/static/sessions.js"></script>
|
<script src="/static/sessions.js"></script>
|
||||||
<script src="/static/commands.js"></script>
|
<script src="/static/commands.js"></script>
|
||||||
|
<script src="/static/boot.js"></script>
|
||||||
|
<script src="/static/activity-tree.js"></script>
|
||||||
<script src="/static/messages.js"></script>
|
<script src="/static/messages.js"></script>
|
||||||
<script src="/static/panels.js"></script>
|
<script src="/static/panels.js"></script>
|
||||||
<script src="/static/onboarding.js"></script>
|
<script src="/static/onboarding.js"></script>
|
||||||
<script src="/static/boot.js"></script>
|
|
||||||
<!-- Task Edit Modal -->
|
<!-- Task Edit Modal -->
|
||||||
<div id="taskEditModal" class="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:2000;justify-content:center;align-items:center">
|
<div id="taskEditModal" class="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:2000;justify-content:center;align-items:center">
|
||||||
<div style="background:var(--surface);border:1px solid var(--border2);border-radius:12px;padding:24px;width:420px;max-width:90vw">
|
<div style="background:var(--surface);border:1px solid var(--border2);border-radius:12px;padding:24px;width:420px;max-width:90vw">
|
||||||
|
|||||||
100
static/login.js
100
static/login.js
@@ -1,55 +1,55 @@
|
|||||||
/* Login page — external script, no inline handlers.
|
(() => {
|
||||||
* Loaded by the /login route. Reads data attributes from the form for
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
* i18n strings so the server does not need to inject JS literals.
|
const form = document.getElementById("login-form");
|
||||||
*/
|
const input = document.getElementById("pw");
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
if (!form || !input) return;
|
||||||
var form = document.getElementById('login-form');
|
const invalidPw = form.getAttribute("data-invalid-pw") || "Invalid password";
|
||||||
var input = document.getElementById('pw');
|
const connFailed = form.getAttribute("data-conn-failed") || "Connection failed";
|
||||||
|
function showErr(msg) {
|
||||||
if (!form || !input) return;
|
const err = document.getElementById("err");
|
||||||
|
if (err) {
|
||||||
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
|
err.textContent = msg;
|
||||||
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
|
err.style.display = "block";
|
||||||
|
|
||||||
function showErr(msg) {
|
|
||||||
var err = document.getElementById('err');
|
|
||||||
if (err) { err.textContent = msg; err.style.display = 'block'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideErr() {
|
|
||||||
var err = document.getElementById('err');
|
|
||||||
if (err) { err.style.display = 'none'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doLogin(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var pw = input.value;
|
|
||||||
hideErr();
|
|
||||||
try {
|
|
||||||
var res = await fetch('api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password: pw }),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
var data = {};
|
|
||||||
try { data = await res.json(); } catch (_) {}
|
|
||||||
if (res.ok && data.ok) {
|
|
||||||
window.location.href = './';
|
|
||||||
} else {
|
|
||||||
showErr(data.error || invalidPw);
|
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
|
||||||
showErr(connFailed);
|
|
||||||
}
|
}
|
||||||
}
|
function hideErr() {
|
||||||
|
const err = document.getElementById("err");
|
||||||
form.addEventListener('submit', doLogin);
|
if (err) {
|
||||||
|
err.style.display = "none";
|
||||||
input.addEventListener('keydown', function (e) {
|
}
|
||||||
if (e.key === 'Enter') {
|
}
|
||||||
|
async function doLogin(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
doLogin(e);
|
const pw = input.value;
|
||||||
|
hideErr();
|
||||||
|
try {
|
||||||
|
const res = await fetch("api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: pw }),
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
if (res.ok && data.ok) {
|
||||||
|
window.location.href = "./";
|
||||||
|
} else {
|
||||||
|
showErr(data.error || invalidPw);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
showErr(connFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
form.addEventListener("submit", doLogin);
|
||||||
|
input.addEventListener("keydown", function(e) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
doLogin(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
})();
|
||||||
|
//# sourceMappingURL=login.js.map
|
||||||
|
|||||||
60
static/login.ts
Normal file
60
static/login.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/* Login page — external script, no inline handlers.
|
||||||
|
* Loaded by the /login route. Reads data attributes from the form for
|
||||||
|
* i18n strings so the server does not need to inject JS literals.
|
||||||
|
*/
|
||||||
|
interface LoginForm extends HTMLFormElement {
|
||||||
|
'data-invalid-pw': string;
|
||||||
|
'data-conn-failed': string;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const form = document.getElementById('login-form') as LoginForm | null;
|
||||||
|
const input = document.getElementById('pw') as HTMLInputElement | null;
|
||||||
|
|
||||||
|
if (!form || !input) return;
|
||||||
|
|
||||||
|
const invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
|
||||||
|
const connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
|
||||||
|
|
||||||
|
function showErr(msg: string): void {
|
||||||
|
const err = document.getElementById('err');
|
||||||
|
if (err) { err.textContent = msg; err.style.display = 'block'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideErr(): void {
|
||||||
|
const err = document.getElementById('err');
|
||||||
|
if (err) { err.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin(e: Event): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
const pw = input!.value;
|
||||||
|
hideErr();
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: pw }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
let data: { ok?: boolean; error?: string } = {};
|
||||||
|
try { data = await res.json(); } catch (_) { /* ignore */ }
|
||||||
|
if (res.ok && data.ok) {
|
||||||
|
window.location.href = './';
|
||||||
|
} else {
|
||||||
|
showErr(data.error || invalidPw);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
showErr(connFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', doLogin);
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
doLogin(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -341,6 +341,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
if(d.name==='clarify') return;
|
if(d.name==='clarify') return;
|
||||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
|
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
|
||||||
|
// ── Activity Tree: track tool call ──
|
||||||
|
if(typeof _atTrackTool==='function') _atTrackTool(d);
|
||||||
|
if(typeof renderActivityTree==='function') renderActivityTree();
|
||||||
const inflight = INFLIGHT[activeSid] || (INFLIGHT[activeSid] = {
|
const inflight = INFLIGHT[activeSid] || (INFLIGHT[activeSid] = {
|
||||||
messages:[...S.messages],
|
messages:[...S.messages],
|
||||||
uploaded:[],
|
uploaded:[],
|
||||||
@@ -366,6 +369,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
source.addEventListener('tool_complete',e=>{
|
source.addEventListener('tool_complete',e=>{
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
if(d.name==='clarify') return;
|
if(d.name==='clarify') return;
|
||||||
|
// ── Activity Tree: track tool complete ──
|
||||||
|
if(typeof _atTrackToolComplete==='function') _atTrackToolComplete(d);
|
||||||
|
if(typeof renderActivityTree==='function') renderActivityTree();
|
||||||
const inflight=INFLIGHT[activeSid];
|
const inflight=INFLIGHT[activeSid];
|
||||||
if(!inflight) return;
|
if(!inflight) return;
|
||||||
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||||
@@ -393,6 +399,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
scrollIfPinned();
|
scrollIfPinned();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Subagent lifecycle: subagent.start / subagent.complete ──────────
|
||||||
|
source.addEventListener('subagent',e=>{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
if(typeof _atTrackSubagent==='function') _atTrackSubagent(d);
|
||||||
|
if(typeof renderActivityTree==='function') renderActivityTree();
|
||||||
|
});
|
||||||
|
|
||||||
source.addEventListener('approval',e=>{
|
source.addEventListener('approval',e=>{
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
d._session_id=activeSid;
|
d._session_id=activeSid;
|
||||||
@@ -444,6 +457,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
|
|
||||||
source.addEventListener('done',e=>{
|
source.addEventListener('done',e=>{
|
||||||
_terminalStateReached=true;
|
_terminalStateReached=true;
|
||||||
|
// ── Activity Tree: finalize turn ──
|
||||||
|
if(typeof _atTrackDone==='function') _atTrackDone();
|
||||||
|
if(typeof renderActivityTree==='function') renderActivityTree();
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
delete INFLIGHT[activeSid];
|
delete INFLIGHT[activeSid];
|
||||||
clearInflight();clearInflightState(activeSid);
|
clearInflight();clearInflightState(activeSid);
|
||||||
|
|||||||
1212
static/messages.ts
Normal file
1212
static/messages.ts
Normal file
File diff suppressed because it is too large
Load Diff
390
static/onboarding.ts
Normal file
390
static/onboarding.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false};
|
||||||
|
|
||||||
|
function _getOnboardingSetupProviders(){
|
||||||
|
return (((ONBOARDING.status||{}).setup||{}).providers)||[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getOnboardingSetupProvider(id){
|
||||||
|
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getOnboardingCurrentSetup(){
|
||||||
|
return (((ONBOARDING.status||{}).setup||{}).current)||{};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onboardingStepMeta(key){
|
||||||
|
return ({
|
||||||
|
system:{title:t('onboarding_step_system_title'),desc:t('onboarding_step_system_desc')},
|
||||||
|
setup:{title:t('onboarding_step_setup_title'),desc:t('onboarding_step_setup_desc')},
|
||||||
|
workspace:{title:t('onboarding_step_workspace_title'),desc:t('onboarding_step_workspace_desc')},
|
||||||
|
password:{title:t('onboarding_step_password_title'),desc:t('onboarding_step_password_desc')},
|
||||||
|
finish:{title:t('onboarding_step_finish_title'),desc:t('onboarding_step_finish_desc')}
|
||||||
|
})[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderOnboardingSteps(){
|
||||||
|
const wrap=$('onboardingSteps');
|
||||||
|
if(!wrap)return;
|
||||||
|
wrap.innerHTML='';
|
||||||
|
ONBOARDING.steps.forEach((key,idx)=>{
|
||||||
|
const meta=_onboardingStepMeta(key);
|
||||||
|
const item=document.createElement('div');
|
||||||
|
item.className='onboarding-step'+(idx===ONBOARDING.step?' active':idx<ONBOARDING.step?' done':'');
|
||||||
|
item.innerHTML=`<div class="onboarding-step-index">${idx+1}</div><div><div class="onboarding-step-title">${meta.title}</div><div class="onboarding-step-desc">${meta.desc}</div></div>`;
|
||||||
|
wrap.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setOnboardingNotice(msg,kind='info'){
|
||||||
|
const el=$('onboardingNotice');
|
||||||
|
if(!el)return;
|
||||||
|
if(!msg){el.style.display='none';el.textContent='';el.className='onboarding-status';return;}
|
||||||
|
el.style.display='block';
|
||||||
|
el.className='onboarding-status '+kind;
|
||||||
|
el.textContent=msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getOnboardingWorkspaceChoices(){
|
||||||
|
const items=((ONBOARDING.status||{}).workspaces||{}).items||[];
|
||||||
|
return items.length?items:[{name:'Home',path:ONBOARDING.form.workspace||''}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getOnboardingProviderModelChoices(){
|
||||||
|
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
|
||||||
|
return provider?(provider.models||[]):[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getOnboardingSelectedModel(){
|
||||||
|
return ONBOARDING.form.model||'';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderOnboardingModelField(){
|
||||||
|
const choices=_getOnboardingProviderModelChoices();
|
||||||
|
if(ONBOARDING.form.provider==='custom'){
|
||||||
|
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
|
||||||
|
}
|
||||||
|
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
|
||||||
|
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _providerStatusLabel(system){
|
||||||
|
if(system.chat_ready) return t('onboarding_check_provider_ready');
|
||||||
|
if(system.provider_configured) return t('onboarding_check_provider_partial');
|
||||||
|
return t('onboarding_check_provider_pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderOnboardingBody(){
|
||||||
|
const body=$('onboardingBody');
|
||||||
|
if(!body||!ONBOARDING.status)return;
|
||||||
|
const key=ONBOARDING.steps[ONBOARDING.step];
|
||||||
|
const system=ONBOARDING.status.system||{};
|
||||||
|
const settings=ONBOARDING.status.settings||{};
|
||||||
|
const setup=ONBOARDING.status.setup||{};
|
||||||
|
const nextBtn=$('onboardingNextBtn');
|
||||||
|
const backBtn=$('onboardingBackBtn');
|
||||||
|
if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none';
|
||||||
|
if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue');
|
||||||
|
|
||||||
|
if(key==='system'){
|
||||||
|
const hermesOk=system.hermes_found&&system.imports_ok;
|
||||||
|
const setupOk=!!system.chat_ready;
|
||||||
|
_setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
|
||||||
|
body.innerHTML=`
|
||||||
|
<div class="onboarding-panel-grid">
|
||||||
|
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
|
||||||
|
<div class="onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}"><strong>${t('onboarding_check_provider')}</strong><span>${_providerStatusLabel(system)}</span></div>
|
||||||
|
<div class="onboarding-check ${(settings.password_enabled?'ok':'muted')}"><strong>${t('onboarding_check_password')}</strong><span>${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-copy">
|
||||||
|
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
|
||||||
|
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
|
||||||
|
<p>${esc(system.provider_note||'')}</p>
|
||||||
|
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}</p>`:''}
|
||||||
|
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
|
||||||
|
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(key==='setup'){
|
||||||
|
const providers=_getOnboardingSetupProviders();
|
||||||
|
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||||
|
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
||||||
|
const showBaseUrl=provider&&provider.requires_base_url;
|
||||||
|
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||||
|
|
||||||
|
// OAuth provider path: configured via CLI, no API key input needed.
|
||||||
|
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
|
||||||
|
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
|
||||||
|
if(currentIsOauth){
|
||||||
|
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
|
||||||
|
const providerLabel=esc(currentProviderName);
|
||||||
|
if(isReady){
|
||||||
|
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
|
||||||
|
body.innerHTML=`
|
||||||
|
<div class="onboarding-oauth-card onboarding-oauth-ready">
|
||||||
|
<div class="onboarding-oauth-icon">✓</div>
|
||||||
|
<div>
|
||||||
|
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
|
||||||
|
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_provider_label')}</span>
|
||||||
|
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||||
|
<span>${t('onboarding_api_key_label')}</span>
|
||||||
|
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||||
|
</label>
|
||||||
|
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||||
|
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||||
|
} else {
|
||||||
|
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
|
||||||
|
body.innerHTML=`
|
||||||
|
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||||
|
<div class="onboarding-oauth-icon">⚠</div>
|
||||||
|
<div>
|
||||||
|
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
|
||||||
|
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_provider_label')}</span>
|
||||||
|
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||||
|
<span>${t('onboarding_api_key_label')}</span>
|
||||||
|
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||||
|
</label>
|
||||||
|
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||||
|
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||||
|
}
|
||||||
|
const providerSel=$('onboardingProviderSelect');
|
||||||
|
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
|
||||||
|
body.innerHTML=`
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_provider_label')}</span>
|
||||||
|
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_api_key_label')}</span>
|
||||||
|
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||||
|
</label>
|
||||||
|
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||||
|
<p class="onboarding-copy">${keyHelp}</p>
|
||||||
|
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||||
|
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
|
||||||
|
const providerSel=$('onboardingProviderSelect');
|
||||||
|
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(key==='workspace'){
|
||||||
|
const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>`<option value="${esc(ws.path)}">${esc(ws.name||ws.path)} — ${esc(ws.path)}</option>`).join('');
|
||||||
|
_setOnboardingNotice(t('onboarding_notice_workspace'), 'info');
|
||||||
|
body.innerHTML=`
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_workspace_label')}</span>
|
||||||
|
<select id="onboardingWorkspaceSelect" onchange="syncOnboardingWorkspaceSelect(this.value)">${workspaceOptions}</select>
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_workspace_or_path')}</span>
|
||||||
|
<input id="onboardingWorkspaceInput" value="${esc(ONBOARDING.form.workspace||'')}" placeholder="${t('onboarding_workspace_placeholder')}" oninput="ONBOARDING.form.workspace=this.value">
|
||||||
|
</label>
|
||||||
|
${_renderOnboardingModelField()}`;
|
||||||
|
const wsSel=$('onboardingWorkspaceSelect');
|
||||||
|
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
|
||||||
|
const modelSel=$('onboardingModelSelect');
|
||||||
|
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(key==='password'){
|
||||||
|
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
|
||||||
|
body.innerHTML=`
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_password_label')}</span>
|
||||||
|
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||||
|
</label>
|
||||||
|
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
|
||||||
|
_setOnboardingNotice(t('onboarding_notice_finish'), 'success');
|
||||||
|
body.innerHTML=`
|
||||||
|
<div class="onboarding-summary">
|
||||||
|
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
|
||||||
|
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
|
||||||
|
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
|
||||||
|
<div><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
|
||||||
|
</div>
|
||||||
|
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
|
||||||
|
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getOnboardingPasswordSummaryKey(settings){
|
||||||
|
const hasExistingPassword=!!(settings&&settings.password_enabled);
|
||||||
|
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
|
||||||
|
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
|
||||||
|
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOnboardingWorkspaceSelect(value){
|
||||||
|
ONBOARDING.form.workspace=value;
|
||||||
|
const input=$('onboardingWorkspaceInput');
|
||||||
|
if(input) input.value=value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOnboardingProvider(value){
|
||||||
|
const provider=_getOnboardingSetupProvider(value);
|
||||||
|
ONBOARDING.form.provider=value;
|
||||||
|
if(provider){
|
||||||
|
if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){
|
||||||
|
ONBOARDING.form.model=provider.default_model||'';
|
||||||
|
}
|
||||||
|
if(provider.requires_base_url){
|
||||||
|
ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||'';
|
||||||
|
}else{
|
||||||
|
ONBOARDING.form.baseUrl=provider.default_base_url||'';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_renderOnboardingBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOnboardingWizard(){
|
||||||
|
try{
|
||||||
|
const status=await api('/api/onboarding/status');
|
||||||
|
ONBOARDING.status=status;
|
||||||
|
const current=((status.setup||{}).current)||{};
|
||||||
|
ONBOARDING.form.provider=current.provider||'openrouter';
|
||||||
|
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
|
||||||
|
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
|
||||||
|
ONBOARDING.form.password='';
|
||||||
|
ONBOARDING.form.apiKey='';
|
||||||
|
ONBOARDING.form.baseUrl=current.base_url||'';
|
||||||
|
ONBOARDING.active=!status.completed;
|
||||||
|
if(!ONBOARDING.active) return false;
|
||||||
|
$('onboardingOverlay').style.display='flex';
|
||||||
|
_renderOnboardingSteps();
|
||||||
|
_renderOnboardingBody();
|
||||||
|
return true;
|
||||||
|
}catch(e){
|
||||||
|
console.warn('onboarding status failed',e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevOnboardingStep(){
|
||||||
|
if(ONBOARDING.step===0)return;
|
||||||
|
ONBOARDING.step--;
|
||||||
|
_renderOnboardingSteps();
|
||||||
|
_renderOnboardingBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _saveOnboardingProviderSetup(){
|
||||||
|
const provider=(ONBOARDING.form.provider||'').trim();
|
||||||
|
const model=(ONBOARDING.form.model||'').trim();
|
||||||
|
const apiKey=(ONBOARDING.form.apiKey||'').trim();
|
||||||
|
const baseUrl=(ONBOARDING.form.baseUrl||'').trim();
|
||||||
|
const current=_getOnboardingCurrentSetup();
|
||||||
|
const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl);
|
||||||
|
// Skip the POST when nothing changed. We also skip when the provider is
|
||||||
|
// unsupported/OAuth-based and already working — chat_ready may be false for
|
||||||
|
// providers not in the quick-setup list (e.g. minimax-cn) even though they are
|
||||||
|
// fully configured. Posting in that case would either be a no-op (the server
|
||||||
|
// just marks complete for unsupported providers) or could silently overwrite
|
||||||
|
// config.yaml if the user accidentally changed the provider dropdown.
|
||||||
|
const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth);
|
||||||
|
if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return;
|
||||||
|
const body: any = {provider,model};
|
||||||
|
if(apiKey) body.api_key=apiKey;
|
||||||
|
if(baseUrl) body.base_url=baseUrl;
|
||||||
|
const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)});
|
||||||
|
ONBOARDING.status=status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _saveOnboardingDefaults(){
|
||||||
|
const workspace=(ONBOARDING.form.workspace||'').trim();
|
||||||
|
const model=(ONBOARDING.form.model||'').trim();
|
||||||
|
const password=(ONBOARDING.form.password||'').trim();
|
||||||
|
if(!workspace) throw new Error(t('onboarding_error_choose_workspace'));
|
||||||
|
if(!model) throw new Error(t('onboarding_error_choose_model'));
|
||||||
|
const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace);
|
||||||
|
if(!known){
|
||||||
|
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
|
||||||
|
}
|
||||||
|
const body: any = {default_workspace:workspace,default_model:model};
|
||||||
|
if(password) body._set_password=password;
|
||||||
|
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||||
|
if(ONBOARDING.status){
|
||||||
|
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
|
||||||
|
}
|
||||||
|
localStorage.setItem('hermes-webui-model',model);
|
||||||
|
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _finishOnboarding(){
|
||||||
|
await _saveOnboardingProviderSetup();
|
||||||
|
await _saveOnboardingDefaults();
|
||||||
|
const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'});
|
||||||
|
ONBOARDING.status=done;
|
||||||
|
ONBOARDING.active=false;
|
||||||
|
$('onboardingOverlay').style.display='none';
|
||||||
|
showToast(t('onboarding_complete'));
|
||||||
|
await loadWorkspaceList();
|
||||||
|
if(typeof renderSessionList==='function') await renderSessionList();
|
||||||
|
if(!S.session && typeof newSession==='function'){
|
||||||
|
await newSession(true);
|
||||||
|
await renderSessionList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipOnboarding(){
|
||||||
|
try{
|
||||||
|
// Mark onboarding completed server-side without changing any config
|
||||||
|
await api('/api/onboarding/complete',{method:'POST',body:'{}'});
|
||||||
|
ONBOARDING.active=false;
|
||||||
|
$('onboardingOverlay').style.display='none';
|
||||||
|
showToast(t('onboarding_skipped')||'Setup skipped');
|
||||||
|
}catch(e){
|
||||||
|
_setOnboardingNotice((e.message||String(e)),'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextOnboardingStep(){
|
||||||
|
try{
|
||||||
|
if(ONBOARDING.steps[ONBOARDING.step]==='setup'){
|
||||||
|
ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim();
|
||||||
|
ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim();
|
||||||
|
ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim();
|
||||||
|
if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required'));
|
||||||
|
if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required'));
|
||||||
|
}
|
||||||
|
if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){
|
||||||
|
ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim();
|
||||||
|
ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim();
|
||||||
|
if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required'));
|
||||||
|
if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required'));
|
||||||
|
}
|
||||||
|
if(ONBOARDING.steps[ONBOARDING.step]==='password'){
|
||||||
|
ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim();
|
||||||
|
}
|
||||||
|
if(ONBOARDING.step===ONBOARDING.steps.length-1){
|
||||||
|
await _finishOnboarding();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ONBOARDING.step++;
|
||||||
|
_renderOnboardingSteps();
|
||||||
|
_renderOnboardingBody();
|
||||||
|
}catch(e){
|
||||||
|
_setOnboardingNotice(e.message||String(e),'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
2500
static/panels.js
2500
static/panels.js
File diff suppressed because it is too large
Load Diff
4087
static/panels.ts
Normal file
4087
static/panels.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,7 @@ let _showArchived = false; // toggle to show archived sessions
|
|||||||
let _allProjects = []; // cached project list
|
let _allProjects = []; // cached project list
|
||||||
let _activeProject = null; // project_id filter (null = show all)
|
let _activeProject = null; // project_id filter (null = show all)
|
||||||
let _showAllProfiles = false; // false = filter to active profile only
|
let _showAllProfiles = false; // false = filter to active profile only
|
||||||
|
let _sessionsExpanded = false; // false = show only first 50 sessions
|
||||||
let _sessionActionMenu = null;
|
let _sessionActionMenu = null;
|
||||||
let _sessionActionAnchor = null;
|
let _sessionActionAnchor = null;
|
||||||
let _sessionActionSessionId = null;
|
let _sessionActionSessionId = null;
|
||||||
@@ -307,13 +308,14 @@ window.addEventListener('resize',()=>{
|
|||||||
|
|
||||||
async function renderSessionList(){
|
async function renderSessionList(){
|
||||||
try{
|
try{
|
||||||
|
_sessionsExpanded = false; // reset expand state on fresh fetch
|
||||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||||
const [sessData, projData] = await Promise.all([
|
const [sessData, projData] = await Promise.all([
|
||||||
api('/api/sessions'),
|
api('/api/sessions'),
|
||||||
api('/api/projects'),
|
api('/api/projects'),
|
||||||
]);
|
]);
|
||||||
_allSessions = sessData.sessions||[];
|
_allSessions = sessData.sessions||[];
|
||||||
_allProjects = projData.projects||[];
|
_allProjects = (projData.projects||[]).map(p=>({...p,project_id:p.project_id||p.id}));
|
||||||
renderSessionListFromCache(); // no-ops if rename is in progress
|
renderSessionListFromCache(); // no-ops if rename is in progress
|
||||||
}catch(e){console.warn('renderSessionList',e);}
|
}catch(e){console.warn('renderSessionList',e);}
|
||||||
}
|
}
|
||||||
@@ -471,7 +473,11 @@ function renderSessionListFromCache(){
|
|||||||
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
|
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
|
||||||
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile);
|
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile);
|
||||||
// Filter by active project
|
// Filter by active project
|
||||||
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
// When a specific project is selected: show only that project's sessions
|
||||||
|
// When "All" is selected: show all sessions (both with and without project_id)
|
||||||
|
const projectFiltered=_activeProject
|
||||||
|
? profileFiltered.filter(s=>s.project_id===_activeProject)
|
||||||
|
: profileFiltered;
|
||||||
// Filter archived unless toggle is on
|
// Filter archived unless toggle is on
|
||||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||||
@@ -485,11 +491,30 @@ function renderSessionListFromCache(){
|
|||||||
allChip.className='project-chip'+(!_activeProject?' active':'');
|
allChip.className='project-chip'+(!_activeProject?' active':'');
|
||||||
allChip.textContent='All';
|
allChip.textContent='All';
|
||||||
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
|
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
|
||||||
|
allChip.addEventListener('dragover',(e)=>{e.preventDefault();e.dataTransfer.dropEffect='move';});
|
||||||
|
allChip.addEventListener('drop',async(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const sid=e.dataTransfer.getData('text/plain');
|
||||||
|
if(!sid)return;
|
||||||
|
try{
|
||||||
|
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:null})});
|
||||||
|
const s=_allSessions.find(x=>x.session_id===sid);
|
||||||
|
if(s)s.project_id=null;
|
||||||
|
if(_activeProject===null)renderSessionListFromCache();
|
||||||
|
else renderSessionList();
|
||||||
|
}catch(_){}
|
||||||
|
});
|
||||||
bar.appendChild(allChip);
|
bar.appendChild(allChip);
|
||||||
// Project chips
|
// Project chips
|
||||||
for(const p of _allProjects){
|
for(const p of _allProjects){
|
||||||
|
const projId=p.project_id;
|
||||||
const chip=document.createElement('span');
|
const chip=document.createElement('span');
|
||||||
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
|
chip.className='project-chip'+(projId===_activeProject?' active':'');
|
||||||
|
// Folder icon first
|
||||||
|
const folderIcon=document.createElement('span');
|
||||||
|
folderIcon.style.cssText='display:inline-flex;align-items:center;margin-right:3px;opacity:.75;';
|
||||||
|
folderIcon.innerHTML=ICONS.folder;
|
||||||
|
chip.appendChild(folderIcon);
|
||||||
if(p.color){
|
if(p.color){
|
||||||
const dot=document.createElement('span');
|
const dot=document.createElement('span');
|
||||||
dot.className='color-dot';
|
dot.className='color-dot';
|
||||||
@@ -499,9 +524,22 @@ function renderSessionListFromCache(){
|
|||||||
const nameSpan=document.createElement('span');
|
const nameSpan=document.createElement('span');
|
||||||
nameSpan.textContent=p.name;
|
nameSpan.textContent=p.name;
|
||||||
chip.appendChild(nameSpan);
|
chip.appendChild(nameSpan);
|
||||||
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
|
chip.onclick=()=>{_activeProject=projId;renderSessionListFromCache();};
|
||||||
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
|
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename({...p},chip);};
|
||||||
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
|
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
|
||||||
|
chip.addEventListener('dragover',(e)=>{e.preventDefault();e.dataTransfer.dropEffect='move';});
|
||||||
|
chip.addEventListener('drop',async(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const sid=e.dataTransfer.getData('text/plain');
|
||||||
|
if(!sid)return;
|
||||||
|
try{
|
||||||
|
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:p.project_id})});
|
||||||
|
const s=_allSessions.find(x=>x.session_id===sid);
|
||||||
|
if(s)s.project_id=p.project_id;
|
||||||
|
if(_activeProject===p.project_id)renderSessionListFromCache();
|
||||||
|
else renderSessionList();
|
||||||
|
}catch(_){}
|
||||||
|
});
|
||||||
bar.appendChild(chip);
|
bar.appendChild(chip);
|
||||||
}
|
}
|
||||||
// Create button
|
// Create button
|
||||||
@@ -547,17 +585,24 @@ function renderSessionListFromCache(){
|
|||||||
// Separate pinned from unpinned
|
// Separate pinned from unpinned
|
||||||
const pinned=orderedSessions.filter(s=>s.pinned);
|
const pinned=orderedSessions.filter(s=>s.pinned);
|
||||||
const unpinned=orderedSessions.filter(s=>!s.pinned);
|
const unpinned=orderedSessions.filter(s=>!s.pinned);
|
||||||
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
|
// Apply session limit to unpinned (pinned always shown in full)
|
||||||
|
const SESSION_LIMIT = 50;
|
||||||
|
const showAllSessions = _sessionsExpanded || unpinned.length <= SESSION_LIMIT;
|
||||||
|
const limitedUnpinned = showAllSessions ? unpinned : unpinned.slice(0, SESSION_LIMIT);
|
||||||
|
// Separate sessions without a project (unfiled) — they get their own section at the bottom
|
||||||
|
const unfiledSessions=limitedUnpinned.filter(s=>!s.project_id);
|
||||||
|
const filedSessions=limitedUnpinned.filter(s=>s.project_id);
|
||||||
|
// Date grouping for filed sessions: Pinned / Today / Yesterday / This week / Last week / Older
|
||||||
const now=Date.now();
|
const now=Date.now();
|
||||||
// Collapse state persisted in localStorage
|
// Collapse state persisted in localStorage
|
||||||
let _groupCollapsed={};
|
let _groupCollapsed={};
|
||||||
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
|
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
|
||||||
const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}};
|
const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}};
|
||||||
// Group sessions by date
|
// Group filed sessions by date
|
||||||
const groups=[];
|
const groups=[];
|
||||||
let curLabel=null,curItems=[];
|
let curLabel=null,curItems=[];
|
||||||
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
|
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
|
||||||
for(const s of unpinned){
|
for(const s of filedSessions){
|
||||||
const ts=_sessionTimestampMs(s);
|
const ts=_sessionTimestampMs(s);
|
||||||
const label=_sessionTimeBucketLabel(ts, now);
|
const label=_sessionTimeBucketLabel(ts, now);
|
||||||
if(label!==curLabel){
|
if(label!==curLabel){
|
||||||
@@ -593,20 +638,97 @@ function renderSessionListFromCache(){
|
|||||||
wrapper.appendChild(body);
|
wrapper.appendChild(body);
|
||||||
list.appendChild(wrapper);
|
list.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
|
// ── Unfiled sessions (no project) — shown as a collapsible "Unfiled" group at the bottom ──
|
||||||
|
if(unfiledSessions.length>0){
|
||||||
|
const unfiledOrdered=[...unfiledSessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||||
|
const unfiledGroup={label:'\u{1F4C1} Unfiled',items:unfiledOrdered,isUnfiled:true};
|
||||||
|
const wrapper=document.createElement('div');
|
||||||
|
wrapper.className='session-date-group';
|
||||||
|
const hdr=document.createElement('div');
|
||||||
|
hdr.className='session-date-header'+(unfiledGroup.isUnfiled?' unfiled':'');
|
||||||
|
const caret=document.createElement('span');
|
||||||
|
caret.className='session-date-caret';
|
||||||
|
caret.textContent='\u25B8';
|
||||||
|
const label=document.createElement('span');
|
||||||
|
label.textContent=unfiledGroup.label;
|
||||||
|
hdr.appendChild(caret);hdr.appendChild(label);
|
||||||
|
const body=document.createElement('div');
|
||||||
|
body.className='session-date-body';
|
||||||
|
if(_groupCollapsed[unfiledGroup.label]){body.style.display='none';caret.classList.add('collapsed');}
|
||||||
|
hdr.onclick=()=>{
|
||||||
|
const isCollapsed=body.style.display==='none';
|
||||||
|
body.style.display=isCollapsed?'':'none';
|
||||||
|
caret.classList.toggle('collapsed',!isCollapsed);
|
||||||
|
_groupCollapsed[unfiledGroup.label]=!isCollapsed;
|
||||||
|
_saveCollapsed();
|
||||||
|
};
|
||||||
|
wrapper.appendChild(hdr);
|
||||||
|
for(const s of unfiledGroup.items){ body.appendChild(_renderOneSession(s)); }
|
||||||
|
wrapper.appendChild(body);
|
||||||
|
list.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
// ── Show all sessions expander (when not expanded) ──
|
||||||
|
if(!showAllSessions && unpinned.length > SESSION_LIMIT){
|
||||||
|
const expander=document.createElement('div');
|
||||||
|
expander.style.cssText='padding:10px 14px;color:var(--muted);cursor:pointer;text-align:center;font-size:12px;opacity:.8;';
|
||||||
|
expander.textContent=`Show all ${unpinned.length} sessions`;
|
||||||
|
expander.onclick=()=>{_sessionsExpanded=true;renderSessionListFromCache();};
|
||||||
|
list.appendChild(expander);
|
||||||
|
}
|
||||||
|
// ── HTML decoder + garbage title guard ──
|
||||||
|
function _decodeHtml(str){
|
||||||
|
const txt=document.createElement('textarea');
|
||||||
|
txt.innerHTML=str;
|
||||||
|
return txt.value;
|
||||||
|
}
|
||||||
|
function _isGarbageTitle(t){
|
||||||
|
return t.startsWith('[SYSTEM:')||t.startsWith('[Note:')||t.startsWith('<input')||t.startsWith('[Note: model was')||/<input|&lt;input/.test(t)||t.includes('model was just switched')||t.length<3;
|
||||||
|
}
|
||||||
// ── Render session items (extracted for group body use) ──
|
// ── Render session items (extracted for group body use) ──
|
||||||
// Note: declared after the groups loop but available via function hoisting.
|
// Note: declared after the groups loop but available via function hoisting.
|
||||||
function _renderOneSession(s){
|
function _renderOneSession(s){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
|
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
|
||||||
|
el.draggable=true;
|
||||||
|
el.dataset.sessionId=s.session_id;
|
||||||
|
// Drag & Drop
|
||||||
|
el.addEventListener('dragstart',(e)=>{
|
||||||
|
e.dataTransfer.effectAllowed='move';
|
||||||
|
e.dataTransfer.setData('text/plain',s.session_id);
|
||||||
|
el.classList.add('dragging');
|
||||||
|
});
|
||||||
|
el.addEventListener('dragend',()=>el.classList.remove('dragging'));
|
||||||
|
el.addEventListener('dragover',(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect='move';
|
||||||
|
const dragging=document.querySelector('.dragging');
|
||||||
|
if(dragging&&dragging!==el){
|
||||||
|
const rect=el.getBoundingClientRect();
|
||||||
|
const mid=rect.top+rect.height/2;
|
||||||
|
el.classList.toggle('drag-after',e.clientY>mid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.addEventListener('drop',async(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
el.classList.remove('drag-after');
|
||||||
|
const draggedId=e.dataTransfer.getData('text/plain');
|
||||||
|
if(draggedId===s.session_id)return;
|
||||||
|
try{
|
||||||
|
const allS=_allSessions.filter(ss=>!ss.archived);
|
||||||
|
const draggedIdx=allS.findIndex(ss=>ss.session_id===draggedId);
|
||||||
|
const targetIdx=allS.findIndex(ss=>ss.session_id===s.session_id);
|
||||||
|
if(draggedIdx<0||targetIdx<0)return;
|
||||||
|
const targetW=(allS[targetIdx].updated_at||Date.now())+1;
|
||||||
|
await api('/api/session/reorder',{method:'POST',body:JSON.stringify({session_id:draggedId,weight:targetW})});
|
||||||
|
renderSessionList();
|
||||||
|
}catch(_){}
|
||||||
|
});
|
||||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||||
const rawTitle=s.title||'Untitled';
|
const rawTitle=_decodeHtml(s.title||'Untitled');
|
||||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
const tags=(rawTitle.match(/#[\\w-]+/g)||[]);
|
||||||
let cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
|
let cleanTitle=tags.length?rawTitle.replace(/#[\\w-]+/g,'').trim():rawTitle;
|
||||||
// Guard: system prompt content must never surface as a visible session title
|
if(_isGarbageTitle(cleanTitle)){cleanTitle='Session';}
|
||||||
if(cleanTitle.startsWith('[SYSTEM:')){
|
|
||||||
cleanTitle='Session';
|
|
||||||
}
|
|
||||||
const sessionText=document.createElement('div');
|
const sessionText=document.createElement('div');
|
||||||
sessionText.className='session-text';
|
sessionText.className='session-text';
|
||||||
const titleRow=document.createElement('div');
|
const titleRow=document.createElement('div');
|
||||||
@@ -616,7 +738,12 @@ function renderSessionListFromCache(){
|
|||||||
title.textContent=cleanTitle||'Untitled';
|
title.textContent=cleanTitle||'Untitled';
|
||||||
title.title='Double-click to rename';
|
title.title='Double-click to rename';
|
||||||
const tsMs=_sessionTimestampMs(s);
|
const tsMs=_sessionTimestampMs(s);
|
||||||
|
const timeEl=document.createElement('span');
|
||||||
|
timeEl.className='session-time';
|
||||||
|
timeEl.textContent=_formatRelativeSessionTime(tsMs);
|
||||||
|
timeEl.title=new Date(tsMs).toLocaleString();
|
||||||
titleRow.appendChild(title);
|
titleRow.appendChild(title);
|
||||||
|
titleRow.appendChild(timeEl);
|
||||||
sessionText.appendChild(titleRow);
|
sessionText.appendChild(titleRow);
|
||||||
// Append tag chips after the title text
|
// Append tag chips after the title text
|
||||||
for(const tag of tags){
|
for(const tag of tags){
|
||||||
@@ -869,13 +996,19 @@ function _startProjectCreate(bar, addBtn){
|
|||||||
inp.className='project-create-input';
|
inp.className='project-create-input';
|
||||||
inp.placeholder='Project name';
|
inp.placeholder='Project name';
|
||||||
const finish=async(save)=>{
|
const finish=async(save)=>{
|
||||||
if(save&&inp.value.trim()){
|
if(!save||!inp.value.trim()){
|
||||||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
inp.replaceWith(addBtn);
|
||||||
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
|
return;
|
||||||
|
}
|
||||||
|
const name=inp.value.trim();
|
||||||
|
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||||||
|
inp.disabled=true;
|
||||||
|
try{
|
||||||
|
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name,color})});
|
||||||
await renderSessionList();
|
await renderSessionList();
|
||||||
showToast('Project created');
|
showToast('Project created');
|
||||||
}else{
|
}finally{
|
||||||
inp.replaceWith(addBtn);
|
inp.disabled=false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
inp.onkeydown=(e)=>{
|
inp.onkeydown=(e)=>{
|
||||||
@@ -886,7 +1019,7 @@ function _startProjectCreate(bar, addBtn){
|
|||||||
}
|
}
|
||||||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||||||
};
|
};
|
||||||
inp.onblur=()=>finish(false);
|
inp.onblur=()=>{if(!inp.disabled)finish(false);};
|
||||||
addBtn.replaceWith(inp);
|
addBtn.replaceWith(inp);
|
||||||
setTimeout(()=>inp.focus(),10);
|
setTimeout(()=>inp.focus(),10);
|
||||||
}
|
}
|
||||||
@@ -896,12 +1029,18 @@ function _startProjectRename(proj, chip){
|
|||||||
inp.className='project-create-input';
|
inp.className='project-create-input';
|
||||||
inp.value=proj.name;
|
inp.value=proj.name;
|
||||||
const finish=async(save)=>{
|
const finish=async(save)=>{
|
||||||
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
|
if(!save||!inp.value.trim()||inp.value.trim()===proj.name){
|
||||||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
|
renderSessionListFromCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name=inp.value.trim();
|
||||||
|
inp.disabled=true;
|
||||||
|
try{
|
||||||
|
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name})});
|
||||||
await renderSessionList();
|
await renderSessionList();
|
||||||
showToast('Project renamed');
|
showToast('Project renamed');
|
||||||
}else{
|
}finally{
|
||||||
renderSessionListFromCache();
|
inp.disabled=false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
inp.onkeydown=(e)=>{
|
inp.onkeydown=(e)=>{
|
||||||
@@ -912,7 +1051,7 @@ function _startProjectRename(proj, chip){
|
|||||||
}
|
}
|
||||||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||||||
};
|
};
|
||||||
inp.onblur=()=>finish(false);
|
inp.onblur=()=>{if(!inp.disabled)finish(false);};
|
||||||
inp.onclick=(e)=>e.stopPropagation();
|
inp.onclick=(e)=>e.stopPropagation();
|
||||||
chip.replaceWith(inp);
|
chip.replaceWith(inp);
|
||||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||||
|
|||||||
1018
static/sessions.ts
Normal file
1018
static/sessions.ts
Normal file
File diff suppressed because it is too large
Load Diff
1032
static/style.css
1032
static/style.css
File diff suppressed because it is too large
Load Diff
127
static/ui.js
127
static/ui.js
@@ -3,7 +3,17 @@ const SHOW_ALL_TOOLS=false; // Toggle to show all tools (default: only active)
|
|||||||
let showAllToolsState=false; // Runtime state for toggle button
|
let showAllToolsState=false; // Runtime state for toggle button
|
||||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||||
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
||||||
const $=id=>document.getElementById(id);
|
$=window.$||(id=>document.getElementById(id));
|
||||||
|
const AGENT_META = {
|
||||||
|
rose: {emoji:'🌹', name:'Rose'},
|
||||||
|
lotus: {emoji:'🪷', name:'Lotus'},
|
||||||
|
'forget-me-not':{emoji:'🌼', name:'Forget-me-not'},
|
||||||
|
sunflower: {emoji:'🌻', name:'Sunflower'},
|
||||||
|
iris: {emoji:'⚜️', name:'Iris'},
|
||||||
|
ivy: {emoji:'🌿', name:'Ivy'},
|
||||||
|
dandelion: {emoji:'🛡️', name:'Dandelion'},
|
||||||
|
root: {emoji:'🌳', name:'Root'},
|
||||||
|
};
|
||||||
function _getSessionQueue(sid, create=false){
|
function _getSessionQueue(sid, create=false){
|
||||||
if(!sid) return [];
|
if(!sid) return [];
|
||||||
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
|
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
|
||||||
@@ -91,7 +101,6 @@ async function populateModelDropdown(){
|
|||||||
_applyModelToDropdown(data.default_model, sel);
|
_applyModelToDropdown(data.default_model, sel);
|
||||||
}
|
}
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
|
||||||
// Kick off a background live-model fetch for the active provider.
|
// Kick off a background live-model fetch for the active provider.
|
||||||
// This runs after the static list is already shown (no blocking flicker).
|
// This runs after the static list is already shown (no blocking flicker).
|
||||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
||||||
@@ -99,7 +108,6 @@ async function populateModelDropdown(){
|
|||||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||||
console.warn('Failed to load models from server:',e.message);
|
console.warn('Failed to load models from server:',e.message);
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +158,6 @@ async function _fetchLiveModels(provider, sel){
|
|||||||
// Restore selection
|
// Restore selection
|
||||||
if(currentVal) _applyModelToDropdown(currentVal, sel);
|
if(currentVal) _applyModelToDropdown(currentVal, sel);
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
|
||||||
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
|
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
|
||||||
}
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
@@ -310,114 +317,89 @@ function closeModelDropdown(){
|
|||||||
if(chip) chip.classList.remove('active');
|
if(chip) chip.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click',e=>{
|
|
||||||
if(!e.target.closest('#composerModelChip') && !e.target.closest('#composerModelDropdown')) closeModelDropdown();
|
|
||||||
});
|
|
||||||
window.addEventListener('resize',()=>{
|
|
||||||
const dd=$('composerModelDropdown');
|
|
||||||
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Agent selector dropdown ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const AGENT_META = {
|
|
||||||
rose: {emoji:'🌹', name:'Rose', domain:'Orchestrator & Main Interface'},
|
|
||||||
lotus: {emoji:'🪷', name:'Lotus', domain:'Health, Fitness & Recovery'},
|
|
||||||
'forget-me-not':{emoji:'🌼', name:'Forget-me-not', domain:'Calendar, Time & Social'},
|
|
||||||
sunflower: {emoji:'🌻', name:'Sunflower', domain:'Finance, Wealth & Subscriptions'},
|
|
||||||
iris: {emoji:'⚜️', name:'Iris', domain:'Career, Learning & Focus'},
|
|
||||||
ivy: {emoji:'🌿', name:'Ivy', domain:'Smart Home & Environment'},
|
|
||||||
dandelion: {emoji:'🛡️', name:'Dandelion', domain:'Communication Triage'},
|
|
||||||
root: {emoji:'🌳', name:'Root', domain:'DevOps, Logs & System Health'},
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderAgentDropdown(){
|
function renderAgentDropdown(){
|
||||||
const dd=$('composerAgentDropdown');
|
const dd=$('composerAgentDropdown');
|
||||||
|
if(!dd) return;
|
||||||
const sel=$('agentSelect');
|
const sel=$('agentSelect');
|
||||||
if(!dd||!sel) return;
|
const current=sel?sel.value:'rose';
|
||||||
const current=sel.value;
|
const agents=[
|
||||||
const groups={'Tier-1':['rose'],'Tier-2':['lotus','forget-me-not','sunflower','iris','ivy','dandelion','root']};
|
{id:'rose',emoji:'🌹',name:'Rose',domain:'Orchestrator'},
|
||||||
|
{id:'lotus',emoji:'🪷',name:'Lotus',domain:'Health'},
|
||||||
|
{id:'forget-me-not',emoji:'🌼',name:'Forget-me-not',domain:'Calendar'},
|
||||||
|
{id:'sunflower',emoji:'🌻',name:'Sunflower',domain:'Finance'},
|
||||||
|
{id:'iris',emoji:'⚜️',name:'Iris',domain:'Career'},
|
||||||
|
{id:'ivy',emoji:'🌿',name:'Ivy',domain:'Smart Home'},
|
||||||
|
{id:'dandelion',emoji:'🛡️',name:'Dandelion',domain:'Security'},
|
||||||
|
{id:'root',emoji:'🌳',name:'Root',domain:'DevOps'}
|
||||||
|
];
|
||||||
let html='';
|
let html='';
|
||||||
for(const [grp,ids] of Object.entries(groups)){
|
for(const m of agents){
|
||||||
html+=`<div class="model-group">${grp}</div>`;
|
const active=m.id===current?' active':'';
|
||||||
for(const id of ids){
|
html+=`<div class="agent-opt${active}" onclick="selectAgentFromDropdown('${m.id}')">`;
|
||||||
const m=AGENT_META[id];
|
html+=`<span class="agent-opt-name">${m.emoji} ${m.name}</span>`;
|
||||||
const active=id===current?' active':'';
|
html+=`<span class="agent-opt-domain">${m.domain}</span></div>`;
|
||||||
html+=`<div class="agent-opt${active}" onclick="selectAgentFromDropdown('${id}')">
|
|
||||||
<span class="agent-opt-name">${m.emoji} ${m.name}</span>
|
|
||||||
<span class="agent-opt-domain">${m.domain}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dd.innerHTML=html;
|
dd.innerHTML=html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAgentDropdown(){
|
function toggleAgentDropdown(){
|
||||||
const dd=$('composerAgentDropdown');
|
const dd=$('composerAgentDropdown');
|
||||||
const chip=$('composerAgentChip');
|
const chip=$('composerAgentChip');
|
||||||
if(!dd||!chip) return;
|
if(!dd||!chip) return;
|
||||||
if(dd.classList.contains('open')){
|
if(dd.classList.contains('open')){dd.classList.remove('open');chip.classList.remove('active');return;}
|
||||||
dd.classList.remove('open');
|
|
||||||
chip.classList.remove('active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeModelDropdown();
|
closeModelDropdown();
|
||||||
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
||||||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||||||
renderAgentDropdown();
|
renderAgentDropdown();
|
||||||
dd.classList.add('open');
|
dd.classList.add('open');
|
||||||
chip.classList.add('active');
|
chip.classList.add('active');
|
||||||
// position below chip
|
|
||||||
const chipRect=chip.getBoundingClientRect();
|
const chipRect=chip.getBoundingClientRect();
|
||||||
const wrap=chip.closest('.composer-agent-wrap');
|
const wrap=chip.closest('.composer-agent-wrap');
|
||||||
const wrapRect=wrap.getBoundingClientRect();
|
const wrapRect=wrap.getBoundingClientRect();
|
||||||
dd.style.left=(chipRect.left-wrapRect.left)+'px';
|
dd.style.left=chipRect.left-wrapRect.left+'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAgentDropdown(){
|
function closeAgentDropdown(){
|
||||||
const dd=$('composerAgentDropdown');
|
const dd=$('composerAgentDropdown');
|
||||||
const chip=$('composerAgentChip');
|
const chip=$('composerAgentChip');
|
||||||
if(dd) dd.classList.remove('open');
|
if(dd) dd.classList.remove('open');
|
||||||
if(chip) chip.classList.remove('active');
|
if(chip) chip.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAgentFromDropdown(value){
|
function selectAgentFromDropdown(value){
|
||||||
const sel=$('agentSelect');
|
const sel=$('agentSelect');
|
||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
|
const prevAgent=sel.value;
|
||||||
sel.value=value;
|
sel.value=value;
|
||||||
syncAgentChip();
|
syncAgentChip();
|
||||||
closeAgentDropdown();
|
closeAgentDropdown();
|
||||||
// Save to session / localStorage
|
if(typeof S!=='undefined') S.session={...S.session,agent:value};
|
||||||
if(typeof S!=='undefined'&&S.session) S.session.agent=value;
|
|
||||||
try{localStorage.setItem('hermes-webui-agent',value);}catch(e){}
|
try{localStorage.setItem('hermes-webui-agent',value);}catch(e){}
|
||||||
|
try{localStorage.setItem('hermes.chat_agent',value);}catch(e){}
|
||||||
|
if(prevAgent!==value&&typeof S!=='undefined'&&S.session&&S.messages){
|
||||||
|
const newMeta=AGENT_META[value]||{emoji:'🌹',name:value||'Rose'};
|
||||||
|
const sysMsg={role:'system',content:`\u2190 Switched to ${newMeta.emoji} **${newMeta.name}**`,_ts:Math.floor(Date.now()/1e3)};
|
||||||
|
S.messages.push(sysMsg);
|
||||||
|
if(typeof renderMessages==='function') renderMessages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncAgentChip(){
|
function syncAgentChip(){
|
||||||
const sel=$('agentSelect');
|
const sel=$('agentSelect');
|
||||||
const icon=$('composerAgentIcon');
|
const icon=$('composerAgentIcon');
|
||||||
const label=$('composerAgentLabel');
|
const label=$('composerAgentLabel');
|
||||||
if(!sel||!icon||!label) return;
|
if(!sel||!icon||!label) return;
|
||||||
const m=AGENT_META[sel.value]||AGENT_META.rose;
|
const m=AGENT_META[sel.value]||{emoji:'🌹',name:sel.value||'Rose'};
|
||||||
icon.textContent=m.emoji;
|
icon.textContent=m.emoji;
|
||||||
label.textContent=m.name;
|
label.textContent=m.name;
|
||||||
|
const topIcon=$('agentSelectorIcon');
|
||||||
|
const topLabel=$('agentSelectorLabel');
|
||||||
|
if(topIcon) topIcon.textContent=m.emoji;
|
||||||
|
if(topLabel) topLabel.textContent=m.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init agent chip from localStorage on load
|
|
||||||
window.addEventListener('DOMContentLoaded',()=>{
|
|
||||||
try{
|
|
||||||
const saved=localStorage.getItem('hermes-webui-agent');
|
|
||||||
if(saved){
|
|
||||||
const sel=$('agentSelect');
|
|
||||||
if(sel&&Array.from(sel.options).some(o=>o.value===saved)){
|
|
||||||
sel.value=saved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}catch(e){}
|
|
||||||
syncAgentChip();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click',e=>{
|
document.addEventListener('click',e=>{
|
||||||
if(!e.target.closest('#composerAgentChip') && !e.target.closest('#composerAgentDropdown')) closeAgentDropdown();
|
if(!e.target.closest('#composerModelChip') && !e.target.closest('#composerModelDropdown')) closeModelDropdown();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize',()=>{
|
||||||
|
const dd=$('composerModelDropdown');
|
||||||
|
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||||
@@ -1138,7 +1120,6 @@ function syncTopbar(){
|
|||||||
document.title=window._botName||'Hermes';
|
document.title=window._botName||'Hermes';
|
||||||
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
|
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
|
||||||
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
||||||
else {
|
else {
|
||||||
const sidebarName=$('sidebarWsName');
|
const sidebarName=$('sidebarWsName');
|
||||||
@@ -1149,8 +1130,9 @@ function syncTopbar(){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sessionTitle=S.session.title||t('untitled');
|
const sessionTitle=S.session.title||t('untitled');
|
||||||
$('topbarTitle').textContent=sessionTitle;
|
const agentMeta=S.session.agent?(AGENT_META[S.session.agent]||null):null;
|
||||||
document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes');
|
$('topbarTitle').textContent=(agentMeta?agentMeta.emoji+' ':'')+sessionTitle;
|
||||||
|
document.title=(agentMeta?agentMeta.emoji+' ':'')+sessionTitle+' \u2014 '+(window._botName||'Hermes');
|
||||||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||||||
$('topbarMeta').textContent=t('n_messages',vis.length);
|
$('topbarMeta').textContent=t('n_messages',vis.length);
|
||||||
// If a profile switch just happened, apply its model rather than the session's stale value.
|
// If a profile switch just happened, apply its model rather than the session's stale value.
|
||||||
@@ -1178,7 +1160,6 @@ function syncTopbar(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
|
||||||
// Show Clear button only when session has messages
|
// Show Clear button only when session has messages
|
||||||
const clearBtn=$('btnClearConv');
|
const clearBtn=$('btnClearConv');
|
||||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
||||||
@@ -1390,6 +1371,10 @@ function renderMessages(){
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
// Apply syntax highlighting after DOM is built
|
// Apply syntax highlighting after DOM is built
|
||||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();renderKatexBlocks();});
|
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();renderKatexBlocks();});
|
||||||
|
// Refresh todo panel if it's currently open
|
||||||
|
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||||
|
loadTodos();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolIcon(name){
|
function toolIcon(name){
|
||||||
|
|||||||
2161
static/ui.ts
Normal file
2161
static/ui.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,438 +1,514 @@
|
|||||||
async function api(path,opts={}){
|
(() => {
|
||||||
// Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
|
const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"]);
|
||||||
const rel = path.startsWith('/') ? path.slice(1) : path;
|
const MD_EXTS = /* @__PURE__ */ new Set([".md", ".markdown", ".mdown"]);
|
||||||
const url=new URL(rel,location.href);
|
const DOWNLOAD_EXTS = /* @__PURE__ */ new Set([
|
||||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
".docx",
|
||||||
if(!res.ok){
|
".doc",
|
||||||
const text=await res.text();
|
".xlsx",
|
||||||
// Parse JSON error body and surface the human-readable message,
|
".xls",
|
||||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
".pptx",
|
||||||
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
|
".ppt",
|
||||||
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
|
".odt",
|
||||||
|
".ods",
|
||||||
|
".odp",
|
||||||
|
".pdf",
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
".bz2",
|
||||||
|
".7z",
|
||||||
|
".rar",
|
||||||
|
".mp3",
|
||||||
|
".mp4",
|
||||||
|
".wav",
|
||||||
|
".m4a",
|
||||||
|
".ogg",
|
||||||
|
".flac",
|
||||||
|
".mov",
|
||||||
|
".avi",
|
||||||
|
".mkv",
|
||||||
|
".webm",
|
||||||
|
".exe",
|
||||||
|
".dmg",
|
||||||
|
".pkg",
|
||||||
|
".deb",
|
||||||
|
".rpm",
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
".ttf",
|
||||||
|
".otf",
|
||||||
|
".eot",
|
||||||
|
".bin",
|
||||||
|
".dat",
|
||||||
|
".db",
|
||||||
|
".sqlite",
|
||||||
|
".pyc",
|
||||||
|
".class",
|
||||||
|
".so",
|
||||||
|
".dylib",
|
||||||
|
".dll"
|
||||||
|
]);
|
||||||
|
window.IMAGE_EXTS = IMAGE_EXTS;
|
||||||
|
window.MD_EXTS = MD_EXTS;
|
||||||
|
window.DOWNLOAD_EXTS = DOWNLOAD_EXTS;
|
||||||
|
function fileExt(p) {
|
||||||
|
const i = p.lastIndexOf(".");
|
||||||
|
return i >= 0 ? p.slice(i).toLowerCase() : "";
|
||||||
}
|
}
|
||||||
const ct=res.headers.get('content-type')||'';
|
window.fileExt = fileExt;
|
||||||
return ct.includes('application/json')?res.json():res.text();
|
async function api(path, opts = {}) {
|
||||||
}
|
const rel = path.startsWith("/") ? path.slice(1) : path;
|
||||||
|
const url = new URL(rel, location.href);
|
||||||
// Persist/restore expanded directory state per workspace in localStorage
|
const res = await fetch(url.href, { credentials: "include", headers: { "Content-Type": "application/json" }, ...opts });
|
||||||
function _wsExpandKey(){
|
if (!res.ok) {
|
||||||
const ws=S.session&&S.session.workspace;
|
const text = await res.text();
|
||||||
return ws?'hermes-webui-expanded:'+ws:null;
|
try {
|
||||||
}
|
const j = JSON.parse(text);
|
||||||
function _saveExpandedDirs(){
|
throw new Error(j.error || j.message || text);
|
||||||
const key=_wsExpandKey();if(!key)return;
|
} catch (e) {
|
||||||
try{localStorage.setItem(key,JSON.stringify([...(S._expandedDirs||new Set())]));}catch(e){}
|
if (e instanceof SyntaxError) throw new Error(text);
|
||||||
}
|
throw e;
|
||||||
function _restoreExpandedDirs(){
|
|
||||||
const key=_wsExpandKey();
|
|
||||||
if(!key){S._expandedDirs=new Set();return;}
|
|
||||||
try{
|
|
||||||
const raw=localStorage.getItem(key);
|
|
||||||
S._expandedDirs=raw?new Set(JSON.parse(raw)):new Set();
|
|
||||||
}catch(e){S._expandedDirs=new Set();}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDir(path){
|
|
||||||
if(!S.session)return;
|
|
||||||
try{
|
|
||||||
if(!path||path==='.'){
|
|
||||||
S._dirCache={};
|
|
||||||
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
|
|
||||||
}
|
|
||||||
S.currentDir=path||'.';
|
|
||||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
|
||||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
|
||||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
|
||||||
if(!path||path==='.'){
|
|
||||||
for(const dirPath of (S._expandedDirs||[])){
|
|
||||||
if(!S._dirCache[dirPath]){
|
|
||||||
try{
|
|
||||||
const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
|
|
||||||
S._dirCache[dirPath]=dc.entries||[];
|
|
||||||
}catch(e2){S._dirCache[dirPath]=[];}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
|
|
||||||
}
|
|
||||||
if(typeof clearPreview==='function'){
|
|
||||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
|
||||||
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
|
|
||||||
}else{
|
|
||||||
clearPreview();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fetch git info for workspace root (non-blocking)
|
const ct = res.headers.get("content-type") || "";
|
||||||
if(!path||path==='.') _refreshGitBadge();
|
return ct.includes("application/json") ? res.json() : res.text();
|
||||||
}catch(e){console.warn('loadDir',e);}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _refreshGitBadge(){
|
|
||||||
const badge=$('gitBadge');
|
|
||||||
if(!badge||!S.session)return;
|
|
||||||
try{
|
|
||||||
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
|
|
||||||
if(data.git&&data.git.is_git){
|
|
||||||
const g=data.git;
|
|
||||||
let text=g.branch||'git';
|
|
||||||
if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta
|
|
||||||
if(g.behind>0) text+=` \u2193${g.behind}`;
|
|
||||||
if(g.ahead>0) text+=` \u2191${g.ahead}`;
|
|
||||||
badge.textContent=text;
|
|
||||||
badge.className='git-badge'+(g.dirty>0?' dirty':'');
|
|
||||||
badge.style.display='';
|
|
||||||
} else {
|
|
||||||
badge.style.display='none';
|
|
||||||
badge.textContent='';
|
|
||||||
}
|
|
||||||
}catch(e){badge.style.display='none';}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateUp(){
|
|
||||||
if(!S.session||S.currentDir==='.')return;
|
|
||||||
const parts=S.currentDir.split('/');
|
|
||||||
parts.pop();
|
|
||||||
loadDir(parts.length?parts.join('/'):'.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// File extension sets for preview routing (must match server-side sets)
|
|
||||||
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
|
||||||
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
|
||||||
// Binary formats that should download rather than preview
|
|
||||||
const DOWNLOAD_EXTS = new Set([
|
|
||||||
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
|
||||||
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
|
|
||||||
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
|
|
||||||
'.exe','.dmg','.pkg','.deb','.rpm',
|
|
||||||
'.woff','.woff2','.ttf','.otf','.eot',
|
|
||||||
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
|
|
||||||
|
|
||||||
let _previewCurrentPath = ''; // relative path of currently previewed file
|
|
||||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
|
||||||
let _previewDirty = false; // true when edits are unsaved
|
|
||||||
|
|
||||||
function showPreview(mode){
|
|
||||||
// mode: 'code' | 'image' | 'md'
|
|
||||||
$('previewCode').style.display = mode==='code' ? '' : 'none';
|
|
||||||
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
|
|
||||||
$('previewMd').style.display = mode==='md' ? '' : 'none';
|
|
||||||
$('previewEditArea').style.display = 'none'; // start in read-only
|
|
||||||
const badge=$('previewBadge');
|
|
||||||
badge.className='preview-badge '+mode;
|
|
||||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
|
|
||||||
_previewCurrentMode = mode;
|
|
||||||
_previewDirty = false;
|
|
||||||
updateEditBtn();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEditBtn(){
|
|
||||||
const btn=$('btnEditFile');
|
|
||||||
if(!btn)return;
|
|
||||||
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
|
|
||||||
btn.style.display = editable?'':'none';
|
|
||||||
const editing = $('previewEditArea').style.display!=='none';
|
|
||||||
btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
|
|
||||||
btn.title = editing ? t('save_title') : t('edit_title');
|
|
||||||
btn.style.color = editing ? 'var(--blue)' : '';
|
|
||||||
if(_previewDirty) btn.innerHTML = '💾 Save*';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleEditMode(){
|
|
||||||
const editing = $('previewEditArea').style.display!=='none';
|
|
||||||
if(editing){
|
|
||||||
// Save
|
|
||||||
if(!S.session||!_previewCurrentPath)return;
|
|
||||||
const content=$('previewEditArea').value;
|
|
||||||
try{
|
|
||||||
await api('/api/file/save',{method:'POST',body:JSON.stringify({
|
|
||||||
session_id:S.session.session_id, path:_previewCurrentPath, content
|
|
||||||
})});
|
|
||||||
_previewDirty=false;
|
|
||||||
// Update read-only views
|
|
||||||
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
|
|
||||||
else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }
|
|
||||||
$('previewEditArea').style.display='none';
|
|
||||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
|
||||||
else $('previewMd').style.display='';
|
|
||||||
showToast(t('saved'));
|
|
||||||
}catch(e){setStatus(t('save_failed')+e.message);}
|
|
||||||
}else{
|
|
||||||
// Enter edit mode: populate textarea with current content
|
|
||||||
const currentText = _previewCurrentMode==='code'
|
|
||||||
? $('previewCode').textContent
|
|
||||||
: _previewRawContent||'';
|
|
||||||
$('previewEditArea').value=currentText;
|
|
||||||
$('previewEditArea').style.display='';
|
|
||||||
if(_previewCurrentMode==='code') $('previewCode').style.display='none';
|
|
||||||
else $('previewMd').style.display='none';
|
|
||||||
// Escape cancels the edit without saving
|
|
||||||
$('previewEditArea').onkeydown=e=>{
|
|
||||||
if(e.key==='Escape'){e.preventDefault();cancelEditMode();}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
updateEditBtn();
|
window.api = api;
|
||||||
}
|
function _wsExpandKey() {
|
||||||
|
const ws = S.session && S.session.workspace;
|
||||||
let _previewRawContent = ''; // raw text for md files (to populate editor)
|
return ws ? "hermes-webui-expanded:" + ws : null;
|
||||||
|
|
||||||
function cancelEditMode(){
|
|
||||||
// Discard changes and return to read-only view
|
|
||||||
$('previewEditArea').style.display='none';
|
|
||||||
$('previewEditArea').onkeydown=null;
|
|
||||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
|
||||||
else $('previewMd').style.display='';
|
|
||||||
_previewDirty=false;
|
|
||||||
updateEditBtn();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openFile(path){
|
|
||||||
if(!S.session)return;
|
|
||||||
const ext=fileExt(path);
|
|
||||||
|
|
||||||
// Binary/download-only formats: trigger browser download, don't preview
|
|
||||||
if(DOWNLOAD_EXTS.has(ext)){
|
|
||||||
downloadFile(path);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
function _saveExpandedDirs() {
|
||||||
$('previewPathText').textContent=path;
|
const key = _wsExpandKey();
|
||||||
$('previewArea').classList.add('visible');
|
if (!key) return;
|
||||||
$('fileTree').style.display='none';
|
try {
|
||||||
const wsSearch=$('wsSearchWrap');if(wsSearch)wsSearch.style.display='none';
|
localStorage.setItem(key, JSON.stringify([...S._expandedDirs || /* @__PURE__ */ new Set()]));
|
||||||
|
} catch (e) {
|
||||||
_previewCurrentPath = path;
|
|
||||||
renderFileBreadcrumb(path);
|
|
||||||
if(IMAGE_EXTS.has(ext)){
|
|
||||||
// Image: load via raw endpoint, show as <img>
|
|
||||||
showPreview('image');
|
|
||||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
|
||||||
$('previewImg').alt=path;
|
|
||||||
$('previewImg').src=url;
|
|
||||||
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
|
|
||||||
} else if(MD_EXTS.has(ext)){
|
|
||||||
// Markdown: fetch text, render with renderMd, display as formatted HTML
|
|
||||||
try{
|
|
||||||
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
|
||||||
showPreview('md');
|
|
||||||
_previewRawContent = data.content;
|
|
||||||
$('previewMd').innerHTML=renderMd(data.content);
|
|
||||||
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
|
|
||||||
}catch(e){setStatus(t('file_open_failed'));}
|
|
||||||
} else {
|
|
||||||
// Plain code / text -- but fall back to download if server signals binary
|
|
||||||
try{
|
|
||||||
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
|
||||||
if(data.binary){
|
|
||||||
// Server flagged this as binary content
|
|
||||||
downloadFile(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showPreview('code');
|
|
||||||
// Apply syntax highlighting based on file extension
|
|
||||||
const content = data.content || '';
|
|
||||||
if(['yml','yaml'].includes(ext)){
|
|
||||||
$('previewCode').className='preview-code hl-yaml';
|
|
||||||
if(typeof highlightYAML==='function'){
|
|
||||||
$('previewCode').innerHTML=highlightYAML(content);
|
|
||||||
}else{
|
|
||||||
$('previewCode').innerHTML=_highlightWithLineNumbers(content);
|
|
||||||
}
|
|
||||||
}else if(ext==='json'){
|
|
||||||
$('previewCode').className='preview-code hl-json';
|
|
||||||
$('previewCode').innerHTML=_highlightJSON(content);
|
|
||||||
}else if(['py','js','ts','sh','bash','zsh','rb','go','rs','java','c','cpp','h','css','scss','html','xml','sql','r','lua','pl','php','swift','kt','dart'].includes(ext)){
|
|
||||||
$('previewCode').className='preview-code';
|
|
||||||
$('previewCode').textContent=content;
|
|
||||||
requestAnimationFrame(()=>{if(typeof highlightCode==='function')highlightCode();});
|
|
||||||
}else{
|
|
||||||
// txt, toml, cfg, ini, conf, env, log, etc — readable with line numbers
|
|
||||||
$('previewCode').className='preview-code hl-text';
|
|
||||||
$('previewCode').innerHTML=_highlightWithLineNumbers(content);
|
|
||||||
}
|
|
||||||
}catch(e){
|
|
||||||
// If it's a 400/too-large error, offer download instead
|
|
||||||
downloadFile(path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
function _restoreExpandedDirs() {
|
||||||
|
const key = _wsExpandKey();
|
||||||
function downloadFile(path){
|
if (!key) {
|
||||||
if(!S.session)return;
|
S._expandedDirs = /* @__PURE__ */ new Set();
|
||||||
// Trigger browser download via the raw file endpoint with content-disposition attachment
|
|
||||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
|
||||||
const filename=path.split('/').pop();
|
|
||||||
const a=document.createElement('a');
|
|
||||||
a.href=url;a.download=filename;
|
|
||||||
document.body.appendChild(a);a.click();
|
|
||||||
setTimeout(()=>document.body.removeChild(a),100);
|
|
||||||
showToast(t('downloading',filename),2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Render breadcrumb for file preview mode ──────────────────────────────────
|
|
||||||
function renderFileBreadcrumb(filePath) {
|
|
||||||
const bar = $('breadcrumbBar');
|
|
||||||
if (!bar) return;
|
|
||||||
bar.style.display = 'flex';
|
|
||||||
const upBtn = $('btnUpDir');
|
|
||||||
if (upBtn) upBtn.style.display = '';
|
|
||||||
|
|
||||||
bar.innerHTML = '';
|
|
||||||
// Root
|
|
||||||
const root = document.createElement('span');
|
|
||||||
root.className = 'breadcrumb-seg breadcrumb-link';
|
|
||||||
root.textContent = '~';
|
|
||||||
root.onclick = () => { clearPreview(); loadDir('.'); };
|
|
||||||
bar.appendChild(root);
|
|
||||||
|
|
||||||
const parts = filePath.split('/');
|
|
||||||
let accumulated = '';
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
|
||||||
const sep = document.createElement('span');
|
|
||||||
sep.className = 'breadcrumb-sep';
|
|
||||||
sep.textContent = '/';
|
|
||||||
bar.appendChild(sep);
|
|
||||||
|
|
||||||
accumulated += (accumulated ? '/' : '') + parts[i];
|
|
||||||
const seg = document.createElement('span');
|
|
||||||
seg.textContent = parts[i];
|
|
||||||
if (i < parts.length - 1) {
|
|
||||||
seg.className = 'breadcrumb-seg breadcrumb-link';
|
|
||||||
const target = accumulated;
|
|
||||||
seg.onclick = () => { clearPreview(); loadDir(target); };
|
|
||||||
} else {
|
|
||||||
seg.className = 'breadcrumb-seg breadcrumb-current';
|
|
||||||
}
|
|
||||||
bar.appendChild(seg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Syntax highlighting helpers for file preview ──
|
|
||||||
function _escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
||||||
|
|
||||||
function _highlightWithLineNumbers(text){
|
|
||||||
return text.split('\n').map(line=>'<span class="code-line">'+_escHtml(line)+'</span>').join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _highlightJSON(text){
|
|
||||||
try{
|
|
||||||
const pretty=JSON.stringify(JSON.parse(text),null,2);
|
|
||||||
return pretty.split('\n').map(raw=>{
|
|
||||||
let line=_escHtml(raw);
|
|
||||||
// Highlight keys
|
|
||||||
line=line.replace(/^(\s*)(")([\w\s.\/\-_@:]+)(")(\s*:)/,'$1<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
|
|
||||||
// Highlight string values (after colon)
|
|
||||||
line=line.replace(/(:\s*)("[^&]*?")/g,'$1<span class="hl-string">$2</span>');
|
|
||||||
// Highlight numbers
|
|
||||||
line=line.replace(/:\s*(\d+\.?\d*)/g,': <span class="hl-number">$1</span>');
|
|
||||||
// Highlight booleans / null
|
|
||||||
line=line.replace(/:\s*(true|false|null)/g,': <span class="hl-bool">$1</span>');
|
|
||||||
return '<span class="code-line">'+line+'</span>';
|
|
||||||
}).join('\n');
|
|
||||||
}catch(e){
|
|
||||||
return _highlightWithLineNumbers(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Workspace file search (server-side recursive) ──
|
|
||||||
let _wsSearchTimer=null;
|
|
||||||
function filterWsFiles(){
|
|
||||||
// Debounce: wait 300ms after last keystroke
|
|
||||||
clearTimeout(_wsSearchTimer);
|
|
||||||
_wsSearchTimer=setTimeout(_doWsSearch,300);
|
|
||||||
}
|
|
||||||
async function _doWsSearch(){
|
|
||||||
const input=$('wsSearchInput');
|
|
||||||
const clearBtn=$('wsSearchClear');
|
|
||||||
const tree=$('fileTree');
|
|
||||||
if(!input||!tree)return;
|
|
||||||
const query=input.value.trim().toLowerCase();
|
|
||||||
if(clearBtn)clearBtn.classList.toggle('visible',query.length>0);
|
|
||||||
// Remove any stale "no results" message
|
|
||||||
const oldNoRes=tree.querySelector('.ws-no-results');
|
|
||||||
if(oldNoRes)oldNoRes.remove();
|
|
||||||
// If empty query, restore original file tree
|
|
||||||
if(!query){
|
|
||||||
if(typeof renderFileTree==='function')renderFileTree();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Not searchable without a workspace
|
|
||||||
if(!S.session||!S.session.workspace)return;
|
|
||||||
// Show loading indicator
|
|
||||||
tree.innerHTML='<div class="ws-no-results" style="opacity:.5">Suche...</div>';
|
|
||||||
try{
|
|
||||||
// Ask server to search recursively
|
|
||||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`);
|
|
||||||
const results=data.entries||[];
|
|
||||||
if(!results.length){
|
|
||||||
tree.innerHTML='<div class="ws-no-results">Keine Dateien gefunden</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Render flat result list with path info
|
try {
|
||||||
tree.innerHTML='';
|
const raw = localStorage.getItem(key);
|
||||||
for(const item of results){
|
S._expandedDirs = raw ? new Set(JSON.parse(raw)) : /* @__PURE__ */ new Set();
|
||||||
const el=document.createElement('div');
|
} catch (e) {
|
||||||
el.className='file-item';
|
S._expandedDirs = /* @__PURE__ */ new Set();
|
||||||
el.style.paddingLeft='10px';
|
|
||||||
const iconEl=document.createElement('span');
|
|
||||||
iconEl.className='file-icon';
|
|
||||||
iconEl.innerHTML=fileIcon(item.name,item.type);
|
|
||||||
el.appendChild(iconEl);
|
|
||||||
const nameEl=document.createElement('span');
|
|
||||||
nameEl.className='file-name';
|
|
||||||
nameEl.textContent=item.name;
|
|
||||||
el.appendChild(nameEl);
|
|
||||||
// Show relative path as hint
|
|
||||||
if(item.path){
|
|
||||||
const pathEl=document.createElement('span');
|
|
||||||
pathEl.className='file-size';
|
|
||||||
pathEl.style.opacity='.4';
|
|
||||||
const dir=item.path.substring(0,item.path.lastIndexOf('/'));
|
|
||||||
pathEl.textContent=dir||'.';
|
|
||||||
el.appendChild(pathEl);
|
|
||||||
}
|
|
||||||
if(item.type==='dir'){
|
|
||||||
el.onclick=()=>{clearWsSearch();loadDir(item.path);};
|
|
||||||
}else{
|
|
||||||
el.onclick=()=>openFile(item.path);
|
|
||||||
}
|
|
||||||
tree.appendChild(el);
|
|
||||||
}
|
}
|
||||||
}catch(e){
|
}
|
||||||
// Fallback: client-side filter on currently visible items
|
async function loadDir(path) {
|
||||||
tree.innerHTML='';
|
if (!S.session) return;
|
||||||
const allItems=S.entries||[];
|
try {
|
||||||
const matches=allItems.filter(it=>it.name.toLowerCase().includes(query));
|
if (!path || path === ".") {
|
||||||
if(!matches.length){
|
S._dirCache = {};
|
||||||
tree.innerHTML='<div class="ws-no-results">Keine Dateien gefunden</div>';
|
_restoreExpandedDirs();
|
||||||
}else{
|
}
|
||||||
for(const item of matches){
|
S.currentDir = path || ".";
|
||||||
const el=document.createElement('div');
|
const data = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||||
el.className='file-item';el.style.paddingLeft='10px';
|
S.entries = data.entries || [];
|
||||||
el.innerHTML='<span class="file-icon">'+fileIcon(item.name,item.type)+'</span><span class="file-name">'+item.name+'</span>';
|
renderBreadcrumb();
|
||||||
el.onclick=item.type==='dir'?()=>{clearWsSearch();loadDir(item.path);}:()=>openFile(item.path);
|
renderFileTree();
|
||||||
tree.appendChild(el);
|
if (!path || path === ".") {
|
||||||
|
for (const dirPath of S._expandedDirs || []) {
|
||||||
|
if (!S._dirCache[dirPath]) {
|
||||||
|
try {
|
||||||
|
const dc = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
|
||||||
|
S._dirCache[dirPath] = dc.entries || [];
|
||||||
|
} catch (e2) {
|
||||||
|
S._dirCache[dirPath] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (S._expandedDirs && S._expandedDirs.size > 0) renderFileTree();
|
||||||
|
}
|
||||||
|
if (typeof clearPreview === "function") {
|
||||||
|
if (typeof _previewDirty !== "undefined" && _previewDirty) {
|
||||||
|
showConfirmDialog({ title: t("unsaved_confirm"), message: "", confirmLabel: "Discard", danger: true, focusCancel: true }).then((ok) => {
|
||||||
|
if (ok) clearPreview();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!path || path === ".") _refreshGitBadge();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("loadDir", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function _refreshGitBadge() {
|
||||||
|
const badge = $("gitBadge");
|
||||||
|
if (!badge || !S.session) return;
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||||
|
if (data.git && data.git.is_git) {
|
||||||
|
const g = data.git;
|
||||||
|
let text = g.branch || "git";
|
||||||
|
if ((g.dirty ?? 0) > 0) text += ` \xB7 ${g.dirty}\u2206`;
|
||||||
|
if ((g.behind ?? 0) > 0) text += ` \u2193${g.behind}`;
|
||||||
|
if ((g.ahead ?? 0) > 0) text += ` \u2191${g.ahead}`;
|
||||||
|
badge.textContent = text;
|
||||||
|
badge.className = "git-badge" + ((g.dirty ?? 0) > 0 ? " dirty" : "");
|
||||||
|
badge.style.display = "";
|
||||||
|
} else {
|
||||||
|
badge.style.display = "none";
|
||||||
|
badge.textContent = "";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (badge) badge.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function navigateUp() {
|
||||||
|
if (!S.session || S.currentDir === ".") return;
|
||||||
|
const parts = S.currentDir.split("/");
|
||||||
|
parts.pop();
|
||||||
|
loadDir(parts.length ? parts.join("/") : ".");
|
||||||
|
}
|
||||||
|
let _previewCurrentPath = "";
|
||||||
|
let _previewCurrentMode = "";
|
||||||
|
let _previewDirty = false;
|
||||||
|
let _previewRawContent = "";
|
||||||
|
function showPreview(mode) {
|
||||||
|
const codeEl = $("previewCode");
|
||||||
|
const imgWrap = $("previewImgWrap");
|
||||||
|
const mdEl = $("previewMd");
|
||||||
|
const editEl = $("previewEditArea");
|
||||||
|
const badge = $("previewBadge");
|
||||||
|
if (codeEl) codeEl.style.display = mode === "code" ? "" : "none";
|
||||||
|
if (imgWrap) imgWrap.style.display = mode === "image" ? "" : "none";
|
||||||
|
if (mdEl) mdEl.style.display = mode === "md" ? "" : "none";
|
||||||
|
if (editEl) editEl.style.display = "none";
|
||||||
|
if (badge) {
|
||||||
|
badge.className = "preview-badge " + mode;
|
||||||
|
const pathText = $("previewPathText");
|
||||||
|
badge.textContent = mode === "image" ? "image" : mode === "md" ? "md" : fileExt(pathText?.textContent || "");
|
||||||
|
}
|
||||||
|
_previewCurrentMode = mode;
|
||||||
|
_previewDirty = false;
|
||||||
|
updateEditBtn();
|
||||||
|
}
|
||||||
|
function updateEditBtn() {
|
||||||
|
const btn = $("btnEditFile");
|
||||||
|
if (!btn) return;
|
||||||
|
const editable = _previewCurrentMode === "code" || _previewCurrentMode === "md";
|
||||||
|
btn.style.display = editable ? "" : "none";
|
||||||
|
const editing = $("previewEditArea")?.style.display !== "none";
|
||||||
|
btn.innerHTML = editing ? `💾 ${t("save")}` : `✎ ${t("edit")}`;
|
||||||
|
btn.title = editing ? t("save_title") : t("edit_title");
|
||||||
|
btn.style.color = editing ? "var(--blue)" : "";
|
||||||
|
if (_previewDirty) btn.innerHTML = "💾 Save*";
|
||||||
|
}
|
||||||
|
async function toggleEditMode() {
|
||||||
|
const editEl = $("previewEditArea");
|
||||||
|
const editing = editEl && editEl.style.display !== "none";
|
||||||
|
if (editing) {
|
||||||
|
if (!S.session || !_previewCurrentPath) return;
|
||||||
|
const content = editEl.value;
|
||||||
|
try {
|
||||||
|
await api("/api/file/save", { method: "POST", body: JSON.stringify({ session_id: S.session.session_id, path: _previewCurrentPath, content }) });
|
||||||
|
_previewDirty = false;
|
||||||
|
const codeEl = $("previewCode");
|
||||||
|
const mdEl = $("previewMd");
|
||||||
|
if (_previewCurrentMode === "code" && codeEl) codeEl.textContent = content;
|
||||||
|
else if (mdEl) {
|
||||||
|
mdEl.innerHTML = renderMd(content);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (typeof renderKatexBlocks === "function") renderKatexBlocks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editEl) editEl.style.display = "none";
|
||||||
|
if (codeEl && _previewCurrentMode === "code") codeEl.style.display = "";
|
||||||
|
else if (mdEl) mdEl.style.display = "";
|
||||||
|
showToast(t("saved"));
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(t("save_failed") + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentText = _previewCurrentMode === "code" ? $("previewCode")?.textContent || "" : _previewRawContent || "";
|
||||||
|
if (editEl) {
|
||||||
|
editEl.value = currentText;
|
||||||
|
editEl.style.display = "";
|
||||||
|
}
|
||||||
|
const codeEl = $("previewCode");
|
||||||
|
const mdEl = $("previewMd");
|
||||||
|
if (codeEl && _previewCurrentMode === "code") codeEl.style.display = "none";
|
||||||
|
else if (mdEl) mdEl.style.display = "none";
|
||||||
|
editEl.onkeydown = (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
updateEditBtn();
|
||||||
|
}
|
||||||
|
function cancelEditMode() {
|
||||||
|
const editEl = $("previewEditArea");
|
||||||
|
if (editEl) {
|
||||||
|
editEl.style.display = "none";
|
||||||
|
editEl.onkeydown = null;
|
||||||
|
}
|
||||||
|
const codeEl = $("previewCode");
|
||||||
|
const mdEl = $("previewMd");
|
||||||
|
if (codeEl && _previewCurrentMode === "code") codeEl.style.display = "";
|
||||||
|
else if (mdEl) mdEl.style.display = "";
|
||||||
|
_previewDirty = false;
|
||||||
|
updateEditBtn();
|
||||||
|
}
|
||||||
|
async function openFile(path) {
|
||||||
|
if (!S.session) return;
|
||||||
|
const ext = fileExt(path);
|
||||||
|
if (DOWNLOAD_EXTS.has(ext)) {
|
||||||
|
downloadFile(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewPathText = $("previewPathText");
|
||||||
|
const previewArea = $("previewArea");
|
||||||
|
const fileTree = $("fileTree");
|
||||||
|
const wsSearch = $("wsSearchWrap");
|
||||||
|
if (previewPathText) previewPathText.textContent = path;
|
||||||
|
previewArea?.classList.add("visible");
|
||||||
|
if (fileTree) fileTree.style.display = "none";
|
||||||
|
if (wsSearch) wsSearch.style.display = "none";
|
||||||
|
_previewCurrentPath = path;
|
||||||
|
renderFileBreadcrumb(path);
|
||||||
|
if (IMAGE_EXTS.has(ext)) {
|
||||||
|
showPreview("image");
|
||||||
|
const url = `api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
||||||
|
const previewImg = $("previewImg");
|
||||||
|
if (previewImg) {
|
||||||
|
previewImg.alt = path;
|
||||||
|
previewImg.src = url;
|
||||||
|
previewImg.onerror = () => setStatus(t("image_load_failed"));
|
||||||
|
}
|
||||||
|
} else if (MD_EXTS.has(ext)) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||||
|
showPreview("md");
|
||||||
|
_previewRawContent = data.content || "";
|
||||||
|
const mdEl = $("previewMd");
|
||||||
|
if (mdEl) mdEl.innerHTML = renderMd(data.content || "");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (typeof renderKatexBlocks === "function") renderKatexBlocks();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(t("file_open_failed"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||||
|
if (data.binary) {
|
||||||
|
downloadFile(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPreview("code");
|
||||||
|
const content = data.content || "";
|
||||||
|
const codeEl = $("previewCode");
|
||||||
|
if (["yml", "yaml"].includes(ext)) {
|
||||||
|
if (codeEl) {
|
||||||
|
codeEl.className = "preview-code hl-yaml";
|
||||||
|
codeEl.innerHTML = typeof highlightYAML === "function" ? highlightYAML(content) : _highlightWithLineNumbers(content);
|
||||||
|
}
|
||||||
|
} else if (ext === "json") {
|
||||||
|
if (codeEl) {
|
||||||
|
codeEl.className = "preview-code hl-json";
|
||||||
|
codeEl.innerHTML = _highlightJSON(content);
|
||||||
|
}
|
||||||
|
} else if (["py", "js", "ts", "sh", "bash", "zsh", "rb", "go", "rs", "java", "c", "cpp", "h", "css", "scss", "html", "xml", "sql", "r", "lua", "pl", "php", "swift", "kt", "dart"].includes(ext)) {
|
||||||
|
if (codeEl) {
|
||||||
|
codeEl.className = "preview-code";
|
||||||
|
codeEl.textContent = content;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (typeof highlightCode === "function") highlightCode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (codeEl) {
|
||||||
|
codeEl.className = "preview-code hl-text";
|
||||||
|
codeEl.innerHTML = _highlightWithLineNumbers(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
downloadFile(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
function downloadFile(path) {
|
||||||
|
if (!S.session) return;
|
||||||
// Toggle workspace search visibility
|
const url = `api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
||||||
function toggleWsSearch(){
|
const filename = path.split("/").pop() || path;
|
||||||
const wrap=$('wsSearchWrap');
|
const a = document.createElement("a");
|
||||||
if(!wrap)return;
|
a.href = url;
|
||||||
// Show/hide the search bar
|
a.download = filename;
|
||||||
wrap.style.display=wrap.style.display==='none'?'flex':'none';
|
document.body.appendChild(a);
|
||||||
// Focus input when showing
|
a.click();
|
||||||
setTimeout(()=>{if(wrap.style.display!=='none'){const inp=$('wsSearchInput');if(inp)inp.focus();}},50);
|
setTimeout(() => document.body.removeChild(a), 100);
|
||||||
}
|
showToast(t("downloading", filename), 2e3);
|
||||||
|
}
|
||||||
function clearWsSearch(){
|
function renderFileBreadcrumb(filePath) {
|
||||||
const input=$('wsSearchInput');
|
const bar = $("breadcrumbBar");
|
||||||
if(input)input.value='';
|
if (!bar) return;
|
||||||
const clearBtn=$('wsSearchClear');
|
bar.style.display = "flex";
|
||||||
if(clearBtn)clearBtn.classList.remove('visible');
|
const upBtn = $("btnUpDir");
|
||||||
if(typeof renderFileTree==='function')renderFileTree();
|
if (upBtn) upBtn.style.display = "";
|
||||||
}
|
bar.innerHTML = "";
|
||||||
|
const root = document.createElement("span");
|
||||||
|
root.className = "breadcrumb-seg breadcrumb-link";
|
||||||
|
root.textContent = "~";
|
||||||
|
root.onclick = () => {
|
||||||
|
clearPreview();
|
||||||
|
loadDir(".");
|
||||||
|
};
|
||||||
|
bar.appendChild(root);
|
||||||
|
const parts = filePath.split("/");
|
||||||
|
let accumulated = "";
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const sep = document.createElement("span");
|
||||||
|
sep.className = "breadcrumb-sep";
|
||||||
|
sep.textContent = "/";
|
||||||
|
bar.appendChild(sep);
|
||||||
|
accumulated += (accumulated ? "/" : "") + parts[i];
|
||||||
|
const seg = document.createElement("span");
|
||||||
|
seg.textContent = parts[i];
|
||||||
|
if (i < parts.length - 1) {
|
||||||
|
seg.className = "breadcrumb-seg breadcrumb-link";
|
||||||
|
const target = accumulated;
|
||||||
|
seg.onclick = () => {
|
||||||
|
clearPreview();
|
||||||
|
loadDir(target);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
seg.className = "breadcrumb-seg breadcrumb-current";
|
||||||
|
}
|
||||||
|
bar.appendChild(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _escHtml(s) {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function _highlightWithLineNumbers(text) {
|
||||||
|
return text.split("\n").map((line) => '<span class="code-line">' + _escHtml(line) + "</span>").join("\n");
|
||||||
|
}
|
||||||
|
function _highlightJSON(text) {
|
||||||
|
try {
|
||||||
|
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
return pretty.split("\n").map((raw) => {
|
||||||
|
let line = _escHtml(raw);
|
||||||
|
line = line.replace(/^(\s*)(")([\w\s.\/\-_@:]+)(")(\s*:)/, '$1<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
|
||||||
|
line = line.replace(/(:\s*)("[^&]*?")/g, '$1<span class="hl-string">$2</span>');
|
||||||
|
line = line.replace(/:\s*(\d+\.?\d*)/g, ': <span class="hl-number">$1</span>');
|
||||||
|
line = line.replace(/:\s*(true|false|null)/g, ': <span class="hl-bool">$1</span>');
|
||||||
|
return '<span class="code-line">' + line + "</span>";
|
||||||
|
}).join("\n");
|
||||||
|
} catch {
|
||||||
|
return _highlightWithLineNumbers(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _wsSearchTimer = null;
|
||||||
|
function filterWsFiles() {
|
||||||
|
clearTimeout(_wsSearchTimer ?? void 0);
|
||||||
|
_wsSearchTimer = setTimeout(_doWsSearch, 300);
|
||||||
|
}
|
||||||
|
async function _doWsSearch() {
|
||||||
|
const input = $("wsSearchInput");
|
||||||
|
const clearBtn = $("wsSearchClear");
|
||||||
|
const tree = $("fileTree");
|
||||||
|
if (!input || !tree) return;
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
if (clearBtn) clearBtn.classList.toggle("visible", query.length > 0);
|
||||||
|
const oldNoRes = tree.querySelector(".ws-no-results");
|
||||||
|
if (oldNoRes) oldNoRes.remove();
|
||||||
|
if (!query) {
|
||||||
|
if (typeof renderFileTree === "function") renderFileTree();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!S.session || !S.session.workspace) return;
|
||||||
|
tree.innerHTML = '<div class="ws-no-results" style="opacity:.5">Suche...</div>';
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`);
|
||||||
|
const results = data.entries || [];
|
||||||
|
if (!results.length) {
|
||||||
|
tree.innerHTML = '<div class="ws-no-results">Keine Dateien gefunden</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tree.innerHTML = "";
|
||||||
|
for (const item of results) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "file-item";
|
||||||
|
el.style.paddingLeft = "10px";
|
||||||
|
const iconEl = document.createElement("span");
|
||||||
|
iconEl.className = "file-icon";
|
||||||
|
iconEl.innerHTML = fileIcon(item.name, item.type);
|
||||||
|
el.appendChild(iconEl);
|
||||||
|
const nameEl = document.createElement("span");
|
||||||
|
nameEl.className = "file-name";
|
||||||
|
nameEl.textContent = item.name;
|
||||||
|
el.appendChild(nameEl);
|
||||||
|
if (item.path) {
|
||||||
|
const pathEl = document.createElement("span");
|
||||||
|
pathEl.className = "file-size";
|
||||||
|
pathEl.style.opacity = ".4";
|
||||||
|
const dir = item.path.substring(0, item.path.lastIndexOf("/"));
|
||||||
|
pathEl.textContent = dir || ".";
|
||||||
|
el.appendChild(pathEl);
|
||||||
|
}
|
||||||
|
if (item.type === "dir") el.onclick = () => {
|
||||||
|
clearWsSearch();
|
||||||
|
loadDir(item.path);
|
||||||
|
};
|
||||||
|
else el.onclick = () => openFile(item.path);
|
||||||
|
tree.appendChild(el);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
tree.innerHTML = "";
|
||||||
|
const allItems = S.entries || [];
|
||||||
|
const matches = allItems.filter((it) => it.name.toLowerCase().includes(query));
|
||||||
|
if (!matches.length) tree.innerHTML = '<div class="ws-no-results">Keine Dateien gefunden</div>';
|
||||||
|
else {
|
||||||
|
for (const item of matches) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "file-item";
|
||||||
|
el.style.paddingLeft = "10px";
|
||||||
|
el.innerHTML = '<span class="file-icon">' + fileIcon(item.name, item.type) + '</span><span class="file-name">' + item.name + "</span>";
|
||||||
|
el.onclick = item.type === "dir" ? () => {
|
||||||
|
clearWsSearch();
|
||||||
|
loadDir(item.path);
|
||||||
|
} : () => openFile(item.path);
|
||||||
|
tree.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleWsSearch() {
|
||||||
|
const wrap = $("wsSearchWrap");
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.style.display = wrap.style.display === "none" ? "flex" : "none";
|
||||||
|
setTimeout(() => {
|
||||||
|
if (wrap.style.display !== "none") {
|
||||||
|
const inp = $("wsSearchInput");
|
||||||
|
if (inp) inp.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
function clearWsSearch() {
|
||||||
|
const input = $("wsSearchInput");
|
||||||
|
if (input) input.value = "";
|
||||||
|
const clearBtn = $("wsSearchClear");
|
||||||
|
if (clearBtn) clearBtn.classList.remove("visible");
|
||||||
|
if (typeof renderFileTree === "function") renderFileTree();
|
||||||
|
}
|
||||||
|
function refreshWorkspace() {
|
||||||
|
loadDir(".");
|
||||||
|
}
|
||||||
|
// ── Export to window for cross-module access ──
|
||||||
|
window.loadDir = loadDir;
|
||||||
|
window.renderFileTree = renderFileTree;
|
||||||
|
window.toggleWsSearch = toggleWsSearch;
|
||||||
|
window.clearWsSearch = clearWsSearch;
|
||||||
|
window.openFile = openFile;
|
||||||
|
window.showPreview = showPreview;
|
||||||
|
window.refreshWorkspace = refreshWorkspace;
|
||||||
|
})();
|
||||||
|
//# sourceMappingURL=workspace.js.map
|
||||||
|
|||||||
396
static/workspace.ts
Normal file
396
static/workspace.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
/* workspace.ts — File browser, preview, and workspace management */
|
||||||
|
/// <reference path="./global.d.ts" />
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
||||||
|
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
||||||
|
const DOWNLOAD_EXTS = new Set([
|
||||||
|
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
||||||
|
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
|
||||||
|
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
|
||||||
|
'.exe','.dmg','.pkg','.deb','.rpm',
|
||||||
|
'.woff','.woff2','.ttf','.otf','.eot',
|
||||||
|
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function fileExt(p: string): string { const i = p.lastIndexOf('.'); return i >= 0 ? p.slice(i).toLowerCase() : ''; }
|
||||||
|
|
||||||
|
async function api(path: string, opts: RequestInit = {}): Promise<unknown> {
|
||||||
|
const rel = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
const url = new URL(rel, location.href);
|
||||||
|
const res = await fetch(url.href, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...opts });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
try { const j = JSON.parse(text); throw new Error(j.error || j.message || text); }
|
||||||
|
catch (e) { if (e instanceof SyntaxError) throw new Error(text); throw e; }
|
||||||
|
}
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
return ct.includes('application/json') ? res.json() : res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wsExpandKey(): string | null {
|
||||||
|
const ws = S.session && S.session.workspace;
|
||||||
|
return ws ? 'hermes-webui-expanded:' + ws : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveExpandedDirs(): void {
|
||||||
|
const key = _wsExpandKey(); if (!key) return;
|
||||||
|
try { localStorage.setItem(key, JSON.stringify([...(S._expandedDirs || new Set())])); } catch (e) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restoreExpandedDirs(): void {
|
||||||
|
const key = _wsExpandKey();
|
||||||
|
if (!key) { S._expandedDirs = new Set(); return; }
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
S._expandedDirs = raw ? new Set(JSON.parse(raw)) : new Set();
|
||||||
|
} catch (e) { S._expandedDirs = new Set(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDir(path: string): Promise<void> {
|
||||||
|
if (!S.session) return;
|
||||||
|
try {
|
||||||
|
if (!path || path === '.') {
|
||||||
|
S._dirCache = {};
|
||||||
|
_restoreExpandedDirs();
|
||||||
|
}
|
||||||
|
S.currentDir = path || '.';
|
||||||
|
const data = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`) as { entries?: WsEntry[] };
|
||||||
|
S.entries = data.entries || [];
|
||||||
|
renderBreadcrumb();
|
||||||
|
renderFileTree();
|
||||||
|
if (!path || path === '.') {
|
||||||
|
for (const dirPath of (S._expandedDirs || [])) {
|
||||||
|
if (!S._dirCache[dirPath]) {
|
||||||
|
try {
|
||||||
|
const dc = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`) as { entries?: WsEntry[] };
|
||||||
|
S._dirCache[dirPath] = dc.entries || [];
|
||||||
|
} catch (e2) { S._dirCache[dirPath] = []; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (S._expandedDirs && S._expandedDirs.size > 0) renderFileTree();
|
||||||
|
}
|
||||||
|
if (typeof clearPreview === 'function') {
|
||||||
|
if (typeof _previewDirty !== 'undefined' && _previewDirty) {
|
||||||
|
showConfirmDialog({ title: t('unsaved_confirm'), message: '', confirmLabel: 'Discard', danger: true, focusCancel: true }).then(ok => { if (ok) clearPreview(); });
|
||||||
|
} else {
|
||||||
|
clearPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!path || path === '.') _refreshGitBadge();
|
||||||
|
} catch (e) { console.warn('loadDir', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _refreshGitBadge(): Promise<void> {
|
||||||
|
const badge = $('gitBadge');
|
||||||
|
if (!badge || !S.session) return;
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`) as { git?: { is_git?: boolean; branch?: string; dirty?: number; behind?: number; ahead?: number } };
|
||||||
|
if (data.git && data.git.is_git) {
|
||||||
|
const g = data.git;
|
||||||
|
let text = g.branch || 'git';
|
||||||
|
if ((g.dirty ?? 0) > 0) text += ` \u00b7 ${g.dirty}\u2206`;
|
||||||
|
if ((g.behind ?? 0) > 0) text += ` \u2193${g.behind}`;
|
||||||
|
if ((g.ahead ?? 0) > 0) text += ` \u2191${g.ahead}`;
|
||||||
|
badge.textContent = text;
|
||||||
|
badge.className = 'git-badge' + ((g.dirty ?? 0) > 0 ? ' dirty' : '');
|
||||||
|
badge.style.display = '';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
badge.textContent = '';
|
||||||
|
}
|
||||||
|
} catch (e) { if (badge) badge.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateUp(): void {
|
||||||
|
if (!S.session || S.currentDir === '.') return;
|
||||||
|
const parts = S.currentDir.split('/');
|
||||||
|
parts.pop();
|
||||||
|
loadDir(parts.length ? parts.join('/') : '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let _previewCurrentPath = '';
|
||||||
|
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
||||||
|
let _previewDirty = false;
|
||||||
|
let _previewRawContent = '';
|
||||||
|
|
||||||
|
function showPreview(mode: 'code' | 'md' | 'image'): void {
|
||||||
|
const codeEl = $('previewCode') as HTMLElement | null;
|
||||||
|
const imgWrap = $('previewImgWrap') as HTMLElement | null;
|
||||||
|
const mdEl = $('previewMd') as HTMLElement | null;
|
||||||
|
const editEl = $('previewEditArea') as HTMLTextAreaElement | null;
|
||||||
|
const badge = $('previewBadge');
|
||||||
|
if (codeEl) codeEl.style.display = mode === 'code' ? '' : 'none';
|
||||||
|
if (imgWrap) imgWrap.style.display = mode === 'image' ? '' : 'none';
|
||||||
|
if (mdEl) mdEl.style.display = mode === 'md' ? '' : 'none';
|
||||||
|
if (editEl) editEl.style.display = 'none';
|
||||||
|
if (badge) {
|
||||||
|
badge.className = 'preview-badge ' + mode;
|
||||||
|
const pathText = $('previewPathText');
|
||||||
|
badge.textContent = mode === 'image' ? 'image' : mode === 'md' ? 'md' : fileExt(pathText?.textContent || '');
|
||||||
|
}
|
||||||
|
_previewCurrentMode = mode;
|
||||||
|
_previewDirty = false;
|
||||||
|
updateEditBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditBtn(): void {
|
||||||
|
const btn = $('btnEditFile') as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
const editable = _previewCurrentMode === 'code' || _previewCurrentMode === 'md';
|
||||||
|
btn.style.display = editable ? '' : 'none';
|
||||||
|
const editing = ($('previewEditArea') as HTMLTextAreaElement | null)?.style.display !== 'none';
|
||||||
|
btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
|
||||||
|
btn.title = editing ? t('save_title') : t('edit_title');
|
||||||
|
btn.style.color = editing ? 'var(--blue)' : '';
|
||||||
|
if (_previewDirty) btn.innerHTML = '💾 Save*';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEditMode(): Promise<void> {
|
||||||
|
const editEl = $('previewEditArea') as HTMLTextAreaElement | null;
|
||||||
|
const editing = editEl && editEl.style.display !== 'none';
|
||||||
|
if (editing) {
|
||||||
|
if (!S.session || !_previewCurrentPath) return;
|
||||||
|
const content = editEl.value;
|
||||||
|
try {
|
||||||
|
await api('/api/file/save', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, path: _previewCurrentPath, content }) });
|
||||||
|
_previewDirty = false;
|
||||||
|
const codeEl = $('previewCode');
|
||||||
|
const mdEl = $('previewMd');
|
||||||
|
if (_previewCurrentMode === 'code' && codeEl) codeEl.textContent = content;
|
||||||
|
else if (mdEl) { mdEl.innerHTML = renderMd(content); requestAnimationFrame(() => { if (typeof renderKatexBlocks === 'function') renderKatexBlocks(); }); }
|
||||||
|
if (editEl) editEl.style.display = 'none';
|
||||||
|
if (codeEl && _previewCurrentMode === 'code') codeEl.style.display = '';
|
||||||
|
else if (mdEl) mdEl.style.display = '';
|
||||||
|
showToast(t('saved'));
|
||||||
|
} catch (e: unknown) { setStatus(t('save_failed') + (e instanceof Error ? e.message : String(e))); }
|
||||||
|
} else {
|
||||||
|
const currentText = _previewCurrentMode === 'code'
|
||||||
|
? ($('previewCode')?.textContent || '')
|
||||||
|
: _previewRawContent || '';
|
||||||
|
if (editEl) {
|
||||||
|
editEl.value = currentText;
|
||||||
|
editEl.style.display = '';
|
||||||
|
}
|
||||||
|
const codeEl = $('previewCode');
|
||||||
|
const mdEl = $('previewMd');
|
||||||
|
if (codeEl && _previewCurrentMode === 'code') codeEl.style.display = 'none';
|
||||||
|
else if (mdEl) mdEl.style.display = 'none';
|
||||||
|
editEl!.onkeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); cancelEditMode(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
updateEditBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditMode(): void {
|
||||||
|
const editEl = $('previewEditArea') as HTMLTextAreaElement | null;
|
||||||
|
if (editEl) {
|
||||||
|
editEl.style.display = 'none';
|
||||||
|
editEl.onkeydown = null;
|
||||||
|
}
|
||||||
|
const codeEl = $('previewCode');
|
||||||
|
const mdEl = $('previewMd');
|
||||||
|
if (codeEl && _previewCurrentMode === 'code') codeEl.style.display = '';
|
||||||
|
else if (mdEl) mdEl.style.display = '';
|
||||||
|
_previewDirty = false;
|
||||||
|
updateEditBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFile(path: string): Promise<void> {
|
||||||
|
if (!S.session) return;
|
||||||
|
const ext = fileExt(path);
|
||||||
|
if (DOWNLOAD_EXTS.has(ext)) { downloadFile(path); return; }
|
||||||
|
|
||||||
|
const previewPathText = $('previewPathText');
|
||||||
|
const previewArea = $('previewArea');
|
||||||
|
const fileTree = $('fileTree');
|
||||||
|
const wsSearch = $('wsSearchWrap');
|
||||||
|
if (previewPathText) previewPathText.textContent = path;
|
||||||
|
previewArea?.classList.add('visible');
|
||||||
|
if (fileTree) fileTree.style.display = 'none';
|
||||||
|
if (wsSearch) wsSearch.style.display = 'none';
|
||||||
|
|
||||||
|
_previewCurrentPath = path;
|
||||||
|
renderFileBreadcrumb(path);
|
||||||
|
|
||||||
|
if (IMAGE_EXTS.has(ext)) {
|
||||||
|
showPreview('image');
|
||||||
|
const url = `api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
||||||
|
const previewImg = $('previewImg') as HTMLImageElement | null;
|
||||||
|
if (previewImg) { previewImg.alt = path; previewImg.src = url; previewImg.onerror = () => setStatus(t('image_load_failed')); }
|
||||||
|
} else if (MD_EXTS.has(ext)) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`) as { content?: string };
|
||||||
|
showPreview('md');
|
||||||
|
_previewRawContent = data.content || '';
|
||||||
|
const mdEl = $('previewMd');
|
||||||
|
if (mdEl) mdEl.innerHTML = renderMd(data.content || '');
|
||||||
|
requestAnimationFrame(() => { if (typeof renderKatexBlocks === 'function') renderKatexBlocks(); });
|
||||||
|
} catch (e: unknown) { setStatus(t('file_open_failed')); }
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`) as { content?: string; binary?: boolean };
|
||||||
|
if (data.binary) { downloadFile(path); return; }
|
||||||
|
showPreview('code');
|
||||||
|
const content = data.content || '';
|
||||||
|
const codeEl = $('previewCode');
|
||||||
|
if (['yml','yaml'].includes(ext)) {
|
||||||
|
if (codeEl) { codeEl.className = 'preview-code hl-yaml'; codeEl.innerHTML = typeof highlightYAML === 'function' ? highlightYAML(content) : _highlightWithLineNumbers(content); }
|
||||||
|
} else if (ext === 'json') {
|
||||||
|
if (codeEl) { codeEl.className = 'preview-code hl-json'; codeEl.innerHTML = _highlightJSON(content); }
|
||||||
|
} else if (['py','js','ts','sh','bash','zsh','rb','go','rs','java','c','cpp','h','css','scss','html','xml','sql','r','lua','pl','php','swift','kt','dart'].includes(ext)) {
|
||||||
|
if (codeEl) { codeEl.className = 'preview-code'; codeEl.textContent = content; requestAnimationFrame(() => { if (typeof highlightCode === 'function') highlightCode(); }); }
|
||||||
|
} else {
|
||||||
|
if (codeEl) { codeEl.className = 'preview-code hl-text'; codeEl.innerHTML = _highlightWithLineNumbers(content); }
|
||||||
|
}
|
||||||
|
} catch (e: unknown) { downloadFile(path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(path: string): void {
|
||||||
|
if (!S.session) return;
|
||||||
|
const url = `api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
||||||
|
const filename = path.split('/').pop() || path;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a as Node); a.click();
|
||||||
|
setTimeout(() => document.body.removeChild(a as Node), 100);
|
||||||
|
showToast(t('downloading', filename), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileBreadcrumb(filePath: string): void {
|
||||||
|
const bar = $('breadcrumbBar');
|
||||||
|
if (!bar) return;
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
const upBtn = $('btnUpDir');
|
||||||
|
if (upBtn) upBtn.style.display = '';
|
||||||
|
bar.innerHTML = '';
|
||||||
|
const root = document.createElement('span');
|
||||||
|
root.className = 'breadcrumb-seg breadcrumb-link';
|
||||||
|
root.textContent = '~';
|
||||||
|
root.onclick = () => { clearPreview(); loadDir('.'); };
|
||||||
|
bar.appendChild(root);
|
||||||
|
const parts = filePath.split('/');
|
||||||
|
let accumulated = '';
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.className = 'breadcrumb-sep';
|
||||||
|
sep.textContent = '/';
|
||||||
|
bar.appendChild(sep);
|
||||||
|
accumulated += (accumulated ? '/' : '') + parts[i];
|
||||||
|
const seg = document.createElement('span');
|
||||||
|
seg.textContent = parts[i];
|
||||||
|
if (i < parts.length - 1) {
|
||||||
|
seg.className = 'breadcrumb-seg breadcrumb-link';
|
||||||
|
const target = accumulated;
|
||||||
|
seg.onclick = () => { clearPreview(); loadDir(target); };
|
||||||
|
} else {
|
||||||
|
seg.className = 'breadcrumb-seg breadcrumb-current';
|
||||||
|
}
|
||||||
|
bar.appendChild(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _highlightWithLineNumbers(text: string): string {
|
||||||
|
return text.split('\n').map(line => '<span class="code-line">' + _escHtml(line) + '</span>').join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _highlightJSON(text: string): string {
|
||||||
|
try {
|
||||||
|
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
return pretty.split('\n').map(raw => {
|
||||||
|
let line = _escHtml(raw);
|
||||||
|
line = line.replace(/^(\s*)(")([\w\s.\/\-_@:]+)(")(\s*:)/, '$1<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
|
||||||
|
line = line.replace(/(:\s*)("[^&]*?")/g, '$1<span class="hl-string">$2</span>');
|
||||||
|
line = line.replace(/:\s*(\d+\.?\d*)/g, ': <span class="hl-number">$1</span>');
|
||||||
|
line = line.replace(/:\s*(true|false|null)/g, ': <span class="hl-bool">$1</span>');
|
||||||
|
return '<span class="code-line">' + line + '</span>';
|
||||||
|
}).join('\n');
|
||||||
|
} catch { return _highlightWithLineNumbers(text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let _wsSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function filterWsFiles(): void {
|
||||||
|
clearTimeout(_wsSearchTimer ?? undefined);
|
||||||
|
_wsSearchTimer = setTimeout(_doWsSearch, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doWsSearch(): Promise<void> {
|
||||||
|
const input = $('wsSearchInput') as HTMLInputElement | null;
|
||||||
|
const clearBtn = $('wsSearchClear');
|
||||||
|
const tree = $('fileTree');
|
||||||
|
if (!input || !tree) return;
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
if (clearBtn) clearBtn.classList.toggle('visible', query.length > 0);
|
||||||
|
const oldNoRes = tree.querySelector('.ws-no-results');
|
||||||
|
if (oldNoRes) oldNoRes.remove();
|
||||||
|
if (!query) { if (typeof renderFileTree === 'function') renderFileTree(); return; }
|
||||||
|
if (!S.session || !S.session.workspace) return;
|
||||||
|
tree.innerHTML = '<div class="ws-no-results" style="opacity:.5">Suche...</div>';
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`) as { entries?: WsEntry[] };
|
||||||
|
const results = data.entries || [];
|
||||||
|
if (!results.length) { tree.innerHTML = '<div class="ws-no-results">Keine Dateien gefunden</div>'; return; }
|
||||||
|
tree.innerHTML = '';
|
||||||
|
for (const item of results) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'file-item';
|
||||||
|
el.style.paddingLeft = '10px';
|
||||||
|
const iconEl = document.createElement('span');
|
||||||
|
iconEl.className = 'file-icon';
|
||||||
|
iconEl.innerHTML = fileIcon(item.name, item.type);
|
||||||
|
el.appendChild(iconEl as Node);
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.className = 'file-name';
|
||||||
|
nameEl.textContent = item.name;
|
||||||
|
el.appendChild(nameEl as Node);
|
||||||
|
if (item.path) {
|
||||||
|
const pathEl = document.createElement('span');
|
||||||
|
pathEl.className = 'file-size';
|
||||||
|
pathEl.style.opacity = '.4';
|
||||||
|
const dir = item.path.substring(0, item.path.lastIndexOf('/'));
|
||||||
|
pathEl.textContent = dir || '.';
|
||||||
|
el.appendChild(pathEl as Node);
|
||||||
|
}
|
||||||
|
if (item.type === 'dir') el.onclick = () => { clearWsSearch(); loadDir(item.path); };
|
||||||
|
else el.onclick = () => openFile(item.path);
|
||||||
|
tree.appendChild(el);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
tree.innerHTML = '';
|
||||||
|
const allItems = S.entries || [];
|
||||||
|
const matches = allItems.filter((it: WsEntry) => it.name.toLowerCase().includes(query));
|
||||||
|
if (!matches.length) tree.innerHTML = '<div class="ws-no-results">Keine Dateien gefunden</div>';
|
||||||
|
else {
|
||||||
|
for (const item of matches) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'file-item'; el.style.paddingLeft = '10px';
|
||||||
|
el.innerHTML = '<span class="file-icon">' + fileIcon(item.name, item.type) + '</span><span class="file-name">' + item.name + '</span>';
|
||||||
|
el.onclick = item.type === 'dir' ? () => { clearWsSearch(); loadDir(item.path); } : () => openFile(item.path);
|
||||||
|
tree.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWsSearch(): void {
|
||||||
|
const wrap = $('wsSearchWrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.style.display = wrap.style.display === 'none' ? 'flex' : 'none';
|
||||||
|
setTimeout(() => { if (wrap.style.display !== 'none') { const inp = $('wsSearchInput') as HTMLInputElement | null; if (inp) inp.focus(); } }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWsSearch(): void {
|
||||||
|
const input = $('wsSearchInput') as HTMLInputElement | null;
|
||||||
|
if (input) input.value = '';
|
||||||
|
const clearBtn = $('wsSearchClear');
|
||||||
|
if (clearBtn) clearBtn.classList.remove('visible');
|
||||||
|
if (typeof renderFileTree === 'function') renderFileTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { api, loadDir, openFile, downloadFile, filterWsFiles, toggleWsSearch, clearWsSearch, navigateUp, showPreview, toggleEditMode, cancelEditMode, _restoreExpandedDirs, _saveExpandedDirs };
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": false
|
||||||
|
},
|
||||||
|
"include": ["static/**/*.ts", "static/**/*.js"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user