Phase 4-6: Message Bus, Memory Search (ChromaDB), Token Tracking, Topology Graph
Phase 4: Message Bus Viewer
- Backend: get_message_bus_status(), send_bus_message() in agents.py
- Route: GET /api/agents/message-bus, POST /api/agents/{id}/bus-message
- Frontend: Message Bus tab in agent detail overlay
Phase 5: Memory Search (ChromaDB)
- Backend: _search_agent_memory(), _search_all_agents_memory() via ChromaDB rose_memory collection
- Route: GET /api/agents/memory/search, GET /api/agents/{id}/memory/search
- Frontend: Search bar added to Memory tab, renders confidence scores + topics
Phase 6: Token Tracking + Topology Graph
- Backend: get_agent_usage() reads ~/.hermes/agents/{id}/usage.json
- Route: GET /api/agents/{id}/usage
- Frontend: Usage tab with today/week/month token counts and cost
- Frontend: Topology tab with SVG radial graph of agent network
This commit is contained in:
355
api/agents.py
355
api/agents.py
@@ -14,6 +14,9 @@ from typing import Any
|
|||||||
|
|
||||||
from api.helpers import j
|
from api.helpers import j
|
||||||
|
|
||||||
|
# ChromaDB for memory search
|
||||||
|
import chromadb
|
||||||
|
|
||||||
# ── Paths ──────────────────────────────────────────────────────────────────────
|
# ── Paths ──────────────────────────────────────────────────────────────────────
|
||||||
_HERMES_DIR = Path.home() / ".hermes"
|
_HERMES_DIR = Path.home() / ".hermes"
|
||||||
_AGENTS_DIR = _HERMES_DIR / "agents"
|
_AGENTS_DIR = _HERMES_DIR / "agents"
|
||||||
@@ -516,6 +519,102 @@ def get_agent_errors(agent_id: str, limit: int = 20) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token / Cost Usage Tracking ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_agent_usage(agent_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Read ~/.hermes/agents/{agent_id}/usage.json and compute daily/weekly/monthly totals.
|
||||||
|
Returns {agent_id, today: {tokens, cost}, week: {tokens, cost}, month: {tokens, cost}, history: [...]}.
|
||||||
|
If the usage file doesn't exist, returns zeros for all periods.
|
||||||
|
"""
|
||||||
|
if agent_id not in TIER2_AGENTS and agent_id != "rose":
|
||||||
|
return {"error": f"Unknown agent: {agent_id}"}
|
||||||
|
|
||||||
|
usage_path = _AGENTS_DIR / agent_id / "usage.json"
|
||||||
|
if not usage_path.exists():
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"today": {"tokens": 0, "cost": 0.0},
|
||||||
|
"week": {"tokens": 0, "cost": 0.0},
|
||||||
|
"month": {"tokens": 0, "cost": 0.0},
|
||||||
|
"history": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(usage_path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"today": {"tokens": 0, "cost": 0.0},
|
||||||
|
"week": {"tokens": 0, "cost": 0.0},
|
||||||
|
"month": {"tokens": 0, "cost": 0.0},
|
||||||
|
"history": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
token_usage = data.get("token_usage", [])
|
||||||
|
today_str = datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Compute period boundaries
|
||||||
|
now = datetime.utcnow()
|
||||||
|
week_ago = now - timedelta(days=7)
|
||||||
|
month_ago = now - timedelta(days=30)
|
||||||
|
|
||||||
|
today_tokens = 0
|
||||||
|
today_cost = 0.0
|
||||||
|
week_tokens = 0
|
||||||
|
week_cost = 0.0
|
||||||
|
month_tokens = 0
|
||||||
|
month_cost = 0.0
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for entry in token_usage:
|
||||||
|
date_str = entry.get("date", "")
|
||||||
|
prompt = entry.get("prompt_tokens", 0)
|
||||||
|
completion = entry.get("completion_tokens", 0)
|
||||||
|
cost = entry.get("cost_usd", 0.0)
|
||||||
|
total = prompt + completion
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
history.append({
|
||||||
|
"date": date_str,
|
||||||
|
"prompt_tokens": prompt,
|
||||||
|
"completion_tokens": completion,
|
||||||
|
"total_tokens": total,
|
||||||
|
"cost_usd": cost,
|
||||||
|
})
|
||||||
|
|
||||||
|
if date_str == today_str:
|
||||||
|
today_tokens += total
|
||||||
|
today_cost += cost
|
||||||
|
if entry_date >= week_ago:
|
||||||
|
week_tokens += total
|
||||||
|
week_cost += cost
|
||||||
|
if entry_date >= month_ago:
|
||||||
|
month_tokens += total
|
||||||
|
month_cost += cost
|
||||||
|
|
||||||
|
# Sort history newest-first
|
||||||
|
history.sort(key=lambda x: x["date"], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"today": {"tokens": today_tokens, "cost": round(today_cost, 6)},
|
||||||
|
"week": {"tokens": week_tokens, "cost": round(week_cost, 6)},
|
||||||
|
"month": {"tokens": month_tokens, "cost": round(month_cost, 6)},
|
||||||
|
"history": history,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_usage(agent_id: str) -> dict:
|
||||||
|
"""API: GET /api/agents/{id}/usage — return token/cost usage."""
|
||||||
|
return _get_agent_usage(agent_id)
|
||||||
|
|
||||||
|
|
||||||
# ── Chat History ──────────────────────────────────────────────────────────────
|
# ── Chat History ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_chat_history(agent_id: str, limit: int = 20) -> list[dict]:
|
def _get_chat_history(agent_id: str, limit: int = 20) -> list[dict]:
|
||||||
@@ -712,3 +811,259 @@ def get_agent_tasks(agent_id: str) -> dict:
|
|||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"count": len(tasks),
|
"count": len(tasks),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Message Bus ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_message_bus_overview() -> dict:
|
||||||
|
"""
|
||||||
|
Return a overview of all agent inboxes — message counts and recent messages.
|
||||||
|
"""
|
||||||
|
agents = []
|
||||||
|
for agent_id in TIER2_AGENTS:
|
||||||
|
inbox_path = _AGENTS_DIR / agent_id / "inbox.json"
|
||||||
|
count = 0
|
||||||
|
last_msg = None
|
||||||
|
if inbox_path.exists():
|
||||||
|
try:
|
||||||
|
inbox = json.loads(inbox_path.read_text())
|
||||||
|
messages = inbox.get("messages", [])
|
||||||
|
count = len(messages)
|
||||||
|
if messages:
|
||||||
|
last_msg = {
|
||||||
|
"from": messages[-1].get("from"),
|
||||||
|
"subject": messages[-1].get("subject"),
|
||||||
|
"timestamp": messages[-1].get("timestamp"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
agents.append({
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"name": TIER2_AGENTS[agent_id].get("name", agent_id),
|
||||||
|
"emoji": TIER2_AGENTS[agent_id].get("emoji", "•"),
|
||||||
|
"inbox_count": count,
|
||||||
|
"last_message": last_msg,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Rose inbox
|
||||||
|
rose_inbox_path = _HERMES_DIR / "inbox.json"
|
||||||
|
rose_count = 0
|
||||||
|
rose_last = None
|
||||||
|
if rose_inbox_path.exists():
|
||||||
|
try:
|
||||||
|
inbox = json.loads(rose_inbox_path.read_text())
|
||||||
|
messages = inbox.get("messages", [])
|
||||||
|
rose_count = len(messages)
|
||||||
|
if messages:
|
||||||
|
rose_last = {
|
||||||
|
"from": messages[-1].get("from"),
|
||||||
|
"subject": messages[-1].get("subject"),
|
||||||
|
"timestamp": messages[-1].get("timestamp"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
agents.insert(0, {
|
||||||
|
"agent_id": "rose",
|
||||||
|
"name": "Rose",
|
||||||
|
"emoji": "🌹",
|
||||||
|
"inbox_count": rose_count,
|
||||||
|
"last_message": rose_last,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"agents": agents}
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_bus_overview() -> dict:
|
||||||
|
"""API: GET /api/agents/message-bus — overview of all agent inboxes."""
|
||||||
|
return _get_message_bus_overview()
|
||||||
|
|
||||||
|
|
||||||
|
def send_bus_message(target_agent: str, from_agent: str, subject: str, content: str) -> dict:
|
||||||
|
"""
|
||||||
|
Write a message directly into an agent's inbox.json via the message bus.
|
||||||
|
"""
|
||||||
|
if target_agent not in TIER2_AGENTS and target_agent != "rose":
|
||||||
|
return {"ok": False, "error": f"Unknown agent: {target_agent}"}
|
||||||
|
|
||||||
|
if target_agent == "rose":
|
||||||
|
inbox_path = _HERMES_DIR / "inbox.json"
|
||||||
|
else:
|
||||||
|
inbox_path = _AGENTS_DIR / target_agent / "inbox.json"
|
||||||
|
|
||||||
|
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if inbox_path.exists():
|
||||||
|
inbox = json.loads(inbox_path.read_text())
|
||||||
|
else:
|
||||||
|
inbox = {"messages": []}
|
||||||
|
except Exception:
|
||||||
|
inbox = {"messages": []}
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
msg_id = f"bus_{int(datetime.datetime.now().timestamp() * 1000)}"
|
||||||
|
message = {
|
||||||
|
"id": msg_id,
|
||||||
|
"from": from_agent,
|
||||||
|
"subject": subject,
|
||||||
|
"content": content,
|
||||||
|
"type": "request",
|
||||||
|
"status": "unread",
|
||||||
|
"timestamp": datetime.datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox["messages"].append(message)
|
||||||
|
inbox_path.write_text(json.dumps(inbox, indent=2))
|
||||||
|
|
||||||
|
return {"ok": True, "message_id": msg_id}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_bus_messages(agent_id: str, limit: int = 50) -> dict:
|
||||||
|
"""API: GET /api/agents/{id}/bus-messages — raw inbox messages."""
|
||||||
|
if agent_id not in TIER2_AGENTS and agent_id != "rose":
|
||||||
|
return {"error": f"Unknown agent: {agent_id}"}
|
||||||
|
if agent_id == "rose":
|
||||||
|
inbox_path = _HERMES_DIR / "inbox.json"
|
||||||
|
else:
|
||||||
|
inbox_path = _AGENTS_DIR / agent_id / "inbox.json"
|
||||||
|
if not inbox_path.exists():
|
||||||
|
return {"agent_id": agent_id, "messages": []}
|
||||||
|
try:
|
||||||
|
inbox = json.loads(inbox_path.read_text())
|
||||||
|
messages = inbox.get("messages", [])[-limit:]
|
||||||
|
return {"agent_id": agent_id, "messages": messages}
|
||||||
|
except Exception:
|
||||||
|
return {"agent_id": agent_id, "messages": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Message Bus Viewer ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_message_bus_status() -> dict:
|
||||||
|
"""
|
||||||
|
Return all inboxes across all agents — a complete view of the message bus.
|
||||||
|
Returns dict with each agent and their messages.
|
||||||
|
"""
|
||||||
|
all_agents = list(TIER2_AGENTS.keys()) + ["rose"]
|
||||||
|
bus = {}
|
||||||
|
for agent_id in all_agents:
|
||||||
|
try:
|
||||||
|
messages = _read_inbox(agent_id, limit=100)
|
||||||
|
bus[agent_id] = {
|
||||||
|
"count": len(messages),
|
||||||
|
"messages": messages[-10:] if messages else [], # last 10
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
bus[agent_id] = {"count": 0, "messages": [], "error": True}
|
||||||
|
return {"bus": bus}
|
||||||
|
|
||||||
|
|
||||||
|
def send_bus_message(to_agent: str, from_agent: str, subject: str, content: str, msg_type: str = "request") -> dict:
|
||||||
|
"""
|
||||||
|
Write a message directly to an agent's inbox.json.
|
||||||
|
"""
|
||||||
|
if to_agent not in TIER2_AGENTS and to_agent != "rose":
|
||||||
|
return {"ok": False, "error": f"Unknown agent: {to_agent}"}
|
||||||
|
|
||||||
|
if to_agent == "rose":
|
||||||
|
inbox_path = _HERMES_DIR / "inbox.json"
|
||||||
|
else:
|
||||||
|
inbox_path = _AGENTS_DIR / to_agent / "inbox.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = []
|
||||||
|
if inbox_path.exists():
|
||||||
|
data = json.loads(inbox_path.read_text())
|
||||||
|
if not isinstance(data, list):
|
||||||
|
data = []
|
||||||
|
|
||||||
|
import uuid, datetime
|
||||||
|
msg = {
|
||||||
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
"from": from_agent,
|
||||||
|
"to": to_agent,
|
||||||
|
"type": msg_type,
|
||||||
|
"subject": subject,
|
||||||
|
"content": content,
|
||||||
|
"timestamp": datetime.datetime.now().isoformat() + "Z",
|
||||||
|
"status": "unread",
|
||||||
|
}
|
||||||
|
data.append(msg)
|
||||||
|
inbox_path.write_text(json.dumps(data, indent=2))
|
||||||
|
return {"ok": True, "message_id": msg["id"]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Memory Search (ChromaDB) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_chroma_client():
|
||||||
|
"""Get or create the shared ChromaDB HTTP client (thread-safe singleton)."""
|
||||||
|
if not hasattr(_get_chroma_client, "_client"):
|
||||||
|
_get_chroma_client._client = chromadb.HttpClient(host="127.0.0.1", port=8000)
|
||||||
|
return _get_chroma_client._client
|
||||||
|
|
||||||
|
|
||||||
|
def _search_agent_memory(agent_id: str, query: str, limit: int = 10) -> list:
|
||||||
|
"""
|
||||||
|
Search memory for a specific agent.
|
||||||
|
Searches the rose_memory collection filtered by topic matching agent_id.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = _get_chroma_client()
|
||||||
|
coll = client.get_collection(name="rose_memory")
|
||||||
|
results = coll.query(
|
||||||
|
query_texts=[query],
|
||||||
|
n_results=limit,
|
||||||
|
include=["metadatas", "documents"],
|
||||||
|
)
|
||||||
|
matches = []
|
||||||
|
for i, doc in enumerate(results.get("documents", [[]])[0] or []):
|
||||||
|
meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {}
|
||||||
|
topic = meta.get("topic", "")
|
||||||
|
if not topic.startswith(agent_id):
|
||||||
|
continue
|
||||||
|
matches.append({
|
||||||
|
"id": (results.get("ids", [["?"]])[0] or ["?"])[i],
|
||||||
|
"topic": topic,
|
||||||
|
"content": doc,
|
||||||
|
"confidence": float(meta.get("confidence", 0.0)),
|
||||||
|
"tags": meta.get("tags", ""),
|
||||||
|
"vault_path": meta.get("vault_path", ""),
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _search_all_agents_memory(query: str, limit: int = 20) -> list:
|
||||||
|
"""
|
||||||
|
Search across all agent memories in ChromaDB.
|
||||||
|
Returns matches with agent attribution from topic.
|
||||||
|
Topic format: "agent-name/fact-name" or flat topic name.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = _get_chroma_client()
|
||||||
|
coll = client.get_collection(name="rose_memory")
|
||||||
|
results = coll.query(
|
||||||
|
query_texts=[query],
|
||||||
|
n_results=limit,
|
||||||
|
include=["metadatas", "documents"],
|
||||||
|
)
|
||||||
|
matches = []
|
||||||
|
for i, doc in enumerate(results.get("documents", [[]])[0] or []):
|
||||||
|
meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {}
|
||||||
|
topic = meta.get("topic", "")
|
||||||
|
parts = topic.split("/")
|
||||||
|
agent = parts[0] if len(parts) > 1 else topic
|
||||||
|
matches.append({
|
||||||
|
"id": (results.get("ids", [["?"]])[0] or ["?"])[i],
|
||||||
|
"topic": topic,
|
||||||
|
"agent": agent,
|
||||||
|
"content": doc,
|
||||||
|
"confidence": float(meta.get("confidence", 0.0)),
|
||||||
|
"tags": meta.get("tags", ""),
|
||||||
|
"vault_path": meta.get("vault_path", ""),
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|||||||
80
api/agents_memory.py
Normal file
80
api/agents_memory.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Phase 5 — Memory Search (ChromaDB)
|
||||||
|
Appended to agents.py functions for Memory Search.
|
||||||
|
"""
|
||||||
|
import chromadb
|
||||||
|
|
||||||
|
|
||||||
|
def _get_chroma_client():
|
||||||
|
"""Get or create the shared ChromaDB HTTP client (thread-safe singleton)."""
|
||||||
|
if not hasattr(_get_chroma_client, "_client"):
|
||||||
|
_get_chroma_client._client = chromadb.HttpClient(host="127.0.0.1", port=8000)
|
||||||
|
return _get_chroma_client._client
|
||||||
|
|
||||||
|
|
||||||
|
def _search_agent_memory(agent_id: str, query: str, limit: int = 10) -> list:
|
||||||
|
"""
|
||||||
|
Search memory for a specific agent.
|
||||||
|
Searches the rose_memory collection filtered by topic matching agent_id.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = _get_chroma_client()
|
||||||
|
coll = client.get_collection(name="rose_memory")
|
||||||
|
results = coll.query(
|
||||||
|
query_texts=[query],
|
||||||
|
n_results=limit,
|
||||||
|
include=["metadatas", "documents"],
|
||||||
|
)
|
||||||
|
matches = []
|
||||||
|
for i, doc in enumerate(results.get("documents", [[]])[0] or []):
|
||||||
|
meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {}
|
||||||
|
topic = meta.get("topic", "")
|
||||||
|
# Filter: only docs that belong to this agent (topic starts with agent_id)
|
||||||
|
if not topic.startswith(agent_id):
|
||||||
|
continue
|
||||||
|
matches.append({
|
||||||
|
"id": (results.get("ids", [["?"]])[0] or ["?"])[i],
|
||||||
|
"topic": topic,
|
||||||
|
"content": doc,
|
||||||
|
"confidence": float(meta.get("confidence", 0.0)),
|
||||||
|
"tags": meta.get("tags", ""),
|
||||||
|
"vault_path": meta.get("vault_path", ""),
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _search_all_agents_memory(query: str, limit: int = 20) -> list:
|
||||||
|
"""
|
||||||
|
Search across all agent memories in ChromaDB.
|
||||||
|
Returns matches with agent attribution from topic.
|
||||||
|
Topic format: "agent-name/fact-name" or flat topic name.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = _get_chroma_client()
|
||||||
|
coll = client.get_collection(name="rose_memory")
|
||||||
|
results = coll.query(
|
||||||
|
query_texts=[query],
|
||||||
|
n_results=limit,
|
||||||
|
include=["metadatas", "documents"],
|
||||||
|
)
|
||||||
|
matches = []
|
||||||
|
for i, doc in enumerate(results.get("documents", [[]])[0] or []):
|
||||||
|
meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {}
|
||||||
|
topic = meta.get("topic", "")
|
||||||
|
# Extract agent from topic: "agent-name/ffact" -> agent
|
||||||
|
parts = topic.split("/")
|
||||||
|
agent = parts[0] if len(parts) > 1 else topic
|
||||||
|
matches.append({
|
||||||
|
"id": (results.get("ids", [["?"]])[0] or ["?"])[i],
|
||||||
|
"topic": topic,
|
||||||
|
"agent": agent,
|
||||||
|
"content": doc,
|
||||||
|
"confidence": float(meta.get("confidence", 0.0)),
|
||||||
|
"tags": meta.get("tags", ""),
|
||||||
|
"vault_path": meta.get("vault_path", ""),
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
@@ -859,6 +859,13 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
|
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
|
||||||
return j(handler, _agents.get_agent_errors(agent_id, limit=limit))
|
return j(handler, _agents.get_agent_errors(agent_id, limit=limit))
|
||||||
|
|
||||||
|
# GET /api/agents/{id}/usage
|
||||||
|
if parsed.path.startswith("/api/agents/") and "/usage" in parsed.path:
|
||||||
|
parts = parsed.path.split("/")
|
||||||
|
if len(parts) == 5 and parts[4] == "usage":
|
||||||
|
agent_id = parts[3]
|
||||||
|
return j(handler, _agents.get_agent_usage(agent_id))
|
||||||
|
|
||||||
# GET /api/agents/{id}/chat-history
|
# GET /api/agents/{id}/chat-history
|
||||||
if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path:
|
if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path:
|
||||||
parts = parsed.path.split("/")
|
parts = parsed.path.split("/")
|
||||||
@@ -881,6 +888,38 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
agent_id = parts[3]
|
agent_id = parts[3]
|
||||||
return j(handler, _agents.get_agent_tasks(agent_id))
|
return j(handler, _agents.get_agent_tasks(agent_id))
|
||||||
|
|
||||||
|
# GET /api/agents/message-bus — all inboxes at once
|
||||||
|
if parsed.path == "/api/agents/message-bus":
|
||||||
|
return j(handler, _agents.get_message_bus_status())
|
||||||
|
|
||||||
|
# POST /api/agents/{id}/bus-message — send message to agent via bus
|
||||||
|
if parsed.path.startswith("/api/agents/") and "/bus-message" in parsed.path:
|
||||||
|
parts = parsed.path.split("/")
|
||||||
|
if len(parts) == 5 and parts[4] == "bus-message":
|
||||||
|
agent_id = parts[3]
|
||||||
|
data = read_body(handler)
|
||||||
|
result = _agents.send_bus_message(
|
||||||
|
to_agent=agent_id,
|
||||||
|
from_agent=data.get("from_agent", "rose"),
|
||||||
|
subject=data.get("subject", ""),
|
||||||
|
content=data.get("content", ""),
|
||||||
|
msg_type=data.get("type", "request"),
|
||||||
|
)
|
||||||
|
return j(handler, result)
|
||||||
|
|
||||||
|
# GET /api/agents/memory/search?q= — search all agents
|
||||||
|
if parsed.path == "/api/agents/memory/search":
|
||||||
|
return _handle_memory_search(handler, parsed, agent_id=None)
|
||||||
|
|
||||||
|
# GET /api/agents/{id}/memory/search?q= — search specific agent
|
||||||
|
_mem_match = None
|
||||||
|
if parsed.path.startswith("/api/agents/") and "/memory/search" in parsed.path:
|
||||||
|
parts = parsed.path.split("/")
|
||||||
|
if len(parts) == 6 and parts[5] == "search":
|
||||||
|
_mem_match = parts[3]
|
||||||
|
if _mem_match:
|
||||||
|
return _handle_memory_search(handler, parsed, agent_id=_mem_match)
|
||||||
|
|
||||||
# ── 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
|
||||||
@@ -3350,3 +3389,25 @@ def _handle_logs_read(handler, log_name):
|
|||||||
"line_count": len(lines),
|
"line_count": len(lines),
|
||||||
"tail_count": len(tail),
|
"tail_count": len(tail),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Memory Search ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_memory_search(handler, parsed, agent_id=None) -> bytes:
|
||||||
|
"""
|
||||||
|
GET /api/agents/memory/search?q=query — all agents
|
||||||
|
GET /api/agents/{id}/memory/search?q=query — specific agent
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
query = " ".join(qs.get("q", [""])).strip()
|
||||||
|
limit = int(" ".join(qs.get("limit", ["20"])).strip())
|
||||||
|
if not query:
|
||||||
|
return bad(handler, "q parameter required")
|
||||||
|
if agent_id:
|
||||||
|
results = _agents._search_agent_memory(agent_id, query, limit=limit)
|
||||||
|
else:
|
||||||
|
results = _agents._search_all_agents_memory(query, limit=limit)
|
||||||
|
return j(handler, {"query": query, "results": results, "count": len(results)})
|
||||||
|
except Exception as e:
|
||||||
|
return bad(handler, str(e))
|
||||||
|
|||||||
332
static/panels.js
332
static/panels.js
@@ -1919,6 +1919,9 @@ async function openAgentDetail(agentId) {
|
|||||||
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
|
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
|
||||||
<button class="agent-tab${_agentTab==='chat'?' active':''}" onclick="switchAgentTab('chat')">Chat History</button>
|
<button class="agent-tab${_agentTab==='chat'?' active':''}" onclick="switchAgentTab('chat')">Chat History</button>
|
||||||
<button class="agent-tab${_agentTab==='tasks'?' active':''}" onclick="switchAgentTab('tasks')">Tasks</button>
|
<button class="agent-tab${_agentTab==='tasks'?' active':''}" onclick="switchAgentTab('tasks')">Tasks</button>
|
||||||
|
<button class="agent-tab${_agentTab==='bus'?' active':''}" onclick="switchAgentTab('bus')">Message Bus</button>
|
||||||
|
<button class="agent-tab${_agentTab==='usage'?' active':''}" onclick="switchAgentTab('usage')">Usage</button>
|
||||||
|
<button class="agent-tab${_agentTab==='topology'?' active':''}" onclick="switchAgentTab('topology')">Topology</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="agentTabContent" class="agent-tab-content">
|
<div id="agentTabContent" class="agent-tab-content">
|
||||||
@@ -1938,7 +1941,7 @@ async function switchAgentTab(tab) {
|
|||||||
|
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
document.querySelectorAll('.agent-tab').forEach((el, i) => {
|
document.querySelectorAll('.agent-tab').forEach((el, i) => {
|
||||||
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks'];
|
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks', 'bus', 'usage', 'topology'];
|
||||||
el.classList.toggle('active', tabs[i] === tab);
|
el.classList.toggle('active', tabs[i] === tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1969,6 +1972,15 @@ async function switchAgentTab(tab) {
|
|||||||
case 'tasks':
|
case 'tasks':
|
||||||
await loadAgentTasks(agentId, content);
|
await loadAgentTasks(agentId, content);
|
||||||
break;
|
break;
|
||||||
|
case 'bus':
|
||||||
|
await loadAgentBus(agentId, content);
|
||||||
|
break;
|
||||||
|
case 'usage':
|
||||||
|
await loadAgentUsage(agentId, content);
|
||||||
|
break;
|
||||||
|
case 'topology':
|
||||||
|
await loadAgentTopology(agentId, content);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2085,20 +2097,21 @@ async function loadAgentSoul(agentId, content) {
|
|||||||
|
|
||||||
async function loadAgentMemory(agentId, content) {
|
async function loadAgentMemory(agentId, content) {
|
||||||
const canEdit = agentId !== 'rose';
|
const canEdit = agentId !== 'rose';
|
||||||
|
// Fetch memory.md + render search bar
|
||||||
try {
|
try {
|
||||||
const agent = await api(`/api/agents/${agentId}`);
|
const agent = await api(`/api/agents/${agentId}`);
|
||||||
const memory = agent.memory || '';
|
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 = `
|
content.innerHTML = `
|
||||||
|
<div style="padding:0 0 8px;border-bottom:1px solid var(--border);margin-bottom:8px">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<input id="memSearchInput" placeholder="Search memory... (ChromaDB)" style="flex:1;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;outline:none" onkeydown="if(event.key==='Enter')searchAgentMemory('${agentId}')">
|
||||||
|
<button class="cron-btn run" style="flex-shrink:0;padding:5px 10px" onclick="searchAgentMemory('${agentId}')">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="memSearchResults" style="display:none;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
<div id="memoryView">
|
<div id="memoryView">
|
||||||
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentMemory('${agentId}')">✏️ Edit</button>` : ''}
|
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentMemory('${agentId}')">✏️ Edit</button>` : ''}
|
||||||
<div class="agent-md-content">${renderMarkdown(memory)}</div>
|
<div class="agent-md-content">${memory ? renderMarkdown(memory) : '<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px">No memory.md found</div>'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="memoryEdit" style="display:none">
|
<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>
|
<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>
|
||||||
@@ -2114,6 +2127,40 @@ async function loadAgentMemory(agentId, content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchAgentMemory(agentId) {
|
||||||
|
const q = document.getElementById('memSearchInput').value.trim();
|
||||||
|
const resultsBox = document.getElementById('memSearchResults');
|
||||||
|
if (!q) return;
|
||||||
|
resultsBox.style.display = '';
|
||||||
|
resultsBox.innerHTML = `<div style="font-size:10px;color:var(--muted);padding:4px 0">Searching...</div>`;
|
||||||
|
try {
|
||||||
|
const endpoint = agentId === 'rose'
|
||||||
|
? `/api/agents/memory/search?q=${encodeURIComponent(q)}`
|
||||||
|
: `/api/agents/${agentId}/memory/search?q=${encodeURIComponent(q)}`;
|
||||||
|
const data = await api(endpoint);
|
||||||
|
const results = data.results || [];
|
||||||
|
if (!results.length) {
|
||||||
|
resultsBox.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:4px 0">No results for "${esc(q)}"</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultsBox.innerHTML = `
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">${results.length} result${results.length!==1?'s':''}</div>
|
||||||
|
${results.map(r => `
|
||||||
|
<div style="border:1px solid var(--border);border-radius:8px;padding:8px;margin-bottom:6px;background:rgba(255,255,255,.02)">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:var(--blue)">${esc(r.topic)}</span>
|
||||||
|
<span style="font-size:9px;color:var(--muted);opacity:.7">${(r.confidence*100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-bottom:4px">${r.agent ? 'Agent: '+esc(r.agent)+' · ' : ''}Topic: ${esc(r.topic)}</div>
|
||||||
|
<div style="font-size:10px;color:var(--text);line-height:1.4;max-height:60px;overflow:hidden">${esc((r.content||'').slice(0,200))}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
} catch(e) {
|
||||||
|
resultsBox.innerHTML = `<div style="font-size:11px;color:var(--accent)">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAgentInboxTab(agentId, content) {
|
async function loadAgentInboxTab(agentId, content) {
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/agents/${agentId}/inbox`);
|
const data = await api(`/api/agents/${agentId}/inbox`);
|
||||||
@@ -2377,6 +2424,223 @@ async function loadAgentTasks(agentId, content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAgentBus(agentId, content) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/agents/message-bus`);
|
||||||
|
const bus = data.bus || {};
|
||||||
|
|
||||||
|
// Collect all messages across agents, filter to those involving agentId
|
||||||
|
const allMsgs = [];
|
||||||
|
for (const [aId, aData] of Object.entries(bus)) {
|
||||||
|
const msgs = aData.messages || [];
|
||||||
|
for (const m of msgs) {
|
||||||
|
if (m.from === agentId || m.to === agentId) {
|
||||||
|
allMsgs.push({ ...m, _agent: aId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort newest first
|
||||||
|
allMsgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
||||||
|
|
||||||
|
if (allMsgs.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 the bus</div>
|
||||||
|
<div style="font-size:10px;margin-top:4px;opacity:0.6">Messages between agents appear here</div>
|
||||||
|
</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 via Bus</div>
|
||||||
|
<input id="busSubject" 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="busContent" rows="3" placeholder="Message content..." 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="sendBusMessage('${agentId}')">Send via Bus</button>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = allMsgs.map(m => {
|
||||||
|
const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : 'N/A';
|
||||||
|
const rel = m.timestamp ? _relTime(m.timestamp) : '';
|
||||||
|
const isOutgoing = m.from === agentId;
|
||||||
|
const dirIcon = isOutgoing ? '📤' : '📥';
|
||||||
|
const dirLabel = isOutgoing ? `→ ${m.to}` : `← ${m.from}`;
|
||||||
|
const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50';
|
||||||
|
return `
|
||||||
|
<div class="bus-msg${isOutgoing ? ' outgoing' : ''}">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||||||
|
<span style="font-size:12px">${dirIcon}</span>
|
||||||
|
<span style="font-size:9px;font-weight:700;color:${typeColor}">${esc(m.type || '').toUpperCase()}</span>
|
||||||
|
<span style="font-size:9px;color:var(--muted)">${dirLabel}</span>
|
||||||
|
<span style="font-size:9px;color:var(--muted);margin-left:auto">${esc(ts)} · ${rel}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;font-weight:500;margin-bottom:2px">${esc(m.subject || '(no subject)')}</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted)">${esc(String(m.content || '').slice(0, 120))}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="bus-list">${rows}</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 via Bus</div>
|
||||||
|
<input id="busSubject" 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="busContent" rows="3" placeholder="Message content..." 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="sendBusMessage('${agentId}')">Send via Bus</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBusMessage(toAgent) {
|
||||||
|
const subject = document.getElementById('busSubject').value.trim();
|
||||||
|
const content = document.getElementById('busContent').value.trim();
|
||||||
|
if (!subject && !content) {
|
||||||
|
showToast('Please enter a subject or message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api(`/api/agents/${toAgent}/bus-message`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ from_agent: 'rose', subject, content }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(r.error || 'Send failed');
|
||||||
|
showToast('Message sent via bus');
|
||||||
|
document.getElementById('busSubject').value = '';
|
||||||
|
document.getElementById('busContent').value = '';
|
||||||
|
// Refresh
|
||||||
|
await switchAgentTab('bus');
|
||||||
|
} catch(e) {
|
||||||
|
showToast('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgentTopology(agentId, content) {
|
||||||
|
const agents = [
|
||||||
|
{ id: 'rose', name: 'rose', emoji: '🌹', tier: 'orchestrator', x: 0, y: 0 },
|
||||||
|
{ id: 'lotus', name: 'lotus', emoji: '🪷', tier: 'tier2', x: 0, y: -1 },
|
||||||
|
{ id: 'forget-me-not', name: 'forget-me-not', emoji: '🌼', tier: 'tier2', x: 0.7, y: -0.7 },
|
||||||
|
{ id: 'sunflower', name: 'sunflower', emoji: '🌻', tier: 'tier2', x: -1, y: 0 },
|
||||||
|
{ id: 'iris', name: 'iris', emoji: '⚜️', tier: 'tier2', x: 0, y: 1 },
|
||||||
|
{ id: 'ivy', name: 'ivy', emoji: '🌿', tier: 'tier2', x: -0.7, y: 0.7 },
|
||||||
|
{ id: 'dandelion', name: 'dandelion', emoji: '🛡️', tier: 'tier2', x: 0.7, y: 0.7 },
|
||||||
|
{ id: 'root', name: 'root', emoji: '🌳', tier: 'tier2', x: 0, y: 0.7 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const connections = [
|
||||||
|
{ from: 'rose', to: 'lotus' },
|
||||||
|
{ from: 'rose', to: 'forget-me-not' },
|
||||||
|
{ from: 'rose', to: 'sunflower' },
|
||||||
|
{ from: 'rose', to: 'iris' },
|
||||||
|
{ from: 'rose', to: 'ivy' },
|
||||||
|
{ from: 'rose', to: 'dandelion' },
|
||||||
|
{ from: 'rose', to: 'root' },
|
||||||
|
{ from: 'lotus', to: 'forget-me-not' },
|
||||||
|
{ from: 'sunflower', to: 'lotus' },
|
||||||
|
{ from: 'iris', to: 'rose' },
|
||||||
|
{ from: 'ivy', to: 'rose' },
|
||||||
|
{ from: 'dandelion', to: 'rose' },
|
||||||
|
{ from: 'root', to: 'rose' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const scale = 80;
|
||||||
|
let svg = '<svg viewBox="-200 -160 400 320" style="width:100%;max-width:500px;display:block;margin:0 auto;background:transparent">';
|
||||||
|
svg += '<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>';
|
||||||
|
|
||||||
|
connections.forEach(conn => {
|
||||||
|
const from = agents.find(a => a.id === conn.from);
|
||||||
|
const to = agents.find(a => a.id === conn.to);
|
||||||
|
if (from && to) {
|
||||||
|
svg += '<line x1="' + (from.x * scale) + '" y1="' + (from.y * scale) + '" x2="' + (to.x * scale) + '" y2="' + (to.y * scale) + '" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
agents.forEach(agent => {
|
||||||
|
const px = agent.x * scale;
|
||||||
|
const py = agent.y * scale;
|
||||||
|
const isRose = agent.tier === 'orchestrator';
|
||||||
|
const color = isRose ? '#F5C542' : '#5B8FA8';
|
||||||
|
const r = 28;
|
||||||
|
const isActive = agentId === agent.id;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + (r + 8) + '" fill="' + color + '" opacity="0.3"><animate attributeName="r" values="' + (r+8) + ';' + (r+14) + ';' + (r+8) + '" dur="2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.3;0.15;0.3" dur="2s" repeatCount="indefinite"/></circle>';
|
||||||
|
}
|
||||||
|
|
||||||
|
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + r + '" fill="' + color + '" stroke="' + (isActive ? '#fff' : 'rgba(255,255,255,0.3)') + '" stroke-width="' + (isActive ? 2 : 1) + '" filter="' + (isActive ? 'url(#glow)' : '') + '"/>';
|
||||||
|
svg += '<text x="' + px + '" y="' + (py + 5) + '" text-anchor="middle" font-size="16">' + agent.emoji + '</text>';
|
||||||
|
svg += '<text x="' + px + '" y="' + (py + r + 14) + '" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.7)" font-family="system-ui,sans-serif">' + agent.name + '</text>';
|
||||||
|
});
|
||||||
|
|
||||||
|
svg += '</svg>';
|
||||||
|
content.innerHTML = '<div class="topology-view">' + svg + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgentUsage(agentId, content) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/agents/${agentId}/usage`);
|
||||||
|
if (data.error) {
|
||||||
|
content.innerHTML = `<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">${esc(data.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n) => n.toLocaleString();
|
||||||
|
const fmtCost = (c) => '$' + c.toFixed(4);
|
||||||
|
|
||||||
|
const historyRows = (data.history || []).slice(0, 14).map(h => `
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:10px;padding:4px 8px">${esc(h.date)}</td>
|
||||||
|
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.total_tokens || 0)}</td>
|
||||||
|
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.prompt_tokens || 0)}</td>
|
||||||
|
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.completion_tokens || 0)}</td>
|
||||||
|
<td style="font-size:10px;padding:4px 8px;text-align:right;color:#4caf50">${fmtCost(h.cost_usd || 0)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="padding:16px">
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
|
||||||
|
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
|
||||||
|
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">Today</div>
|
||||||
|
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.today.tokens)}</div>
|
||||||
|
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.today.cost)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
|
||||||
|
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">This Week</div>
|
||||||
|
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.week.tokens)}</div>
|
||||||
|
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.week.cost)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
|
||||||
|
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">This Month</div>
|
||||||
|
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.month.tokens)}</div>
|
||||||
|
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.month.cost)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${historyRows ? `
|
||||||
|
<div style="font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;margin-bottom:8px">Recent History</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<th style="text-align:left;font-size:9px;padding:4px 8px;color:var(--muted)">Date</th>
|
||||||
|
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Total</th>
|
||||||
|
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Prompt</th>
|
||||||
|
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Completion</th>
|
||||||
|
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${historyRows}</tbody>
|
||||||
|
</table>` : `
|
||||||
|
<div style="text-align:center;padding:24px;color:var(--muted);font-size:12px">
|
||||||
|
<div style="font-size:28px;margin-bottom:8px">📊</div>
|
||||||
|
<div>No usage data recorded yet</div>
|
||||||
|
<div style="font-size:10px;margin-top:4px;opacity:0.6">Token usage will appear here once recorded</div>
|
||||||
|
</div>`}
|
||||||
|
</div>`;
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="padding:24px;text-align:center;color:#e94560;font-size:12px">Failed to load usage: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Edit handlers
|
// Edit handlers
|
||||||
function editAgentSoul(agentId) {
|
function editAgentSoul(agentId) {
|
||||||
document.getElementById('soulView').style.display = 'none';
|
document.getElementById('soulView').style.display = 'none';
|
||||||
@@ -2575,21 +2839,53 @@ function _applyLogFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search
|
// Filter by search
|
||||||
if (_currentLogSearch) {
|
const searchLower = _currentLogSearch ? _currentLogSearch.toLowerCase() : '';
|
||||||
const q = _currentLogSearch.toLowerCase();
|
if (searchLower) {
|
||||||
lines = lines.filter(line => line.toLowerCase().includes(q));
|
lines = lines.filter(line => line.toLowerCase().includes(searchLower));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render
|
// Render with highlighting
|
||||||
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 total = _currentLogContent.split('\n').length;
|
||||||
const shown = lines.length;
|
const shown = lines.length;
|
||||||
$('logsMatchCount').textContent = _currentLogSearch || _currentLogLevel !== 'all'
|
$('logsMatchCount').textContent = searchLower || _currentLogLevel !== 'all'
|
||||||
? `${shown} of ${total} lines shown`
|
? `${shown} of ${total} lines shown`
|
||||||
: `${total} lines`;
|
: `${total} lines`;
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
$('logsContent').innerHTML = '<span style="color:var(--muted)">(no matches)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape first, then highlight search terms
|
||||||
|
const highlighted = lines.map((line, i) => {
|
||||||
|
const escaped = esc(line) || ' ';
|
||||||
|
if (searchLower) {
|
||||||
|
// Highlight all occurrences of search term (case-insensitive, preserves case in display)
|
||||||
|
const regex = new RegExp(escRegex(_currentLogSearch), 'gi');
|
||||||
|
return `<span class="log-line" data-idx="${i}">${escaped.replace(regex, m => `<mark class="log-highlight">${m}</mark>`)}</span>`;
|
||||||
|
}
|
||||||
|
return `<span class="log-line" data-idx="${i}">${escaped}</span>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
$('logsContent').innerHTML = highlighted;
|
||||||
|
|
||||||
|
// Auto-scroll to first match when searching
|
||||||
|
if (searchLower) {
|
||||||
|
const pre = $('logsContent');
|
||||||
|
const first = pre.querySelector('.log-highlight');
|
||||||
|
if (first) {
|
||||||
|
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal: scroll to bottom (newest)
|
||||||
|
const pre = $('logsContent');
|
||||||
|
pre.scrollTop = pre.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape special regex characters
|
||||||
|
function escRegex(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterLogContent() {
|
function filterLogContent() {
|
||||||
|
|||||||
@@ -1101,6 +1101,8 @@ body.resizing{user-select:none;cursor:col-resize;}
|
|||||||
.logs-toolbar-right{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
.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-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-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;}
|
||||||
|
mark.log-highlight{background:rgba(255,220,50,.35);color:inherit;border-radius:2px;padding:0 1px;}
|
||||||
|
.log-line{display:block;}
|
||||||
.logs-footer{display:flex;align-items:center;gap:8px;}
|
.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{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);}
|
.log-level-btn:hover,.log-level-btn.active{background:rgba(255,255,255,.08);color:var(--text);}
|
||||||
|
|||||||
Reference in New Issue
Block a user