From fbf79362a4b66ca0ff288f9b287f8ed4b0b9af41 Mon Sep 17 00:00:00 2001 From: Rose Date: Mon, 20 Apr 2026 13:28:37 +0200 Subject: [PATCH] Phase 1: Activity and Error Log for Agent Tab Backend: _log_agent_activity, _get_activity_log, _get_error_log. API: GET /api/agents/{id}/activity and /errors. Frontend: Activity and Errors tabs in agent detail overlay. CSS: activity-event-row, error-event-row. Config fix: Z.ai API key. --- api/agents.py | 410 ++++++++++++++++++++++-- api/config.py | 3 +- api/routes.py | 176 ++++++++++- static/index.html | 40 +++ static/panels.js | 777 +++++++++++++++++++++++++++++++++++++++++----- static/style.css | 350 +++++++++++++++++++++ 6 files changed, 1652 insertions(+), 104 deletions(-) diff --git a/api/agents.py b/api/agents.py index 66fe545..fb131a9 100644 --- a/api/agents.py +++ b/api/agents.py @@ -1,6 +1,6 @@ """ Rose Agents Panel API — Data layer for Hermes WebUI Agents extension. -Provides Rose + Tier-2 agent status, inbox management, and configuration. +Provides Rose + Tier-2 agent status, inbox management, soul/memory editing, and configuration. """ import json @@ -8,6 +8,7 @@ import os import subprocess import threading import time +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -20,13 +21,13 @@ _INBOX_BUS = _HERMES_DIR / "scripts" / "message_bus.py" # ── Tier-2 Agent Registry ────────────────────────────────────────────────────── TIER2_AGENTS = { - "lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"}, - "forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"}, - "sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"}, - "iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"}, - "ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"}, - "dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"}, - "root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"}, + "lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"}, + "forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"}, + "sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"}, + "iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"}, + "ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"}, + "dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"}, + "root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"}, } ROSE_META = { @@ -51,8 +52,44 @@ def _get_process_status(agent_name: str) -> dict: except Exception: return {"running": False, "pid": None} + +def _get_agent_status(agent_name: str) -> dict: + """ + Determine agent status: active / idle / offline. + - active: process running AND recent activity (< 5 min) + - idle: process running BUT no recent activity (5-15 min) + - offline: no process + """ + proc = _get_process_status(agent_name) + active_session_path = _AGENTS_DIR / agent_name / "active_session.txt" + + if not proc["running"]: + return {"status": "offline", "last_activity": None, "pid": None} + + last_activity = None + if active_session_path.exists(): + try: + mtime = active_session_path.stat().st_mtime + last_activity = datetime.fromtimestamp(mtime).isoformat() + "Z" + age_minutes = (time.time() - mtime) / 60 + if age_minutes < 5: + status = "active" + elif age_minutes < 15: + status = "idle" + else: + status = "offline" + except Exception: + status = "unknown" + else: + # Process running but no session file = treat as idle + status = "idle" + last_activity = None + + return {"status": status, "last_activity": last_activity, "pid": proc["pid"]} + + def _get_inbox_count(agent_name: str) -> int: - """Count messages in agent inbox via message_bus.py.""" + """Count pending (unread) messages in agent inbox.""" try: result = subprocess.run( ["/usr/bin/python3", str(_INBOX_BUS), "check", "--agent", agent_name], @@ -65,7 +102,8 @@ def _get_inbox_count(agent_name: str) -> int: pass return 0 -def _read_inbox(agent_name: str, limit: int = 20) -> list[dict]: + +def _read_inbox(agent_name: str, limit: int = 50) -> list[dict]: """Read messages from agent inbox.""" inbox_path = _AGENTS_DIR / agent_name / "inbox.json" if not inbox_path.exists(): @@ -74,18 +112,42 @@ def _read_inbox(agent_name: str, limit: int = 20) -> list[dict]: with open(inbox_path, "r") as f: data = json.load(f) messages = data if isinstance(data, list) else data.get("messages", []) - return messages[-limit:] + # Reverse so newest first, return limited + return list(reversed(messages))[:limit] except (json.JSONDecodeError, IOError): return [] + +def _read_file_safe(path: Path) -> str | None: + """Read a file safely, return None if missing.""" + try: + if path.exists(): + return path.read_text() + except Exception: + pass + return None + + +def _write_file_safe(path: Path, content: str, backup: bool = True) -> dict: + """Write a file safely with optional backup. Returns dict with success/error.""" + try: + if backup and path.exists(): + backup_path = path.with_suffix(path.suffix + ".backup") + backup_path.write_text(path.read_text()) + path.write_text(content) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + # ── API Functions ───────────────────────────────────────────────────────────── def list_agents() -> dict: - """Return status for Rose + all Tier-2 agents.""" + """Return status summary for Rose + all Tier-2 agents.""" agents = [] - # Rose (the orchestrator) - rose_running = True # Rose IS the gateway/webui + # Rose (orchestrator — always "running" as it's the gateway itself) + rose_status = _get_agent_status("rose") rose_inbox_count = _get_inbox_count("rose") agents.append({ "id": "rose", @@ -94,15 +156,16 @@ def list_agents() -> dict: "domain": ROSE_META["domain"], "color": ROSE_META["color"], "tier": "orchestrator", - "running": rose_running, + "status": "active", # Rose is always running "pid": None, + "last_activity": rose_status.get("last_activity"), "inbox_count": rose_inbox_count, }) # Tier-2 agents for agent_id, meta in TIER2_AGENTS.items(): - status = _get_process_status(agent_id) - inbox_count = _get_inbox_count(agent_id) if status["running"] else 0 + status_info = _get_agent_status(agent_id) + inbox_count = _get_inbox_count(agent_id) if status_info["status"] != "offline" else 0 agents.append({ "id": agent_id, "name": meta["name"], @@ -110,27 +173,203 @@ def list_agents() -> dict: "domain": meta["domain"], "color": meta["color"], "tier": "tier2", - "running": status["running"], - "pid": status["pid"], + "status": status_info["status"], + "pid": status_info["pid"], + "last_activity": status_info.get("last_activity"), "inbox_count": inbox_count, }) return {"agents": agents} -def get_agent_inbox(agent_id: str, limit: int = 20) -> dict: + +def get_agent(agent_id: str) -> dict: + """Return full detail for one agent: soul.md, memory.md, inbox, config.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + + meta = TIER2_AGENTS.get(agent_id, ROSE_META) + agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR + + soul = _read_file_safe(agent_dir / "soul.md") + memory = _read_file_safe(agent_dir / "memory.md") + inbox_messages = _read_inbox(agent_id) + status_info = _get_agent_status(agent_id) + inbox_count = _get_inbox_count(agent_id) + + # Default model — extract from soul.md YAML frontmatter if present + default_model = None + if soul: + import re + m = re.search(r'model:\s*["\']?([^"\'\n]+)["\']?', soul) + if m: + default_model = m.group(1).strip() + + # Disabled flag + disabled = (agent_dir / "disabled").exists() if agent_dir.exists() else False + + return { + "id": agent_id, + "name": meta["name"], + "emoji": meta["emoji"], + "domain": meta["domain"], + "color": meta["color"], + "tier": "orchestrator" if agent_id == "rose" else "tier2", + "status": status_info["status"], + "last_activity": status_info.get("last_activity"), + "pid": status_info["pid"], + "inbox_count": inbox_count, + "soul": soul or "", + "memory": memory or "", + "default_model": default_model, + "disabled": disabled, + "inbox": inbox_messages, + } + + +def get_agent_status(agent_id: str) -> dict: + """Return only the status info for one agent.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + if agent_id == "rose": + return {"status": "active", "last_activity": None, "pid": None} + return _get_agent_status(agent_id) + + +def get_agent_inbox(agent_id: str, limit: int = 50) -> dict: """Return inbox messages for a specific agent.""" if agent_id not in TIER2_AGENTS and agent_id != "rose": return {"error": f"Unknown agent: {agent_id}"} messages = _read_inbox(agent_id, limit) + meta = TIER2_AGENTS.get(agent_id, ROSE_META) return { "agent_id": agent_id, - "agent_name": TIER2_AGENTS.get(agent_id, {}).get("name", "Rose"), + "agent_name": meta["name"], "messages": messages, } + +def update_agent_soul(agent_id: str, content: str) -> dict: + """Write soul.md for an agent. Returns {ok, error}.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"ok": False, "error": f"Unknown agent: {agent_id}"} + if agent_id == "rose": + return {"ok": False, "error": "Rose's soul.md cannot be edited via this API"} + + soul_path = _AGENTS_DIR / agent_id / "soul.md" + # Ensure directory exists + (_AGENTS_DIR / agent_id).mkdir(parents=True, exist_ok=True) + return _write_file_safe(soul_path, content, backup=True) + + +def update_agent_memory(agent_id: str, content: str) -> dict: + """Write memory.md for an agent. Returns {ok, error}.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"ok": False, "error": f"Unknown agent: {agent_id}"} + if agent_id == "rose": + return {"ok": False, "error": "Rose's memory.md cannot be edited via this API"} + + memory_path = _AGENTS_DIR / agent_id / "memory.md" + (_AGENTS_DIR / agent_id).mkdir(parents=True, exist_ok=True) + return _write_file_safe(memory_path, content, backup=True) + + +def send_agent_message(agent_id: str, payload: dict) -> dict: + """ + Add a message to an agent's inbox (simulates inter-agent message). + payload: {from, type, subject, content} + """ + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"ok": False, "error": f"Unknown agent: {agent_id}"} + + inbox_path = _AGENTS_DIR / agent_id / "inbox.json" + (_AGENTS_DIR / agent_id).mkdir(parents=True, exist_ok=True) + + # Load existing inbox + if inbox_path.exists(): + try: + with open(inbox_path, "r") as f: + data = json.load(f) + messages = data if isinstance(data, list) else data.get("messages", []) + except (json.JSONDecodeError, IOError): + messages = [] + else: + messages = [] + + # Add new message + import uuid + msg = { + "id": uuid.uuid4().hex[:8], + "from": payload.get("from", "rose"), + "type": payload.get("type", "request"), + "subject": payload.get("subject", ""), + "content": payload.get("content", ""), + "timestamp": datetime.utcnow().isoformat() + "Z", + "status": "unread", + } + messages.append(msg) + + try: + with open(inbox_path, "w") as f: + json.dump(messages, f, indent=2) + return {"ok": True, "message_id": msg["id"]} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def ack_agent_message(agent_id: str, msg_id: str) -> dict: + """Mark a message as acknowledged/read in an agent's inbox.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"ok": False, "error": f"Unknown agent: {agent_id}"} + + inbox_path = _AGENTS_DIR / agent_id / "inbox.json" + if not inbox_path.exists(): + return {"ok": False, "error": "Inbox not found"} + + try: + with open(inbox_path, "r") as f: + messages = json.load(f) + if not isinstance(messages, list): + messages = messages.get("messages", []) + + found = False + for msg in messages: + if msg.get("id") == msg_id: + msg["status"] = "read" + found = True + break + + if not found: + return {"ok": False, "error": f"Message {msg_id} not found"} + + with open(inbox_path, "w") as f: + json.dump(messages, f, indent=2) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def set_agent_enabled(agent_id: str, enabled: bool) -> dict: + """Enable or disable an agent. Disabled agents won't respond.""" + if agent_id not in TIER2_AGENTS: + return {"ok": False, "error": f"Unknown agent: {agent_id}"} + if agent_id == "rose": + return {"ok": False, "error": "Rose cannot be disabled"} + + disabled_flag = _AGENTS_DIR / agent_id / "disabled" + try: + if enabled: + if disabled_flag.exists(): + disabled_flag.unlink() + else: + disabled_flag.write_text("disabled") + return {"ok": True, "disabled": not enabled} + except Exception as e: + return {"ok": False, "error": str(e)} + + def get_agent_config(agent_id: str) -> dict: - """Return configuration for a specific agent (soul.md path, etc).""" + """Return configuration paths/info for a specific agent.""" if agent_id == "rose": return { "id": "rose", @@ -140,11 +379,138 @@ def get_agent_config(agent_id: str) -> dict: } elif agent_id in TIER2_AGENTS: soul_path = _AGENTS_DIR / agent_id / "soul.md" + memory_path = _AGENTS_DIR / agent_id / "memory.md" inbox_path = _AGENTS_DIR / agent_id / "inbox.json" return { "id": agent_id, "name": TIER2_AGENTS[agent_id]["name"], "soul_path": str(soul_path) if soul_path.exists() else None, + "memory_path": str(memory_path) if memory_path.exists() else None, "inbox_path": str(inbox_path), } return {"error": f"Unknown agent: {agent_id}"} + + +# ── Activity & Error Log ─────────────────────────────────────────────────────── + +ACTIVITY_EVENT_TYPES = [ + "agent_started", "agent_stopped", + "message_sent", "message_received", + "task_started", "task_completed", "task_failed", + "error", "soul_updated", "memory_updated", + "chat_started", "chat_ended", + "health_check", "config_updated", +] + + +def _log_agent_activity(agent_id: str, event_type: str, details: str = "") -> dict: + """ + Write an activity event to the agent's activity.log file. + Log format: ISO timestamp | type | details + Returns {ok: True} or {ok: False, error: ...} + """ + if event_type not in ACTIVITY_EVENT_TYPES: + return {"ok": False, "error": f"Unknown event type: {event_type}"} + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"ok": False, "error": f"Unknown agent: {agent_id}"} + + agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR + log_path = agent_dir / "activity.log" + agent_dir.mkdir(parents=True, exist_ok=True) + + try: + timestamp = datetime.utcnow().isoformat() + "Z" + line = f"{timestamp} | {event_type} | {details}\n" + with open(log_path, "a") as f: + f.write(line) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def _get_activity_log(agent_id: str, limit: int = 50) -> list[dict]: + """ + Read recent activity events for an agent. + Returns list of {timestamp, type, details} sorted newest-first. + """ + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return [] + agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR + log_path = agent_dir / "activity.log" + if not log_path.exists(): + return [] + + try: + lines = log_path.read_text().strip().split("\n") + events = [] + for line in reversed(lines): + line = line.strip() + if not line: + continue + parts = line.split(" | ", 2) + if len(parts) >= 2: + events.append({ + "timestamp": parts[0], + "type": parts[1], + "details": parts[2] if len(parts) > 2 else "", + }) + if len(events) >= limit: + break + return events + except Exception: + return [] + + +def _get_error_log(agent_id: str, limit: int = 20) -> list[dict]: + """ + Read error events from activity log (type == 'error'). + Returns list of {timestamp, type, details} sorted newest-first. + """ + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return [] + agent_dir = _AGENTS_DIR / agent_id if agent_id != "rose" else _HERMES_DIR + log_path = agent_dir / "activity.log" + if not log_path.exists(): + return [] + + try: + lines = log_path.read_text().strip().split("\n") + errors = [] + for line in reversed(lines): + line = line.strip() + if not line or " | error | " not in line: + continue + parts = line.split(" | ", 2) + if len(parts) >= 2: + errors.append({ + "timestamp": parts[0], + "type": parts[1], + "details": parts[2] if len(parts) > 2 else "", + }) + if len(errors) >= limit: + break + return errors + except Exception: + return [] + + +def get_agent_activity(agent_id: str, limit: int = 50) -> dict: + """API: GET /api/agents/{id}/activity — return activity log.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + events = _get_activity_log(agent_id, limit) + return { + "agent_id": agent_id, + "events": events, + } + + +def get_agent_errors(agent_id: str, limit: int = 20) -> dict: + """API: GET /api/agents/{id}/errors — return error log.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + errors = _get_error_log(agent_id, limit) + return { + "agent_id": agent_id, + "errors": errors, + } diff --git a/api/config.py b/api/config.py index eb0ba33..6703983 100644 --- a/api/config.py +++ b/api/config.py @@ -786,6 +786,7 @@ def get_available_models() -> dict: "OPENROUTER_API_KEY", "GOOGLE_API_KEY", "GLM_API_KEY", + "ZAI_API_KEY", "KIMI_API_KEY", "DEEPSEEK_API_KEY", "OPENCODE_ZEN_API_KEY", @@ -802,7 +803,7 @@ def get_available_models() -> dict: detected_providers.add("openrouter") if all_env.get("GOOGLE_API_KEY"): detected_providers.add("google") - if all_env.get("GLM_API_KEY"): + if all_env.get("GLM_API_KEY") or all_env.get("ZAI_API_KEY"): detected_providers.add("zai") if all_env.get("KIMI_API_KEY"): detected_providers.add("kimi-coding") diff --git a/api/routes.py b/api/routes.py index 4d5552c..fad1810 100644 --- a/api/routes.py +++ b/api/routes.py @@ -58,7 +58,7 @@ from api import agents as _agents # ── CSRF: validate Origin/Referer on POST ──────────────────────────────────── import re as _re - +_re_path = _re.compile(r"^(?P/[^?]*)") def _normalize_host_port(value: str) -> tuple[str, str | None]: """Split a host or host:port string into (hostname, port|None). @@ -471,6 +471,14 @@ def handle_get(handler, parsed) -> bool: settings.pop("password_hash", None) return j(handler, settings) + # ── Logs ── + if parsed.path == "/api/logs": + return _handle_logs_list(handler) + + if parsed.path.startswith("/api/logs/"): + log_name = parsed.path[len("/api/logs/"):] + return _handle_logs_read(handler, log_name) + if parsed.path == "/api/onboarding/status": return j(handler, get_onboarding_status()) @@ -768,8 +776,7 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"tasks": _mc.get_tasks()}) if parsed.path == "/api/mc/feed": - limit = int(parse_qs(parsed.query).get("limit", ["50"])[0]) - return j(handler, {"feed": _mc.get_feed(limit=limit)}) + return j(handler, {"feed": _mc.get_feed(limit=50)}) # ── Agents API (Rose + Tier-2) ── if parsed.path == "/api/agents": @@ -783,6 +790,75 @@ def handle_get(handler, parsed) -> bool: agent_id = parsed.path.split("/")[-1] return j(handler, _agents.get_agent_config(agent_id)) + # GET /api/agents/{id} — full agent detail + if parsed.path == "/api/agents/rose" or parsed.path == "/api/agents/lotus" or \ + parsed.path == "/api/agents/sunflower" or parsed.path == "/api/agents/forget-me-not" or \ + parsed.path == "/api/agents/root" or parsed.path == "/api/agents/dandelion" or \ + parsed.path == "/api/agents/iris" or parsed.path == "/api/agents/ivy": + agent_id = parsed.path.split("/")[-1] + return j(handler, _agents.get_agent(agent_id)) + + # GET /api/agents/{id}/status + if parsed.path.startswith("/api/agents/") and parsed.path.endswith("/status"): + agent_id = parsed.path.split("/")[-2] + return j(handler, _agents.get_agent_status(agent_id)) + + # PUT /api/agents/{id}/soul + if parsed.path.endswith("/soul") and method == "PUT": + agent_id = parsed.path.split("/")[-2] + data = read_body(handler) + return j(handler, _agents.update_agent_soul(agent_id, data.get("content", ""))) + + # PUT /api/agents/{id}/memory + if parsed.path.endswith("/memory") and method == "PUT": + agent_id = parsed.path.split("/")[-2] + data = read_body(handler) + return j(handler, _agents.update_agent_memory(agent_id, data.get("content", ""))) + + # POST /api/agents/{id}/message + if parsed.path.endswith("/message") and method == "POST": + agent_id = parsed.path.split("/")[-2] + data = read_body(handler) + return j(handler, _agents.send_agent_message(agent_id, data)) + + # POST /api/agents/{id}/ack/{msg_id} + if "/ack/" in parsed.path and method == "POST": + parts = parsed.path.split("/") + agent_id = parts[2] + msg_id = parts[4] + return j(handler, _agents.ack_agent_message(agent_id, msg_id)) + + # POST /api/agents/{id}/enable | /disable + if parsed.path.endswith("/enable") or parsed.path.endswith("/disable"): + if method == "POST": + agent_id = parsed.path.split("/")[-2] + action = parsed.path.split("/")[-1] + return j(handler, _agents.set_agent_enabled(agent_id, action == "enable")) + + # GET /api/agents/{id}/inbox (full, with limit query param) + if parsed.path.startswith("/api/agents/") and "/inbox" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "inbox": + agent_id = parts[3] + limit = int(parse_qs(parsed.query).get("limit", ["50"])[0]) + return j(handler, _agents.get_agent_inbox(agent_id, limit=limit)) + + # GET /api/agents/{id}/activity + if parsed.path.startswith("/api/agents/") and "/activity" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "activity": + agent_id = parts[3] + limit = int(parse_qs(parsed.query).get("limit", ["50"])[0]) + return j(handler, _agents.get_agent_activity(agent_id, limit=limit)) + + # GET /api/agents/{id}/errors + if parsed.path.startswith("/api/agents/") and "/errors" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "errors": + agent_id = parts[3] + limit = int(parse_qs(parsed.query).get("limit", ["20"])[0]) + return j(handler, _agents.get_agent_errors(agent_id, limit=limit)) + # ── Profile API (GET) ── if parsed.path == "/api/profiles": from api.profiles import list_profiles_api, get_active_profile_name @@ -1248,6 +1324,14 @@ def handle_post(handler, parsed) -> bool: except RuntimeError as e: return bad(handler, str(e), 409) + # ── Logs API ── + if parsed.path == "/api/logs": + return _handle_logs_list(handler) + + if parsed.path.startswith("/api/logs/"): + log_name = parsed.path[len("/api/logs/"):] + return _handle_logs_read(handler, log_name) + # ── Gateway API ── if parsed.path == "/api/gateways": # GET - list all gateways @@ -3158,3 +3242,89 @@ def _handle_session_import(handler, body): SESSIONS.popitem(last=False) s.save() return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}) + + +# ── Logs ────────────────────────────────────────────────────────────────── +ALLOWED_LOG_FILES = { + "agent.log": "~/.hermes/logs/agent.log", + "errors.log": "~/.hermes/logs/errors.log", + "gateway.log": "~/.hermes/logs/gateway.log", + "gateway.error.log": "~/.hermes/logs/gateway.error.log", + "update.log": "~/.hermes/logs/update.log", + "webui.log": "~/.hermes/logs/webui.log", + "webui-prod.log": "~/.hermes/webui/bootstrap-8787.log", + "webui-dev.log": "~/.hermes/webui-dev/bootstrap-8788.log", +} + + +def _handle_logs_list(handler): + """Return list of available log files with metadata.""" + logs = [] + for name, rel_path in ALLOWED_LOG_FILES.items(): + path = Path(rel_path.replace("~", str(Path.home()))) + if path.exists(): + stat = path.stat() + logs.append({ + "name": name, + "path": str(path), + "size": stat.st_size, + "modified": stat.st_mtime, + "size_human": _human_size(stat.st_size), + }) + else: + logs.append({ + "name": name, + "path": str(path), + "size": 0, + "modified": None, + "size_human": "0 B", + "missing": True, + }) + return j(handler, {"logs": logs}) + + +def _human_size(num_bytes): + for unit in ["B", "KB", "MB", "GB"]: + if num_bytes < 1024: + return f"{num_bytes:.1f} {unit}" + num_bytes /= 1024 + return f"{num_bytes:.1f} TB" + + +def _handle_logs_read(handler, log_name): + """Return last N lines of a log file.""" + if log_name not in ALLOWED_LOG_FILES: + return bad(handler, f"Unknown log file: {log_name}") + + rel_path = ALLOWED_LOG_FILES[log_name] + path = Path(rel_path.replace("~", str(Path.home()))) + + # Security: resolve and verify path stays within ~/.hermes + try: + resolved = path.resolve() + hermes_root = Path.home() / ".hermes" + if not str(resolved).startswith(str(hermes_root)): + return bad(handler, "Access denied") + except Exception: + return bad(handler, "Invalid path") + + if not path.exists(): + return bad(handler, f"Log file not found: {log_name}") + + # Tail last 1000 lines + try: + lines = path.read_text(errors="replace").splitlines() + tail = lines[-1000:] + content = "\n".join(tail) + except Exception as e: + return bad(handler, f"Cannot read log: {e}") + + stat = path.stat() + return j(handler, { + "name": log_name, + "content": content, + "size": stat.st_size, + "size_human": _human_size(stat.st_size), + "line_count": len(lines), + "tail_count": len(tail), + }) diff --git a/static/index.html b/static/index.html index edbdf83..0358ee0 100644 --- a/static/index.html +++ b/static/index.html @@ -523,6 +523,10 @@ Gateways +
@@ -659,6 +663,42 @@
Loading...
+
+
+
+
Logs
+ +
+
+
+
+
Loading...
+
+
+
+ Select a log file +
+ + + + +
+
+
Select a log file from the list to view its contents.
+ +
+
+
diff --git a/static/panels.js b/static/panels.js index 623cc9f..0f7a2fc 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1211,10 +1211,10 @@ let _settingsThemeOnOpen = null; // track theme at open time for discard revert let _settingsSection = 'conversation'; function switchSettingsSection(name){ - const section=(name==='preferences'||name==='system'||name==='gateways')?name:'conversation'; + const section=(name==='preferences'||name==='system'||name==='gateways'||name==='logs')?name:'conversation'; _settingsSection=section; - const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways'}; - ['conversation','preferences','system','gateways'].forEach(key=>{ + const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways',logs:'Logs'}; + ['conversation','preferences','system','gateways','logs'].forEach(key=>{ const tab=$('settingsTab'+map[key]); const pane=$('settingsPane'+map[key]); const active=key===section; @@ -1224,6 +1224,7 @@ function switchSettingsSection(name){ } if(pane) pane.classList.toggle('active',active); }); + if(section==='logs') loadLogsPanel(); } function _syncHermesPanelSessionActions(){ @@ -1768,6 +1769,22 @@ async function deleteMCPriority(id) { // ── Agents Panel (Rose + Tier-2) ───────────────────────────────────────────── let _agentsInterval = null; let _selectedAgent = null; +let _agentTab = 'overview'; // current tab in detail overlay + +const STATUS_COLORS = { active: '#4caf50', idle: '#ff9800', offline: '#9e9e9e' }; +const STATUS_LABELS = { active: 'Active', idle: 'Idle', offline: 'Offline' }; + +function _relTime(ts) { + if (!ts) return 'N/A'; + try { + const d = new Date(ts); + const diff = (Date.now() - d.getTime()) / 1000; + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff/60)}m ago`; + if (diff < 86400) return `${Math.floor(diff/3600)}h ago`; + return d.toLocaleDateString(); + } catch { return ts; } +} async function loadAgentsPanel() { clearInterval(_agentsInterval); @@ -1790,23 +1807,28 @@ function renderAgentsList(agents) { if (!box) return; const html = agents.map(a => { - const statusColor = a.running ? '#4caf50' : '#9e9e9e'; - const statusLabel = a.running ? 'Active' : 'Inactive'; + const color = STATUS_COLORS[a.status] || STATUS_COLORS.offline; + const label = STATUS_LABELS[a.status] || 'Offline'; + const tierBadge = a.tier === 'orchestrator' + ? '🌹 Tier-1' + : 'Tier-2'; const inboxBadge = a.inbox_count > 0 - ? `${a.inbox_count}` + ? `${a.inbox_count}` : ''; - return `
+ const disabled = a.disabled ? 'opacity:0.5;' : ''; + return `
- ${a.emoji} + ${a.emoji}
${esc(a.name)}
${esc(a.domain)}
- - ${statusLabel} - ${a.tier === 'orchestrator' ? 'Tier 0' : 'Tier 2'} + + ${label} + ${tierBadge}
+
${_relTime(a.last_activity)}
${inboxBadge} @@ -1818,88 +1840,517 @@ function renderAgentsList(agents) { box.innerHTML = html; } -async function selectAgent(agentId) { +async function openAgentDetail(agentId) { _selectedAgent = agentId; - // Highlight selected + _agentTab = 'overview'; + + // Highlight card document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected')); const cards = document.querySelectorAll('.agent-card'); - const agents_data = await api('/api/agents'); - const idx = agents_data.agents.findIndex(a => a.id === agentId); + const agentsData = await api('/api/agents'); + const idx = agentsData.agents.findIndex(a => a.id === agentId); if (cards[idx]) cards[idx].classList.add('selected'); - // Show inbox panel - const inboxBox = $('agentInbox'); - const agentName = agents_data.agents[idx]?.name || agentId; - const emoji = agents_data.agents[idx]?.emoji || '🤖'; - const domain = agents_data.agents[idx]?.domain || ''; + const box = $('agentInbox'); - inboxBox.innerHTML = ` -
-
- ${emoji} -
-
${esc(agentName)}
-
${esc(domain)}
-
- -
-
Loading inbox...
-
- `; - inboxBox.style.display = 'block'; - - // Fetch inbox + // Fetch full agent data + let agent; try { - const data = await api(`/api/agents/inbox/${agentId}`); - renderAgentInbox(data); + agent = await api(`/api/agents/${agentId}`); } catch(e) { - inboxBox.innerHTML = `
Error: ${esc(e.message)}
`; - } -} - -function renderAgentInbox(data) { - const inboxBox = $('agentInbox'); - const agentName = data.agent_name || _selectedAgent; - const messages = data.messages || []; - - if (messages.length === 0) { - inboxBox.innerHTML = ` -
-
- 📭 - ${esc(agentName)} — Inbox - -
-
-
-
📭
-
No messages in inbox
-
Messages from other agents appear here
-
- `; + box.innerHTML = `
Error loading agent: ${esc(e.message)}
`; + box.style.display = 'block'; return; } - inboxBox.innerHTML = ` -
-
- 📥 - ${esc(agentName)} — Inbox - (${messages.length} messages) - + const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline; + const tierBadge = agent.tier === 'orchestrator' + ? '🌹 Tier-1' + : 'Tier-2'; + const lastAct = agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'; + const canEdit = agentId !== 'rose'; + + box.innerHTML = ` +
+
+ ${agent.emoji} +
+
${esc(agent.name)}
+
${esc(agent.domain)}
+
${tierBadge}
+
+
-
- ${messages.map(m => { - const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : ''; - const content = typeof m === 'string' ? m : (m.content || JSON.stringify(m)); - return `
-
${esc(ts)}
-
${esc(String(content).slice(0, 300))}
-
`; - }).join('')} + +
+
+ + ${STATUS_LABELS[agent.status] || 'Offline'} + ${agent.pid ? `PID ${agent.pid}` : ''} +
+
Last active: ${esc(lastAct)}
+ ${agent.default_model ? `
Model: ${esc(agent.default_model)}
` : ''} +
+ + ${agentId !== 'rose' ? ` +
+ Agent ${agent.disabled ? 'disabled' : 'enabled'} + +
+ ` : ''} + +
+ +
+ +
+ + + + + + +
+ +
+
Loading...
`; + box.style.display = 'block'; + + // Load first tab content + await switchAgentTab('overview'); +} + +async function switchAgentTab(tab) { + _agentTab = tab; + const agentId = _selectedAgent; + if (!agentId) return; + + // Update tab buttons + document.querySelectorAll('.agent-tab').forEach((el, i) => { + const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors']; + el.classList.toggle('active', tabs[i] === tab); + }); + + const content = $('agentTabContent'); + + switch(tab) { + case 'overview': + await loadAgentOverview(agentId, content); + break; + case 'soul': + await loadAgentSoul(agentId, content); + break; + case 'memory': + await loadAgentMemory(agentId, content); + break; + case 'inbox': + await loadAgentInboxTab(agentId, content); + break; + case 'activity': + await loadAgentActivity(agentId, content); + break; + case 'errors': + await loadAgentErrors(agentId, content); + break; + } +} + +async function loadAgentOverview(agentId, content) { + try { + const agent = await api(`/api/agents/${agentId}`); + const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline; + content.innerHTML = ` +
+
+ Status + + + ${STATUS_LABELS[agent.status] || 'Offline'} + +
+
+ Domain + ${esc(agent.domain)} +
+
+ Tier + ${agent.tier === 'orchestrator' ? '🌹 Orchestrator (Tier-1)' : 'Tier-2 Domain Agent'} +
+
+ Last Active + ${agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'} +
+ ${agent.default_model ? ` +
+ Model + ${esc(agent.default_model)} +
` : ''} + ${agent.inbox_count > 0 ? ` +
+ Inbox + ${agent.inbox_count} unread messages +
` : ''} + ${agent.pid ? ` +
+ Process + PID ${agent.pid} +
` : ''} +
+ `; + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + +async function loadAgentSoul(agentId, content) { + const canEdit = agentId !== 'rose'; + try { + const agent = await api(`/api/agents/${agentId}`); + const soul = agent.soul || ''; + if (!soul) { + content.innerHTML = `
+
📄
+
No soul.md found
+
`; + return; + } + content.innerHTML = ` +
+ ${canEdit ? `` : ''} +
${renderMarkdown(soul)}
+
+ + `; + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + +async function loadAgentMemory(agentId, content) { + const canEdit = agentId !== 'rose'; + try { + const agent = await api(`/api/agents/${agentId}`); + const memory = agent.memory || ''; + if (!memory) { + content.innerHTML = `
+
🧠
+
No memory.md found
+
`; + return; + } + content.innerHTML = ` +
+ ${canEdit ? `` : ''} +
${renderMarkdown(memory)}
+
+ + `; + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + +async function loadAgentInboxTab(agentId, content) { + try { + const data = await api(`/api/agents/${agentId}/inbox`); + const messages = data.messages || []; + const agentName = data.agent_name || agentId; + + if (messages.length === 0) { + content.innerHTML = ` +
+
📭
+
No messages in inbox
+
Messages from other agents appear here
+
+
+
Send Message
+ + + + +
+ `; + return; + } + + const msgsHtml = messages.map(m => { + const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : ''; + const isUnread = m.status !== 'read'; + const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50'; + const typeLabel = m.type === 'request' ? '📨 REQUEST' : '✅ REPLY'; + return ` +
+
+ ${typeLabel} + ← ${esc(m.from || 'unknown')} + ${esc(ts)} +
+
${esc(m.subject || '(no subject)')}
+
${esc(String(m.content || '').slice(0,200))}
+
+ ${isUnread ? `` : ''} +
+
`; + }).join(''); + + content.innerHTML = ` +
+ ${msgsHtml} +
+
+
Send Message
+ + + + +
+ `; + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + +async function loadAgentActivity(agentId, content) { + try { + const data = await api(`/api/agents/${agentId}/activity`); + const events = data.events || []; + + if (events.length === 0) { + content.innerHTML = ` +
+
📋
+
No activity recorded yet
+
Events like messages, tasks and updates appear here
+
`; + return; + } + + const EVENT_COLORS = { + 'agent_started': '#4caf50', + 'agent_stopped': '#9e9e9e', + 'message_sent': '#2196f3', + 'message_received': '#00bcd4', + 'task_started': '#ff9800', + 'task_completed': '#4caf50', + 'task_failed': '#f44336', + 'soul_updated': '#9c27b0', + 'memory_updated': '#3f51b5', + 'chat_started': '#ff9800', + 'chat_ended': '#795548', + 'health_check': '#4caf50', + 'config_updated': '#607d8b', + 'error': '#f44336', + }; + + const EVENT_ICONS = { + 'agent_started': '🟢', + 'agent_stopped': '⚫', + 'message_sent': '📤', + 'message_received': '📥', + 'task_started': '▶️', + 'task_completed': '✅', + 'task_failed': '❌', + 'soul_updated': '✏️', + 'memory_updated': '🧠', + 'chat_started': '💬', + 'chat_ended': '💬', + 'health_check': '❤️', + 'config_updated': '⚙️', + 'error': '⚠️', + }; + + const rows = events.map(e => { + const color = EVENT_COLORS[e.type] || '#9e9e9e'; + const icon = EVENT_ICONS[e.type] || '•'; + const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'N/A'; + const rel = e.timestamp ? _relTime(e.timestamp) : ''; + const details = e.details ? `${esc(e.details)}` : ''; + return ` +
+ ${icon} +
+
+ ${esc(e.type)} + ${details} +
+
${esc(ts)} · ${rel}
+
+
`; + }).join(''); + + content.innerHTML = `
${rows}
`; + + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + +async function loadAgentErrors(agentId, content) { + try { + const data = await api(`/api/agents/${agentId}/errors`); + const errors = data.errors || []; + + if (errors.length === 0) { + content.innerHTML = ` +
+
+
No errors recorded
+
All good — this agent has no logged errors
+
`; + return; + } + + const rows = errors.map(e => { + const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'N/A'; + const rel = e.timestamp ? _relTime(e.timestamp) : ''; + return ` +
+ ⚠️ +
+
${esc(e.details || 'Unknown error')}
+
${esc(ts)} · ${rel}
+
+
`; + }).join(''); + + content.innerHTML = ` +
+ ⚠️ ${errors.length} error${errors.length !== 1 ? 's' : ''} total +
+
${rows}
`; + + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + +function toggleInboxMsg(el) { + el.classList.toggle('expanded'); +} + +// Edit handlers +function editAgentSoul(agentId) { + document.getElementById('soulView').style.display = 'none'; + document.getElementById('soulEdit').style.display = 'block'; +} + +function cancelEditSoul(agentId) { + document.getElementById('soulView').style.display = 'block'; + document.getElementById('soulEdit').style.display = 'none'; +} + +async function saveAgentSoul(agentId) { + const content = document.getElementById('soulEditArea').value; + const errEl = document.getElementById('soulEditError'); + errEl.style.display = 'none'; + try { + const r = await api(`/api/agents/${agentId}/soul`, { method: 'PUT', body: JSON.stringify({ content }) }); + if (!r.ok) throw new Error(r.error || 'Save failed'); + showToast('soul.md saved'); + await switchAgentTab('soul'); + } catch(e) { + errEl.textContent = e.message; + errEl.style.display = 'block'; + } +} + +function editAgentMemory(agentId) { + document.getElementById('memoryView').style.display = 'none'; + document.getElementById('memoryEdit').style.display = 'block'; +} + +function cancelEditMemory(agentId) { + document.getElementById('memoryView').style.display = 'block'; + document.getElementById('memoryEdit').style.display = 'none'; +} + +async function saveAgentMemory(agentId) { + const content = document.getElementById('memoryEditArea').value; + const errEl = document.getElementById('memoryEditError'); + errEl.style.display = 'none'; + try { + const r = await api(`/api/agents/${agentId}/memory`, { method: 'PUT', body: JSON.stringify({ content }) }); + if (!r.ok) throw new Error(r.error || 'Save failed'); + showToast('memory.md saved'); + await switchAgentTab('memory'); + } catch(e) { + errEl.textContent = e.message; + errEl.style.display = 'block'; + } +} + +async function sendToAgent(agentId) { + const subject = document.getElementById('msgToAgentSubject').value.trim(); + const body = document.getElementById('msgToAgentBody').value.trim(); + const errEl = document.getElementById('sendToAgentError'); + errEl.style.display = 'none'; + if (!body) { errEl.textContent = 'Message body is required'; errEl.style.display = 'block'; return; } + try { + const r = await api(`/api/agents/${agentId}/message`, { + method: 'POST', + body: JSON.stringify({ from: 'rose', type: 'request', subject, content: body }), + }); + if (!r.ok) throw new Error(r.error || 'Send failed'); + showToast('Message sent'); + document.getElementById('msgToAgentSubject').value = ''; + document.getElementById('msgToAgentBody').value = ''; + await switchAgentTab('inbox'); + } catch(e) { + errEl.textContent = e.message; + errEl.style.display = 'block'; + } +} + +async function ackMsg(agentId, msgId) { + try { + await api(`/api/agents/${agentId}/ack/${msgId}`, { method: 'POST' }); + await switchAgentTab('inbox'); + await refreshAgents(); + } catch(e) { + showToast('Ack failed: ' + e.message); + } +} + +async function toggleAgentEnabled(agentId, enable) { + try { + const r = await api(`/api/agents/${agentId}/${enable ? 'enable' : 'disable'}`, { method: 'POST' }); + if (!r.ok) throw new Error(r.error || 'Toggle failed'); + showToast(`Agent ${enable ? 'enabled' : 'disabled'}`); + await openAgentDetail(agentId); + await refreshAgents(); + } catch(e) { + showToast('Error: ' + e.message); + } +} + +function chatWithAgent(agentId) { + localStorage.setItem('hermes.chat_agent', agentId); + closeAgentInbox(); + switchPanel('chat'); } function closeAgentInbox() { @@ -1908,4 +2359,174 @@ function closeAgentInbox() { document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected')); } +// Simple markdown renderer (bold, italic, code, headers, lists, linebreaks) +function renderMarkdown(text) { + if (!text) return ''; + return esc(text) + .replace(/<(\/?)(pre|code|strong|b|em|i|li|ul|ol|h[1-6]|br|p)>/gi, '<$1$2>') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') + .replace(/\n/g, '
    '); +} + +// ── Logs Panel ──────────────────────────────────────────────────────────────── +let _currentLogFile = null; +let _currentLogContent = ''; +let _currentLogLevel = 'all'; +let _currentLogSearch = ''; +let _logAutoRefreshInterval = null; + +async function loadLogsPanel() { + const el = $('logsFileList'); + if (!el) return; + el.innerHTML = '
    Loading...
    '; + try { + const data = await api('/api/logs'); + if (!data.logs) return; + el.innerHTML = ''; + data.logs.forEach(log => { + const item = document.createElement('div'); + item.className = 'logs-sidebar-item' + (log.missing ? ' missing' : '') + (_currentLogFile === log.name ? ' active' : ''); + item.onclick = () => { if (!log.missing) selectLog(log.name); }; + item.innerHTML = ` +
    ${esc(log.name)}
    +
    ${log.missing ? 'Missing' : log.size_human + ' • ' + (log.modified ? _formatDate(log.modified) : 'Unknown')}
    + `; + el.appendChild(item); + }); + } catch(e) { + el.innerHTML = '
    Failed to load logs.
    '; + } +} + +async function selectLog(name) { + _currentLogFile = name; + _currentLogLevel = 'all'; + _currentLogSearch = ''; + $('logsSearchInput').value = ''; + $('logsFileName').textContent = name; + // Show toolbar controls + $('logsSearchInput').style.display = ''; + $('logsLevelBtns').style.display = ''; + $('logsAutoRefreshLabel').style.display = ''; + $('btnRefreshLog').style.display = ''; + $('logsFooter').style.display = ''; + $('logsContent').innerHTML = '
    Loading...
    '; + // Reset level buttons + document.querySelectorAll('.log-level-btn').forEach(b => b.classList.toggle('active', b.dataset.level === 'all')); + try { + const data = await api('/api/logs/' + encodeURIComponent(name)); + _currentLogContent = data.content || ''; + _applyLogFilter(); + } catch(e) { + $('logsContent').innerHTML = '
    Failed to load log.
    '; + } + // Update active state in list + document.querySelectorAll('.logs-sidebar-item').forEach(el => { + el.classList.toggle('active', el.querySelector('.logs-sidebar-name').textContent === name); + }); + // Stop auto-refresh if running for different log + if (_logAutoRefreshInterval) { + clearInterval(_logAutoRefreshInterval); + _logAutoRefreshInterval = null; + } +} + +function _applyLogFilter() { + let content = _currentLogContent; + let lines = content.split('\n'); + + // Filter by level + if (_currentLogLevel !== 'all') { + const levelMap = { ERROR: ['ERROR', 'CRITICAL', 'FATAL'], WARN: ['WARNING', 'WARN'], INFO: ['INFO', 'DEBUG', 'TRACE'] }; + const allowed = levelMap[_currentLogLevel] || [_currentLogLevel]; + lines = lines.filter(line => allowed.some(l => line.toUpperCase().includes(l))); + } + + // Filter by search + if (_currentLogSearch) { + const q = _currentLogSearch.toLowerCase(); + lines = lines.filter(line => line.toLowerCase().includes(q)); + } + + // Render + const html = esc(lines.join('\n')) || '(no matches)'; + $('logsContent').innerHTML = html; + + // Match count + const total = _currentLogContent.split('\n').length; + const shown = lines.length; + $('logsMatchCount').textContent = _currentLogSearch || _currentLogLevel !== 'all' + ? `${shown} of ${total} lines shown` + : `${total} lines`; +} + +function filterLogContent() { + _currentLogSearch = $('logsSearchInput').value; + _applyLogFilter(); +} + +function setLogLevel(level) { + _currentLogLevel = level; + document.querySelectorAll('.log-level-btn').forEach(b => b.classList.toggle('active', b.dataset.level === level)); + _applyLogFilter(); +} + +function toggleLogAutoRefresh() { + const enabled = $('logsAutoRefresh').checked; + if (enabled) { + if (!_logAutoRefreshInterval) { + _logAutoRefreshInterval = setInterval(() => refreshLog(), 5000); + } + } else { + if (_logAutoRefreshInterval) { + clearInterval(_logAutoRefreshInterval); + _logAutoRefreshInterval = null; + } + } +} + +async function refreshLog() { + if (!_currentLogFile) return; + try { + const data = await api('/api/logs/' + encodeURIComponent(_currentLogFile)); + _currentLogContent = data.content || ''; + _applyLogFilter(); + // Auto-scroll to bottom if near bottom + const pre = $('logsContent'); + if (pre.scrollHeight - pre.scrollTop - pre.clientHeight < 100) { + pre.scrollTop = pre.scrollHeight; + } + } catch(e) { + // Silent fail on auto-refresh + } +} + +async function refreshLogManual() { + if (!_currentLogFile) return; + try { + const data = await api('/api/logs/' + encodeURIComponent(_currentLogFile)); + _currentLogContent = data.content || ''; + _applyLogFilter(); + const pre = $('logsContent'); + pre.scrollTop = pre.scrollHeight; + showToast('Log refreshed'); + } catch(e) { + showToast('Refresh failed'); + } +} + +function _formatDate(ts) { + if (!ts) return ''; + const d = new Date(ts * 1000); + return d.toLocaleDateString(undefined, {month:'short', day:'numeric'}) + ' ' + + d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'}); +} + // Event wiring diff --git a/static/style.css b/static/style.css index 0a38751..0d84d50 100644 --- a/static/style.css +++ b/static/style.css @@ -1087,6 +1087,23 @@ body.resizing{user-select:none;cursor:col-resize;} .settings-action-btn:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18);} .settings-action-btn.danger{color:var(--accent);border-color:rgba(233,69,96,.25);} .settings-action-btn.danger:hover{background:rgba(233,69,96,.08);border-color:rgba(233,69,96,.4);} + +.logs-viewer{display:grid;grid-template-columns:200px minmax(0,1fr);gap:0;height:420px;border:1px solid var(--border2);border-radius:8px;overflow:hidden;margin-top:8px;} +.logs-sidebar{background:var(--code-bg);border-right:1px solid var(--border);overflow-y:auto;padding:8px 0;} +.logs-sidebar-item{display:flex;flex-direction:column;gap:2px;padding:8px 12px;cursor:pointer;border-left:3px solid transparent;transition:background .1s,border-color .1s;} +.logs-sidebar-item:hover{background:rgba(255,255,255,.05);} +.logs-sidebar-item.active{background:rgba(124,185,255,.1);border-left-color:var(--accent);} +.logs-sidebar-item.missing{opacity:.4;} +.logs-sidebar-name{font-size:12px;font-weight:600;color:var(--text);} +.logs-sidebar-meta{font-size:10px;color:var(--muted);} +.logs-content{display:flex;flex-direction:column;min-height:0;} +.logs-toolbar{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;border-bottom:1px solid var(--border);background:rgba(255,255,255,.02);gap:8px;} +.logs-toolbar-right{display:flex;align-items:center;gap:6px;flex-wrap:wrap;} +.logs-filename{font-size:12px;font-weight:600;color:var(--text);} +.logs-pre{flex:1;overflow:auto;margin:0;padding:12px;font-family:'Fira Code','Cascadia Code',Monaco,monospace;font-size:11px;line-height:1.6;color:var(--text);background:var(--code-bg);white-space:pre-wrap;word-break:break-all;} +.logs-footer{display:flex;align-items:center;gap:8px;} +.log-level-btn{background:transparent;border:1px solid var(--border2);border-radius:4px;padding:2px 6px;font-size:10px;font-weight:700;cursor:pointer;color:var(--muted);transition:all .1s;} +.log-level-btn:hover,.log-level-btn.active{background:rgba(255,255,255,.08);color:var(--text);} .settings-action-btn:disabled,.settings-action-btn.disabled{opacity:.45;cursor:not-allowed;} .settings-action-btn:disabled:hover,.settings-action-btn.disabled:hover{background:var(--input-bg);border-color:var(--border2);} .settings-field{margin-bottom:16px;} @@ -1280,3 +1297,336 @@ body.resizing{user-select:none;cursor:col-resize;} word-break: break-word; white-space: pre-wrap; } + +/* ── Agent Detail Overlay ── */ +.agent-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); + gap: 12px; +} +.agent-detail-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.agent-detail-status { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 3px; +} +.agent-status-row { + display: flex; + align-items: center; + gap: 6px; +} +.agent-status-dot.lg { + width: 10px; + height: 10px; +} + +/* Toggle switch */ +.agent-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid var(--border); +} +.agent-toggle-btn { + width: 38px; + height: 20px; + border-radius: 10px; + background: var(--border); + border: none; + position: relative; + cursor: pointer; + transition: background 0.2s; + flex-shrink: 0; +} +.agent-toggle-btn.on { + background: var(--accent); +} +.agent-toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + transition: transform 0.2s; + display: block; +} +.agent-toggle-btn.on .agent-toggle-knob { + transform: translateX(18px); +} + +/* Actions bar */ +.agent-detail-actions { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + display: flex; + gap: 8px; +} +.agent-action-btn { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(255,255,255,.04); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.agent-action-btn:hover { + background: rgba(255,255,255,.08); + border-color: var(--accent); +} +.agent-action-btn.primary { + background: rgba(var(--accent-rgb, 80,200,180), .15); + border-color: var(--accent); + color: var(--accent); +} + +/* Tabs */ +.agent-tabs { + display: flex; + border-bottom: 1px solid var(--border); + padding: 0 16px; + gap: 2px; +} +.agent-tab { + padding: 8px 14px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + display: flex; + align-items: center; + gap: 4px; +} +.agent-tab:hover { + color: var(--text); +} +.agent-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* Tab content */ +.agent-tab-content { + overflow-y: auto; + max-height: calc(100vh - 420px); + min-height: 120px; +} + +/* Overview tab */ +.agent-overview { + padding: 4px 0; +} +.agent-info-row { + display: flex; + align-items: center; + padding: 7px 16px; + border-bottom: 1px solid rgba(255,255,255,.04); + gap: 12px; +} +.agent-info-row:last-child { + border-bottom: none; +} +.agent-info-label { + font-size: 10px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .5px; + min-width: 80px; + flex-shrink: 0; +} + +/* soul/memory editor */ +.agent-edit-btn { + float: right; + background: rgba(255,255,255,.06); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 8px; + color: var(--muted); + font-size: 10px; + cursor: pointer; + margin: 12px 16px 0 0; +} +.agent-edit-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.agent-md-content { + padding: 12px 16px; + font-size: 12px; + line-height: 1.7; + color: var(--text); + clear: right; +} +.agent-md-content h2 { + font-size: 15px; + font-weight: 700; + margin: 14px 0 6px; + color: var(--text); +} +.agent-md-content h3 { + font-size: 13px; + font-weight: 700; + margin: 10px 0 4px; +} +.agent-md-content h4 { + font-size: 12px; + font-weight: 700; + margin: 8px 0 4px; +} +.agent-md-content p { + margin: 6px 0; +} +.agent-md-content li { + margin: 3px 0 3px 14px; +} + +/* Badges */ +.agent-tier-badge { + font-size: 8px; + font-weight: 700; + padding: 1px 5px; + border-radius: 4px; + letter-spacing: .3px; +} +.agent-tier-badge.tier-1 { + background: rgba(255,182,193,.2); + color: #ffb6c1; + border: 1px solid rgba(255,182,193,.3); +} +.agent-tier-badge.tier-2 { + background: rgba(255,255,255,.06); + color: var(--muted); + border: 1px solid var(--border); +} +.agent-inbox-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + border-radius: 9px; + background: #ff5722; + color: white; + font-size: 9px; + font-weight: 700; + padding: 0 5px; +} +.agent-inbox-badge.sm { + min-width: 15px; + height: 15px; + border-radius: 7px; + font-size: 8px; +} + +/* Inbox tab */ +.inbox-messages-list { + overflow-y: auto; + max-height: calc(100vh - 480px); +} +.inbox-msg { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; +} +.inbox-msg:hover { + background: rgba(255,255,255,.02); +} +.inbox-msg.unread { + background: rgba(255,152,0,.05); + border-left: 2px solid #ff9800; +} +.inbox-msg.unread:hover { + background: rgba(255,152,0,.08); +} +.inbox-msg-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; +} +.inbox-msg-subject { + font-size: 11px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} +.inbox-msg-body { + font-size: 10px; + color: var(--muted); + line-height: 1.5; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.inbox-msg.expanded .inbox-msg-body { + -webkit-line-clamp: unset; +} +.inbox-msg-actions { + margin-top: 6px; +} + +.activity-list { + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.activity-event-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--card-bg); + border: 1px solid var(--border); + transition: background 0.15s; +} + +.activity-event-row:hover { + background: var(--row-hover); +} + +.error-list { + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.error-event-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(244, 67, 54, 0.05); + border: 1px solid rgba(244, 67, 54, 0.2); + transition: background 0.15s; +} + +.error-event-row:hover { + background: rgba(244, 67, 54, 0.1); +}