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.
This commit is contained in:
Rose
2026-04-20 13:28:37 +02:00
parent 96977b576a
commit fbf79362a4
6 changed files with 1652 additions and 104 deletions

View File

@@ -1,6 +1,6 @@
""" """
Rose Agents Panel API — Data layer for Hermes WebUI Agents extension. 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 import json
@@ -8,6 +8,7 @@ import os
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -20,13 +21,13 @@ _INBOX_BUS = _HERMES_DIR / "scripts" / "message_bus.py"
# ── Tier-2 Agent Registry ────────────────────────────────────────────────────── # ── Tier-2 Agent Registry ──────────────────────────────────────────────────────
TIER2_AGENTS = { TIER2_AGENTS = {
"lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"}, "lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"},
"forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"}, "forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"},
"sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"}, "sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"},
"iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"}, "iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"},
"ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"}, "ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"},
"dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"}, "dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"},
"root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"}, "root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"},
} }
ROSE_META = { ROSE_META = {
@@ -51,8 +52,44 @@ def _get_process_status(agent_name: str) -> dict:
except Exception: except Exception:
return {"running": False, "pid": None} 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: 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: try:
result = subprocess.run( result = subprocess.run(
["/usr/bin/python3", str(_INBOX_BUS), "check", "--agent", agent_name], ["/usr/bin/python3", str(_INBOX_BUS), "check", "--agent", agent_name],
@@ -65,7 +102,8 @@ def _get_inbox_count(agent_name: str) -> int:
pass pass
return 0 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.""" """Read messages from agent inbox."""
inbox_path = _AGENTS_DIR / agent_name / "inbox.json" inbox_path = _AGENTS_DIR / agent_name / "inbox.json"
if not inbox_path.exists(): 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: with open(inbox_path, "r") as f:
data = json.load(f) data = json.load(f)
messages = data if isinstance(data, list) else data.get("messages", []) 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): except (json.JSONDecodeError, IOError):
return [] 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 ───────────────────────────────────────────────────────────── # ── API Functions ─────────────────────────────────────────────────────────────
def list_agents() -> dict: def list_agents() -> dict:
"""Return status for Rose + all Tier-2 agents.""" """Return status summary for Rose + all Tier-2 agents."""
agents = [] agents = []
# Rose (the orchestrator) # Rose (orchestrator — always "running" as it's the gateway itself)
rose_running = True # Rose IS the gateway/webui rose_status = _get_agent_status("rose")
rose_inbox_count = _get_inbox_count("rose") rose_inbox_count = _get_inbox_count("rose")
agents.append({ agents.append({
"id": "rose", "id": "rose",
@@ -94,15 +156,16 @@ def list_agents() -> dict:
"domain": ROSE_META["domain"], "domain": ROSE_META["domain"],
"color": ROSE_META["color"], "color": ROSE_META["color"],
"tier": "orchestrator", "tier": "orchestrator",
"running": rose_running, "status": "active", # Rose is always running
"pid": None, "pid": None,
"last_activity": rose_status.get("last_activity"),
"inbox_count": rose_inbox_count, "inbox_count": rose_inbox_count,
}) })
# Tier-2 agents # Tier-2 agents
for agent_id, meta in TIER2_AGENTS.items(): for agent_id, meta in TIER2_AGENTS.items():
status = _get_process_status(agent_id) status_info = _get_agent_status(agent_id)
inbox_count = _get_inbox_count(agent_id) if status["running"] else 0 inbox_count = _get_inbox_count(agent_id) if status_info["status"] != "offline" else 0
agents.append({ agents.append({
"id": agent_id, "id": agent_id,
"name": meta["name"], "name": meta["name"],
@@ -110,27 +173,203 @@ def list_agents() -> dict:
"domain": meta["domain"], "domain": meta["domain"],
"color": meta["color"], "color": meta["color"],
"tier": "tier2", "tier": "tier2",
"running": status["running"], "status": status_info["status"],
"pid": status["pid"], "pid": status_info["pid"],
"last_activity": status_info.get("last_activity"),
"inbox_count": inbox_count, "inbox_count": inbox_count,
}) })
return {"agents": agents} 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.""" """Return inbox messages for a specific agent."""
if agent_id not in TIER2_AGENTS and agent_id != "rose": if agent_id not in TIER2_AGENTS and agent_id != "rose":
return {"error": f"Unknown agent: {agent_id}"} return {"error": f"Unknown agent: {agent_id}"}
messages = _read_inbox(agent_id, limit) messages = _read_inbox(agent_id, limit)
meta = TIER2_AGENTS.get(agent_id, ROSE_META)
return { return {
"agent_id": agent_id, "agent_id": agent_id,
"agent_name": TIER2_AGENTS.get(agent_id, {}).get("name", "Rose"), "agent_name": meta["name"],
"messages": messages, "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: 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": if agent_id == "rose":
return { return {
"id": "rose", "id": "rose",
@@ -140,11 +379,138 @@ def get_agent_config(agent_id: str) -> dict:
} }
elif agent_id in TIER2_AGENTS: elif agent_id in TIER2_AGENTS:
soul_path = _AGENTS_DIR / agent_id / "soul.md" soul_path = _AGENTS_DIR / agent_id / "soul.md"
memory_path = _AGENTS_DIR / agent_id / "memory.md"
inbox_path = _AGENTS_DIR / agent_id / "inbox.json" inbox_path = _AGENTS_DIR / agent_id / "inbox.json"
return { return {
"id": agent_id, "id": agent_id,
"name": TIER2_AGENTS[agent_id]["name"], "name": TIER2_AGENTS[agent_id]["name"],
"soul_path": str(soul_path) if soul_path.exists() else None, "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), "inbox_path": str(inbox_path),
} }
return {"error": f"Unknown agent: {agent_id}"} 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,
}

View File

@@ -786,6 +786,7 @@ def get_available_models() -> dict:
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY",
"GOOGLE_API_KEY", "GOOGLE_API_KEY",
"GLM_API_KEY", "GLM_API_KEY",
"ZAI_API_KEY",
"KIMI_API_KEY", "KIMI_API_KEY",
"DEEPSEEK_API_KEY", "DEEPSEEK_API_KEY",
"OPENCODE_ZEN_API_KEY", "OPENCODE_ZEN_API_KEY",
@@ -802,7 +803,7 @@ def get_available_models() -> dict:
detected_providers.add("openrouter") detected_providers.add("openrouter")
if all_env.get("GOOGLE_API_KEY"): if all_env.get("GOOGLE_API_KEY"):
detected_providers.add("google") 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") detected_providers.add("zai")
if all_env.get("KIMI_API_KEY"): if all_env.get("KIMI_API_KEY"):
detected_providers.add("kimi-coding") detected_providers.add("kimi-coding")

View File

@@ -58,7 +58,7 @@ from api import agents as _agents
# ── CSRF: validate Origin/Referer on POST ──────────────────────────────────── # ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re import re as _re
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
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).
@@ -471,6 +471,14 @@ def handle_get(handler, parsed) -> bool:
settings.pop("password_hash", None) settings.pop("password_hash", None)
return j(handler, settings) 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": if parsed.path == "/api/onboarding/status":
return j(handler, get_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()}) return j(handler, {"tasks": _mc.get_tasks()})
if parsed.path == "/api/mc/feed": if parsed.path == "/api/mc/feed":
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0]) return j(handler, {"feed": _mc.get_feed(limit=50)})
return j(handler, {"feed": _mc.get_feed(limit=limit)})
# ── Agents API (Rose + Tier-2) ── # ── Agents API (Rose + Tier-2) ──
if parsed.path == "/api/agents": if parsed.path == "/api/agents":
@@ -783,6 +790,75 @@ def handle_get(handler, parsed) -> bool:
agent_id = parsed.path.split("/")[-1] agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent_config(agent_id)) 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) ── # ── Profile API (GET) ──
if parsed.path == "/api/profiles": if parsed.path == "/api/profiles":
from api.profiles import list_profiles_api, get_active_profile_name 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: except RuntimeError as e:
return bad(handler, str(e), 409) 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 ── # ── Gateway API ──
if parsed.path == "/api/gateways": if parsed.path == "/api/gateways":
# GET - list all gateways # GET - list all gateways
@@ -3158,3 +3242,89 @@ def _handle_session_import(handler, body):
SESSIONS.popitem(last=False) SESSIONS.popitem(last=False)
s.save() s.save()
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}) 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),
})

