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:
410
api/agents.py
410
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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
176
api/routes.py
176
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<path>/[^?]*)")
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
<span class="settings-tab-title">Gateways</span>
|
||||
</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 class="settings-main">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
777
static/panels.js
777
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'
|
||||
? '<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
|
||||
? `<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">
|
||||
<span style="font-size:24px;line-height:1">${a.emoji}</span>
|
||||
<span style="font-size:28px;line-height:1">${a.emoji}</span>
|
||||
</div>
|
||||
<div class="agent-card-body">
|
||||
<div class="agent-card-name">${esc(a.name)}</div>
|
||||
<div class="agent-card-domain">${esc(a.domain)}</div>
|
||||
<div class="agent-card-meta">
|
||||
<span class="agent-status-dot" style="background:${statusColor}"></span>
|
||||
<span style="color:${statusColor};font-size:10px">${statusLabel}</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>'}
|
||||
<span class="agent-status-dot" style="background:${color}"></span>
|
||||
<span style="color:${color};font-size:10px;font-weight:600">${label}</span>
|
||||
${tierBadge}
|
||||
</div>
|
||||
<div style="font-size:9px;color:var(--muted);margin-top:2px">${_relTime(a.last_activity)}</div>
|
||||
</div>
|
||||
<div class="agent-card-right">
|
||||
${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 = `
|
||||
<div class="inbox-header">
|
||||
<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
|
||||
// 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 = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
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';
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
<span style="opacity:0.5;font-size:10px">(${messages.length} messages)</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>
|
||||
const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline;
|
||||
const tierBadge = agent.tier === 'orchestrator'
|
||||
? '<span class="agent-tier-badge tier-1">🌹 Tier-1</span>'
|
||||
: '<span class="agent-tier-badge tier-2">Tier-2</span>';
|
||||
const lastAct = agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A';
|
||||
const canEdit = agentId !== 'rose';
|
||||
|
||||
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>
|
||||
<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 class="inbox-messages">
|
||||
${messages.map(m => {
|
||||
const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : '';
|
||||
const content = typeof m === 'string' ? m : (m.content || JSON.stringify(m));
|
||||
return `<div class="inbox-msg">
|
||||
<div class="inbox-msg-ts">${esc(ts)}</div>
|
||||
<div class="inbox-msg-content">${esc(String(content).slice(0, 300))}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
|
||||
<div class="agent-detail-status">
|
||||
<div class="agent-status-row">
|
||||
<span class="agent-status-dot lg" style="background:${color}"></span>
|
||||
<span style="color:${color};font-weight:600;font-size:12px">${STATUS_LABELS[agent.status] || 'Offline'}</span>
|
||||
${agent.pid ? `<span style="font-size:9px;color:var(--muted);margin-left:4px">PID ${agent.pid}</span>` : ''}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted)">Last active: ${esc(lastAct)}</div>
|
||||
${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>
|
||||
`;
|
||||
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() {
|
||||
@@ -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, '<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
|
||||
|
||||
350
static/style.css
350
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user