View File

@@ -523,6 +523,10 @@
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg> <svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<span class="settings-tab-title">Gateways</span> <span class="settings-tab-title">Gateways</span>
</button> </button>
<button class="settings-tab" id="settingsTabLogs" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneLogs" onclick="switchSettingsSection('logs')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="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"/></svg>
<span class="settings-tab-title">Logs</span>
</button>
</div> </div>
<div class="settings-main"> <div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation"> <div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
@@ -659,6 +663,42 @@
<div id="gatewaysPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div> <div id="gatewaysPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div> </div>
</div> </div>
<div class="settings-pane" id="settingsPaneLogs" role="tabpanel" aria-labelledby="settingsTabLogs">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Logs</div>
<div class="settings-section-meta">Hermes system logs and error logs.</div>
</div>
</div>
<div class="logs-viewer">
<div class="logs-sidebar">
<div id="logsFileList"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div>
<div class="logs-content">
<div class="logs-toolbar">
<span class="logs-filename" id="logsFileName">Select a log file</span>
<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()">
<div class="logs-level-btns" id="logsLevelBtns" style="display:none">
<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="WARN" onclick="setLogLevel('WARN')" style="color:#e8a030">WARN</button>
<button class="log-level-btn" data-level="INFO" onclick="setLogLevel('INFO')" style="color:#4ade80">INFO</button>
</div>
<label style="display:none;align-items:center;gap:4px;cursor:pointer;font-size:11px;color:var(--muted)" id="logsAutoRefreshLabel">
<input type="checkbox" id="logsAutoRefresh" onchange="toggleLogAutoRefresh()" style="width:13px;height:13px;accent-color:var(--accent)">
Live
</label>
<button class="sm-btn" id="btnRefreshLog" onclick="refreshLogManual()" style="display:none;padding:3px 8px;font-size:11px">Refresh</button>
</div>
</div>
<pre class="logs-pre" id="logsContent"><span style="color:var(--muted);font-size:12px">Select a log file from the list to view its contents.</span></pre>
<div class="logs-footer" id="logsFooter" style="display:none;font-size:10px;color:var(--muted);padding:4px 12px;border-top:1px solid var(--border)">
<span id="logsMatchCount"></span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1211,10 +1211,10 @@ let _settingsThemeOnOpen = null; // track theme at open time for discard revert
let _settingsSection = 'conversation'; let _settingsSection = 'conversation';
function switchSettingsSection(name){ 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; _settingsSection=section;
const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways'}; const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways',logs:'Logs'};
['conversation','preferences','system','gateways'].forEach(key=>{ ['conversation','preferences','system','gateways','logs'].forEach(key=>{
const tab=$('settingsTab'+map[key]); const tab=$('settingsTab'+map[key]);
const pane=$('settingsPane'+map[key]); const pane=$('settingsPane'+map[key]);
const active=key===section; const active=key===section;
@@ -1224,6 +1224,7 @@ function switchSettingsSection(name){
} }
if(pane) pane.classList.toggle('active',active); if(pane) pane.classList.toggle('active',active);
}); });
if(section==='logs') loadLogsPanel();
} }
function _syncHermesPanelSessionActions(){ function _syncHermesPanelSessionActions(){
@@ -1768,6 +1769,22 @@ async function deleteMCPriority(id) {
// ── Agents Panel (Rose + Tier-2) ───────────────────────────────────────────── // ── Agents Panel (Rose + Tier-2) ─────────────────────────────────────────────
let _agentsInterval = null; let _agentsInterval = null;
let _selectedAgent = 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() { async function loadAgentsPanel() {
clearInterval(_agentsInterval); clearInterval(_agentsInterval);
@@ -1790,23 +1807,28 @@ function renderAgentsList(agents) {
if (!box) return; if (!box) return;
const html = agents.map(a => { const html = agents.map(a => {
const statusColor = a.running ? '#4caf50' : '#9e9e9e'; const color = STATUS_COLORS[a.status] || STATUS_COLORS.offline;
const statusLabel = a.running ? 'Active' : 'Inactive'; const label = STATUS_LABELS[a.status] || 'Offline';
const tierBadge = a.tier === 'orchestrator'
? '<span class="agent-tier-badge tier-1">🌹 Tier-1</span>'
: '<span class="agent-tier-badge tier-2">Tier-2</span>';
const inboxBadge = a.inbox_count > 0 const inboxBadge = a.inbox_count > 0
? `<span style="background:#ff5722;color:white;border-radius:10px;padding:1px 6px;font-size:9px;font-weight:600">${a.inbox_count}</span>` ? `<span class="agent-inbox-badge">${a.inbox_count}</span>`
: ''; : '';
return `<div class="agent-card" onclick="selectAgent('${a.id}')" style="cursor:pointer"> const disabled = a.disabled ? 'opacity:0.5;' : '';
return `<div class="agent-card${a.disabled ? ' agent-card-disabled' : ''}" onclick="openAgentDetail('${a.id}')" style="cursor:pointer;${disabled}">
<div class="agent-card-left"> <div class="agent-card-left">
<span style="font-size:24px;line-height:1">${a.emoji}</span> <span style="font-size:28px;line-height:1">${a.emoji}</span>
</div> </div>
<div class="agent-card-body"> <div class="agent-card-body">
<div class="agent-card-name">${esc(a.name)}</div> <div class="agent-card-name">${esc(a.name)}</div>
<div class="agent-card-domain">${esc(a.domain)}</div> <div class="agent-card-domain">${esc(a.domain)}</div>
<div class="agent-card-meta"> <div class="agent-card-meta">
<span class="agent-status-dot" style="background:${statusColor}"></span> <span class="agent-status-dot" style="background:${color}"></span>
<span style="color:${statusColor};font-size:10px">${statusLabel}</span> <span style="color:${color};font-size:10px;font-weight:600">${label}</span>
${a.tier === 'orchestrator' ? '<span style="opacity:0.5;font-size:9px;margin-left:4px">Tier 0</span>' : '<span style="opacity:0.5;font-size:9px;margin-left:4px">Tier 2</span>'} ${tierBadge}
</div> </div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${_relTime(a.last_activity)}</div>
</div> </div>
<div class="agent-card-right"> <div class="agent-card-right">
${inboxBadge} ${inboxBadge}
@@ -1818,88 +1840,517 @@ function renderAgentsList(agents) {
box.innerHTML = html; box.innerHTML = html;
} }
async function selectAgent(agentId) { async function openAgentDetail(agentId) {
_selectedAgent = agentId; _selectedAgent = agentId;
// Highlight selected _agentTab = 'overview';
// Highlight card
document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected')); document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected'));
const cards = document.querySelectorAll('.agent-card'); const cards = document.querySelectorAll('.agent-card');
const agents_data = await api('/api/agents'); const agentsData = await api('/api/agents');
const idx = agents_data.agents.findIndex(a => a.id === agentId); const idx = agentsData.agents.findIndex(a => a.id === agentId);
if (cards[idx]) cards[idx].classList.add('selected'); if (cards[idx]) cards[idx].classList.add('selected');
// Show inbox panel const box = $('agentInbox');
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 || '';
inboxBox.innerHTML = ` // Fetch full agent data
<div class="inbox-header"> let agent;
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="font-size:20px">${emoji}</span>
<div>
<div style="font-weight:600;font-size:13px">${esc(agentName)}</div>
<div style="font-size:10px;opacity:0.5">${esc(domain)}</div>
</div>
<button onclick="closeAgentInbox()" style="margin-left:auto;background:rgba(255,255,255,.05);border:1px solid var(--border);border-radius:6px;padding:4px 8px;cursor:pointer;color:var(--muted);font-size:11px">× Close</button>
</div>
<div style="color:var(--muted);font-size:11px;text-align:center;padding:20px">Loading inbox...</div>
</div>
`;
inboxBox.style.display = 'block';
// Fetch inbox
try { try {
const data = await api(`/api/agents/inbox/${agentId}`); agent = await api(`/api/agents/${agentId}`);
renderAgentInbox(data);
} catch(e) { } catch(e) {
inboxBox.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; box.innerHTML = `<div style="padding:16px;color:var(--accent)">Error loading agent: ${esc(e.message)}</div><div style="padding:16px"><button onclick="closeAgentInbox()" class="cron-btn">Close</button></div>`;
} box.style.display = 'block';
}
function renderAgentInbox(data) {
const inboxBox = $('agentInbox');
const agentName = data.agent_name || _selectedAgent;
const messages = data.messages || [];
if (messages.length === 0) {
inboxBox.innerHTML = `
<div class="inbox-header">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:18px">📭</span>
<span style="font-weight:600;font-size:13px">${esc(agentName)} — Inbox</span>
<button onclick="closeAgentInbox()" style="margin-left:auto;background:rgba(255,255,255,.05);border:1px solid var(--border);border-radius:6px;padding:4px 8px;cursor:pointer;color:var(--muted);font-size:11px">× Close</button>
</div>
</div>
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📭</div>
<div>No messages in inbox</div>
<div style="font-size:10px;margin-top:4px;opacity:0.5">Messages from other agents appear here</div>
</div>
`;
return; return;
} }
inboxBox.innerHTML = ` const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline;
<div class="inbox-header"> const tierBadge = agent.tier === 'orchestrator'
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> ? '<span class="agent-tier-badge tier-1">🌹 Tier-1</span>'
<span style="font-size:18px">📥</span> : '<span class="agent-tier-badge tier-2">Tier-2</span>';
<span style="font-weight:600;font-size:13px">${esc(agentName)} — Inbox</span> const lastAct = agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A';
<span style="opacity:0.5;font-size:10px">(${messages.length} messages)</span> const canEdit = agentId !== 'rose';
<button onclick="closeAgentInbox()" style="margin-left:auto;background:rgba(255,255,255,.05);border:1px solid var(--border);border-radius:6px;padding:4px 8px;cursor:pointer;color:var(--muted);font-size:11px">× Close</button>
box.innerHTML = `
<div class="agent-detail-header">
<div class="agent-detail-title">
<span style="font-size:28px">${agent.emoji}</span>
<div>
<div style="font-weight:700;font-size:15px">${esc(agent.name)}</div>
<div style="font-size:10px;opacity:0.6">${esc(agent.domain)}</div>
<div style="margin-top:4px">${tierBadge}</div>
</div>
</div> </div>
<button onclick="closeAgentInbox()" style="background:rgba(255,255,255,.06);border:1px solid var(--border);border-radius:8px;padding:6px 10px;cursor:pointer;color:var(--muted);font-size:12px;flex-shrink:0">× Close</button>
</div> </div>
<div class="inbox-messages">
${messages.map(m => { <div class="agent-detail-status">
const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : ''; <div class="agent-status-row">
const content = typeof m === 'string' ? m : (m.content || JSON.stringify(m)); <span class="agent-status-dot lg" style="background:${color}"></span>
return `<div class="inbox-msg"> <span style="color:${color};font-weight:600;font-size:12px">${STATUS_LABELS[agent.status] || 'Offline'}</span>
<div class="inbox-msg-ts">${esc(ts)}</div> ${agent.pid ? `<span style="font-size:9px;color:var(--muted);margin-left:4px">PID ${agent.pid}</span>` : ''}
<div class="inbox-msg-content">${esc(String(content).slice(0, 300))}</div> </div>
</div>`; <div style="font-size:10px;color:var(--muted)">Last active: ${esc(lastAct)}</div>
}).join('')} ${agent.default_model ? `<div style="font-size:10px;color:var(--muted)">Model: ${esc(agent.default_model)}</div>` : ''}
</div>
${agentId !== 'rose' ? `
<div class="agent-toggle-row">
<span style="font-size:11px;color:var(--muted)">Agent ${agent.disabled ? 'disabled' : 'enabled'}</span>
<button class="agent-toggle-btn${agent.disabled ? '' : ' on'}" onclick="toggleAgentEnabled('${agentId}', ${!agent.disabled})">
<span class="agent-toggle-knob"></span>
</button>
</div>
` : ''}
<div class="agent-detail-actions">
<button class="agent-action-btn primary" onclick="chatWithAgent('${agentId}')">
💬 Direct Chat
</button>
</div>
<div class="agent-tabs">
<button class="agent-tab${_agentTab==='overview'?' active':''}" onclick="switchAgentTab('overview')">Overview</button>
<button class="agent-tab${_agentTab==='soul'?' active':''}" onclick="switchAgentTab('soul')">soul.md</button>
<button class="agent-tab${_agentTab==='memory'?' active':''}" onclick="switchAgentTab('memory')">memory.md</button>
<button class="agent-tab${_agentTab==='inbox'?' active':''}" onclick="switchAgentTab('inbox')">
Inbox${agent.inbox_count > 0 ? ` <span class="agent-inbox-badge sm">${agent.inbox_count}</span>` : ''}
</button>
<button class="agent-tab${_agentTab==='activity'?' active':''}" onclick="switchAgentTab('activity')">Activity</button>
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
</div>
<div id="agentTabContent" class="agent-tab-content">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">Loading...</div>
</div> </div>
`; `;
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 = `
<div class="agent-overview">
<div class="agent-info-row">
<span class="agent-info-label">Status</span>
<span style="display:flex;align-items:center;gap:4px">
<span class="agent-status-dot" style="background:${color}"></span>
<span style="color:${color};font-size:11px;font-weight:600">${STATUS_LABELS[agent.status] || 'Offline'}</span>
</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Domain</span>
<span style="font-size:11px">${esc(agent.domain)}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Tier</span>
<span style="font-size:11px">${agent.tier === 'orchestrator' ? '🌹 Orchestrator (Tier-1)' : 'Tier-2 Domain Agent'}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Last Active</span>
<span style="font-size:11px">${agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'}</span>
</div>
${agent.default_model ? `
<div class="agent-info-row">
<span class="agent-info-label">Model</span>
<span style="font-size:11px;font-family:monospace">${esc(agent.default_model)}</span>
</div>` : ''}
${agent.inbox_count > 0 ? `
<div class="agent-info-row">
<span class="agent-info-label">Inbox</span>
<span style="font-size:11px"><span class="agent-inbox-badge">${agent.inbox_count}</span> unread messages</span>
</div>` : ''}
${agent.pid ? `
<div class="agent-info-row">
<span class="agent-info-label">Process</span>
<span style="font-size:11px;font-family:monospace">PID ${agent.pid}</span>
</div>` : ''}
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `<div style="padding:16px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:24px;margin-bottom:8px">📄</div>
<div>No soul.md found</div>
</div>`;
return;
}
content.innerHTML = `
<div id="soulView">
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentSoul('${agentId}')">✏️ Edit</button>` : ''}
<div class="agent-md-content">${renderMarkdown(soul)}</div>
</div>
<div id="soulEdit" style="display:none">
<textarea id="soulEditArea" rows="18" style="width:100%;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;padding:10px;font-family:monospace;font-size:11px;line-height:1.6;resize:vertical;outline:none;box-sizing:border-box">${esc(soul)}</textarea>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="cron-btn run" onclick="saveAgentSoul('${agentId}')">💾 Save</button>
<button class="cron-btn" onclick="cancelEditSoul('${agentId}')">Cancel</button>
</div>
<div id="soulEditError" style="color:var(--accent);font-size:11px;margin-top:6px;display:none"></div>
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `<div style="padding:16px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:24px;margin-bottom:8px">🧠</div>
<div>No memory.md found</div>
</div>`;
return;
}
content.innerHTML = `
<div id="memoryView">
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentMemory('${agentId}')">✏️ Edit</button>` : ''}
<div class="agent-md-content">${renderMarkdown(memory)}</div>
</div>
<div id="memoryEdit" style="display:none">
<textarea id="memoryEditArea" rows="18" style="width:100%;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;padding:10px;font-family:monospace;font-size:11px;line-height:1.6;resize:vertical;outline:none;box-sizing:border-box">${esc(memory)}</textarea>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="cron-btn run" onclick="saveAgentMemory('${agentId}')">💾 Save</button>
<button class="cron-btn" onclick="cancelEditMemory('${agentId}')">Cancel</button>
</div>
<div id="memoryEditError" style="color:var(--accent);font-size:11px;margin-top:6px;display:none"></div>
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📭</div>
<div>No messages in inbox</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Messages from other agents appear here</div>
</div>
<div style="padding:12px;border-top:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message</div>
<input id="msgToAgentSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="msgToAgentBody" rows="3" placeholder="Message body..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendToAgent('${agentId}')">Send</button>
<div id="sendToAgentError" style="color:var(--accent);font-size:11px;margin-top:4px;display:none"></div>
</div>
`;
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 `
<div class="inbox-msg${isUnread ? ' unread' : ''}" onclick="toggleInboxMsg(this)">
<div class="inbox-msg-header">
<span style="font-size:9px;font-weight:700;color:${typeColor}">${typeLabel}</span>
<span style="font-size:9px;color:var(--muted)">← ${esc(m.from || 'unknown')}</span>
<span style="font-size:9px;color:var(--muted);margin-left:auto">${esc(ts)}</span>
</div>
<div class="inbox-msg-subject">${esc(m.subject || '(no subject)')}</div>
<div class="inbox-msg-body">${esc(String(m.content || '').slice(0,200))}</div>
<div class="inbox-msg-actions">
${isUnread ? `<button class="cron-btn" style="padding:2px 8px;font-size:9px" onclick="event.stopPropagation();ackMsg('${agentId}','${m.id}')">✓ Acknowledge</button>` : ''}
</div>
</div>`;
}).join('');
content.innerHTML = `
<div class="inbox-messages-list">
${msgsHtml}
</div>
<div style="padding:12px;border-top:1px solid var(--border);margin-top:8px">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message</div>
<input id="msgToAgentSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="msgToAgentBody" rows="3" placeholder="Message body..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendToAgent('${agentId}')">Send</button>
<div id="sendToAgentError" style="color:var(--accent);font-size:11px;margin-top:4px;display:none"></div>
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
async function loadAgentActivity(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/activity`);
const events = data.events || [];
if (events.length === 0) {
content.innerHTML = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📋</div>
<div>No activity recorded yet</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Events like messages, tasks and updates appear here</div>
</div>`;
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 ? `<span style="font-size:10px;color:var(--muted);margin-left:6px">${esc(e.details)}</span>` : '';
return `
<div class="activity-event-row">
<span style="font-size:14px;flex-shrink:0">${icon}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span style="font-size:10px;font-weight:600;color:${color}">${esc(e.type)}</span>
${details}
</div>
<div style="font-size:9px;color:var(--muted)">${esc(ts)} · ${rel}</div>
</div>
</div>`;
}).join('');
content.innerHTML = `<div class="activity-list">${rows}</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
async function loadAgentErrors(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/errors`);
const errors = data.errors || [];
if (errors.length === 0) {
content.innerHTML = `
<div style="padding:24px;text-align:center;color:#4caf50;font-size:12px">
<div style="font-size:28px;margin-bottom:8px">✅</div>
<div>No errors recorded</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">All good — this agent has no logged errors</div>
</div>`;
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 `
<div class="error-event-row">
<span style="font-size:14px;flex-shrink:0">⚠️</span>
<div style="flex:1;min-width:0">
<div style="font-size:11px;color:#f44336;font-weight:600">${esc(e.details || 'Unknown error')}</div>
<div style="font-size:9px;color:var(--muted)">${esc(ts)} · ${rel}</div>
</div>
</div>`;
}).join('');
content.innerHTML = `
<div style="padding:8px 0 8px;font-size:10px;color:#f44336;margin-bottom:4px">
⚠️ ${errors.length} error${errors.length !== 1 ? 's' : ''} total
</div>
<div class="error-list">${rows}</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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() { function closeAgentInbox() {
@@ -1908,4 +2359,174 @@ function closeAgentInbox() {
document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected')); 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(/&lt;(\/?)(pre|code|strong|b|em|i|li|ul|ol|h[1-6]|br|p)&gt;/gi, '<$1$2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code style="background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;font-size:.9em">$1</code>')
.replace(/^### (.+)$/gm, '<h4 style="font-size:12px;font-weight:700;margin:8px 0 4px">$1</h3>')
.replace(/^## (.+)$/gm, '<h3 style="font-size:13px;font-weight:700;margin:10px 0 4px">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 style="font-size:14px;font-weight:700;margin:12px 0 6px">$1</h2>')
.replace(/^- (.+)$/gm, '<li style="margin-left:12px">$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li style="margin-left:12px;list-style:decimal">$2</li>')
.replace(/\n/g, '<br>');
}
// ── 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 = '<div style="color:var(--muted);font-size:12px;padding:8px">Loading...</div>';
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 = `
<div class="logs-sidebar-name">${esc(log.name)}</div>
<div class="logs-sidebar-meta">${log.missing ? 'Missing' : log.size_human + ' • ' + (log.modified ? _formatDate(log.modified) : 'Unknown')}</div>
`;
el.appendChild(item);
});
} catch(e) {
el.innerHTML = '<div style="color:var(--accent);font-size:12px;padding:8px">Failed to load logs.</div>';
}
}
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 = '<div style="color:var(--muted);font-size:12px;padding:12px">Loading...</div>';
// 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 = '<div style="color:var(--accent);font-size:12px">Failed to load log.</div>';
}
// 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')) || '<span style="color:var(--muted)">(no matches)</span>';
$('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 // Event wiring

View File

@@ -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: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{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);} .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,.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-action-btn:disabled:hover,.settings-action-btn.disabled:hover{background:var(--input-bg);border-color:var(--border2);}
.settings-field{margin-bottom:16px;} .settings-field{margin-bottom:16px;}
@@ -1280,3 +1297,336 @@ body.resizing{user-select:none;cursor:col-resize;}
word-break: break-word; word-break: break-word;
white-space: pre-wrap; 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);
}